mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-25 08:48:13 +02:00
feat: add Tuner Integration to Dograh (#311)
* Add tuner integration * bump pipecat version * chore: update pipecat submodule to match upstream and use tuner-pipecat-sdk 0.2.0 Update pipecat submodule from 0.0.109.dev23 to 13e98d0d9 (the exact commit upstream dograh-hq/dograh uses after v1.30.1). This installs pipecat-ai as 1.1.0.post277 via setuptools_scm, satisfying tuner-pipecat-sdk 0.2.0's pipecat-ai>=1.0.0 requirement. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * wire tuner * feat: refactor integrations into self contained packages * chore: simplify ensure_public_access_token * fix: remove NodeSpec and make DTOs the source of truth * feat: send relevant signal to mcp using to_mcp_dict * fix: fix tests * cleanup: remove nango integrations * feat: add agents.md for integrations --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
parent
afa78fe859
commit
5f28c1b2a9
93 changed files with 3388 additions and 3414 deletions
239
api/services/integrations/AGENTS.md
Normal file
239
api/services/integrations/AGENTS.md
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
# Integrations - Plugin Contract
|
||||
|
||||
`api/services/integrations/` is the extension seam for third-party integrations.
|
||||
New integrations should be self-contained here. Do not bleed integration-specific
|
||||
logic into `workflow/dto.py`, `workflow/node_specs/`, `run_pipeline.py`,
|
||||
`event_handlers.py`, or `run_integrations.py` unless you are changing the generic
|
||||
framework itself.
|
||||
|
||||
## Golden Path
|
||||
|
||||
Create a package:
|
||||
|
||||
```text
|
||||
api/services/integrations/<name>/
|
||||
├── __init__.py
|
||||
├── node.py
|
||||
├── runtime.py # optional
|
||||
├── completion.py # optional
|
||||
├── routes.py # optional
|
||||
└── client.py # optional
|
||||
```
|
||||
|
||||
The package self-registers on import via `register_package(...)`. Discovery is
|
||||
automatic: `api/services/integrations/loader.py` imports every submodule under
|
||||
`api.services.integrations` except the reserved internal names `base`, `loader`,
|
||||
and `registry`.
|
||||
|
||||
## Registration Pattern
|
||||
|
||||
`__init__.py` should register one `IntegrationPackageSpec`, following the
|
||||
existing integration packages in this directory.
|
||||
|
||||
Use:
|
||||
|
||||
```python
|
||||
PACKAGE = register_package(
|
||||
IntegrationPackageSpec(
|
||||
name="<package_name>",
|
||||
nodes=(NODE,),
|
||||
create_runtime_sessions=create_runtime_sessions, # optional
|
||||
run_completion=run_completion, # optional
|
||||
routers=(router,), # optional
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
The package name is the registry key. The node `type_name` is the workflow node
|
||||
type string and must stay stable once exposed.
|
||||
|
||||
## Node Model + Spec
|
||||
|
||||
For integration nodes, the Pydantic model is the source of truth. The serialized
|
||||
`NodeSpec` is derived from it.
|
||||
|
||||
Refer to an existing integration node for the overall structure:
|
||||
|
||||
- Define one Pydantic model per node, inheriting
|
||||
`api/services/workflow/node_data.py:BaseNodeData`.
|
||||
- Annotate it with `@node_spec(...)`.
|
||||
- Define fields with `spec_field(...)`.
|
||||
- Generate the external spec with `SPEC = build_spec(ModelClass)`.
|
||||
- Register the node with `IntegrationNodeRegistration(...)`.
|
||||
|
||||
Important rules:
|
||||
|
||||
- Put runtime validation in the model, not in the generated spec.
|
||||
Example: conditional requiredness belongs in `@model_validator(mode="after")`.
|
||||
- Keep `@node_spec(name=...)` and `IntegrationNodeRegistration.type_name`
|
||||
identical. They are the same workflow node type string.
|
||||
- Put wire constraints in the field itself where possible.
|
||||
Example: `gt=0`, `min_length=1`, `pattern=...`.
|
||||
- Put UI/export-only differences in `field_overrides`.
|
||||
Use this for `display_name`, `description`, `required`, `spec_default`,
|
||||
`display_options`, or property ordering.
|
||||
- Use `spec_exclude=True` for internal fields that must exist in persisted data
|
||||
but must not show up in `/api/v1/node-types`.
|
||||
- Set `property_order=(...)` in `@node_spec(...)` when the editor field order
|
||||
must remain stable.
|
||||
|
||||
Typical workflow graph constraints for configuration-only integration nodes:
|
||||
|
||||
```python
|
||||
GraphConstraints(min_incoming=0, max_incoming=0, min_outgoing=0, max_outgoing=0)
|
||||
```
|
||||
|
||||
These constraints control how the node can be connected in the workflow graph.
|
||||
Use them for configuration nodes that are not conversational graph steps.
|
||||
|
||||
## Secret Fields
|
||||
|
||||
If the node stores secrets, register them in
|
||||
`IntegrationNodeRegistration.sensitive_fields`.
|
||||
|
||||
That is enough for generic masking / masked round-trip preservation via
|
||||
`api/services/configuration/masking.py`. Do not add new integration-specific
|
||||
masking branches unless you are changing the shared masking framework.
|
||||
|
||||
## No Central DTO Edits
|
||||
|
||||
Do not add integration node classes to `api/services/workflow/dto.py`.
|
||||
|
||||
Integration nodes are resolved dynamically through:
|
||||
|
||||
- `get_node_data_model()` in `workflow/dto.py`
|
||||
- `get_node_spec()` / `all_node_specs()` in `services/integrations/registry.py`
|
||||
|
||||
`RFNodeDTO` validates integration nodes by `type` through the registry. That is
|
||||
the intended extension path.
|
||||
|
||||
## Live Call Path
|
||||
|
||||
If the integration needs live call data, implement `create_runtime_sessions(...)`
|
||||
in `runtime.py` and return `IntegrationRuntimeSession` objects.
|
||||
|
||||
The generic wiring is already in `api/services/pipecat/run_pipeline.py`:
|
||||
|
||||
- `create_runtime_sessions(IntegrationRuntimeContext(...))` is called before the
|
||||
pipeline task starts.
|
||||
- Each returned session gets `session.attach(task)` called.
|
||||
|
||||
Use this only for lightweight live collection:
|
||||
|
||||
- attach task observers
|
||||
- read context messages
|
||||
- capture timing / turn / tool events
|
||||
- build an in-memory snapshot
|
||||
|
||||
Do not do outbound network I/O in the live path unless there is a very strong
|
||||
reason. Prefer the standard pattern: collect live, deliver after the call.
|
||||
|
||||
`IntegrationRuntimeContext` gives you:
|
||||
|
||||
- `workflow_run_id`
|
||||
- `workflow_run`
|
||||
- `workflow_graph`
|
||||
- `run_definition`
|
||||
- `user_config`
|
||||
- `is_realtime`
|
||||
- `context_messages_provider`
|
||||
|
||||
Typical runtime pattern:
|
||||
|
||||
- scan `context.workflow_graph.nodes.values()` for enabled nodes of your type
|
||||
- if none are enabled, return `[]`
|
||||
- build one collector/session per workflow run, not per node, unless the
|
||||
integration truly needs multiple independent collectors
|
||||
|
||||
## Call-Finish Snapshot Path
|
||||
|
||||
`api/services/pipecat/event_handlers.py` finalizes runtime sessions before the
|
||||
engine is cleaned up.
|
||||
|
||||
The generic flow:
|
||||
|
||||
1. `on_pipeline_finished` builds `gathered_context`
|
||||
2. each runtime session gets `await session.on_call_finished(...)`
|
||||
3. returned dicts are merged into `integration_logs`
|
||||
4. those logs are persisted into `workflow_run.logs`
|
||||
|
||||
Use `on_call_finished(...)` to emit a compact, serializable snapshot that the
|
||||
post-call completion handler can consume later. Return `None` if there is nothing
|
||||
to persist.
|
||||
|
||||
This is the handoff between the live call path and the post-call task path.
|
||||
|
||||
## Post-Call Completion Path
|
||||
|
||||
If the integration needs durable artifacts, public URLs, retries, or external
|
||||
delivery, implement `run_completion(nodes, context)` in `completion.py`.
|
||||
|
||||
The generic orchestration is already in `api/tasks/run_integrations.py`:
|
||||
|
||||
1. load the pinned workflow definition from the workflow run
|
||||
2. create a public token if post-call work exists
|
||||
3. run QA nodes first
|
||||
4. run registered integration completion handlers
|
||||
5. run webhook nodes last
|
||||
|
||||
Your handler receives:
|
||||
|
||||
- `nodes`: raw workflow node dicts for your node types only
|
||||
- `IntegrationCompletionContext`:
|
||||
- `workflow_run_id`
|
||||
- `workflow_run`
|
||||
- `workflow_definition`
|
||||
- `definition_id`
|
||||
- `organization_id`
|
||||
- `public_token`
|
||||
|
||||
Expected completion handler pattern:
|
||||
|
||||
- validate each node with `YourNodeData.model_validate(node.get("data", {}))`
|
||||
- skip disabled nodes
|
||||
- read any runtime snapshot from `context.workflow_run.logs`
|
||||
- build durable URLs using `public_token` when appropriate
|
||||
- perform external delivery
|
||||
- return a result dict keyed per node, usually with `node_id` embedded
|
||||
|
||||
Returned data is merged into `workflow_run.annotations`.
|
||||
|
||||
Do not assume completion runs inside the live pipeline process. Treat it as a
|
||||
separate post-call worker step.
|
||||
|
||||
## Optional Routes
|
||||
|
||||
If an integration exposes HTTP routes, put them in `routes.py` and include the
|
||||
router in `IntegrationPackageSpec.routers`.
|
||||
|
||||
Routers are mounted automatically by `api/routes/main.py` through `all_routers()`.
|
||||
Do not edit `routes/main.py` for per-integration route wiring.
|
||||
|
||||
## Import Discipline
|
||||
|
||||
Keep package import side effects light.
|
||||
|
||||
The integration loader runs during:
|
||||
|
||||
- node-type/spec enumeration
|
||||
- tests
|
||||
- route startup
|
||||
- registry access
|
||||
|
||||
So avoid top-level imports that require environment variables, network access,
|
||||
or heavyweight initialization when possible. Prefer lazy imports inside
|
||||
`run_completion()` / `create_runtime_sessions()` if the dependency is optional or
|
||||
environment-sensitive.
|
||||
|
||||
## Testing Expectations
|
||||
|
||||
At minimum, new integrations should add coverage for:
|
||||
|
||||
- node model validation
|
||||
- generated spec/example validity
|
||||
- secret masking + masked round-trip preservation if secrets exist
|
||||
- runtime snapshot creation if live collectors exist
|
||||
- completion handler happy path and disabled-node skip path
|
||||
|
||||
If you change shared integration machinery, test the framework in the generic
|
||||
code path, not only the concrete integration.
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
from api.services.integrations.base import (
|
||||
IntegrationCompletionContext,
|
||||
IntegrationNodeRegistration,
|
||||
IntegrationPackageSpec,
|
||||
IntegrationRuntimeContext,
|
||||
IntegrationRuntimeSession,
|
||||
)
|
||||
from api.services.integrations.registry import (
|
||||
all_node_specs,
|
||||
all_packages,
|
||||
all_routers,
|
||||
create_runtime_sessions,
|
||||
get_node_data_model,
|
||||
get_node_registration,
|
||||
get_node_secret_fields,
|
||||
get_node_spec,
|
||||
has_completion_handlers,
|
||||
register_package,
|
||||
run_completion_handlers,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"IntegrationCompletionContext",
|
||||
"IntegrationNodeRegistration",
|
||||
"IntegrationPackageSpec",
|
||||
"IntegrationRuntimeContext",
|
||||
"IntegrationRuntimeSession",
|
||||
"all_node_specs",
|
||||
"all_packages",
|
||||
"all_routers",
|
||||
"create_runtime_sessions",
|
||||
"get_node_data_model",
|
||||
"get_node_registration",
|
||||
"get_node_secret_fields",
|
||||
"get_node_spec",
|
||||
"has_completion_handlers",
|
||||
"register_package",
|
||||
"run_completion_handlers",
|
||||
]
|
||||
69
api/services/integrations/base.py
Normal file
69
api/services/integrations/base.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable, Protocol
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.services.workflow.node_data import BaseNodeData
|
||||
from api.services.workflow.node_specs._base import NodeSpec
|
||||
|
||||
|
||||
class IntegrationRuntimeSession(Protocol):
|
||||
name: str
|
||||
|
||||
def attach(self, task: Any) -> None: ...
|
||||
|
||||
async def on_call_finished(
|
||||
self,
|
||||
*,
|
||||
gathered_context: dict[str, Any],
|
||||
) -> dict[str, Any] | None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegrationRuntimeContext:
|
||||
workflow_run_id: int
|
||||
workflow_run: Any
|
||||
workflow_graph: Any
|
||||
run_definition: Any
|
||||
user_config: Any
|
||||
is_realtime: bool
|
||||
context_messages_provider: Callable[[], list[dict[str, Any]]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegrationCompletionContext:
|
||||
workflow_run_id: int
|
||||
workflow_run: Any
|
||||
workflow_definition: dict[str, Any]
|
||||
definition_id: int | None
|
||||
organization_id: int
|
||||
public_token: str | None
|
||||
|
||||
|
||||
RuntimeFactory = Callable[
|
||||
[IntegrationRuntimeContext],
|
||||
list[IntegrationRuntimeSession],
|
||||
]
|
||||
CompletionHandler = Callable[
|
||||
[list[dict[str, Any]], IntegrationCompletionContext],
|
||||
Awaitable[dict[str, Any]],
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegrationNodeRegistration:
|
||||
type_name: str
|
||||
data_model: type[BaseNodeData]
|
||||
node_spec: NodeSpec
|
||||
sensitive_fields: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegrationPackageSpec:
|
||||
name: str
|
||||
nodes: tuple[IntegrationNodeRegistration, ...] = ()
|
||||
routers: tuple[APIRouter, ...] = ()
|
||||
create_runtime_sessions: RuntimeFactory | None = None
|
||||
run_completion: CompletionHandler | None = None
|
||||
21
api/services/integrations/loader.py
Normal file
21
api/services/integrations/loader.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import pkgutil
|
||||
|
||||
_INTERNAL_MODULES = {"base", "loader", "registry"}
|
||||
_loaded = False
|
||||
|
||||
|
||||
def ensure_integrations_loaded() -> None:
|
||||
global _loaded
|
||||
if _loaded:
|
||||
return
|
||||
|
||||
package = importlib.import_module("api.services.integrations")
|
||||
for module_info in pkgutil.iter_modules(package.__path__):
|
||||
if module_info.name in _INTERNAL_MODULES:
|
||||
continue
|
||||
importlib.import_module(f"{package.__name__}.{module_info.name}")
|
||||
|
||||
_loaded = True
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.db import db_client
|
||||
|
||||
NANGO_ALLOWED_INTEGRATIONS = [
|
||||
i.strip() for i in os.environ.get("NANGO_ALLOWED_INTEGRATIONS", "slack").split(",")
|
||||
]
|
||||
|
||||
|
||||
class NangoWebhookRequest(BaseModel):
|
||||
type: str
|
||||
connectionId: str
|
||||
providerConfigKey: str
|
||||
authMode: str
|
||||
provider: str
|
||||
environment: str
|
||||
operation: str
|
||||
endUser: dict # Contains endUserId and organizationId
|
||||
success: bool
|
||||
|
||||
|
||||
class NangoService:
|
||||
def __init__(self):
|
||||
self.base_url = "https://api.nango.dev"
|
||||
self.secret_key = os.getenv("NANGO_API_KEY")
|
||||
|
||||
def _verify_webhook_signature(
|
||||
self, request_body: str, signature: str = None
|
||||
) -> bool:
|
||||
"""
|
||||
Verify the webhook signature using SHA256 hash.
|
||||
|
||||
Args:
|
||||
request_body: The raw request body as string
|
||||
signature: The signature from request headers (optional for now)
|
||||
|
||||
Returns:
|
||||
True if signature is valid
|
||||
"""
|
||||
expected_signature = self.secret_key + request_body
|
||||
expected_hash = hashlib.sha256(expected_signature.encode("utf-8")).hexdigest()
|
||||
return expected_hash == signature
|
||||
|
||||
async def create_session(
|
||||
self, user_id: str, organization_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a Nango session for the given user and organization.
|
||||
|
||||
Args:
|
||||
user_id: The end user ID
|
||||
organization_id: The organization ID
|
||||
|
||||
Returns:
|
||||
Response from Nango API
|
||||
"""
|
||||
if not self.secret_key:
|
||||
raise ValueError("NANGO_SECRET_KEY environment variable is not set")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.secret_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"end_user": {"id": user_id},
|
||||
"organization": {"id": str(organization_id)},
|
||||
"allowed_integrations": NANGO_ALLOWED_INTEGRATIONS,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/connect/sessions", headers=headers, json=payload
|
||||
)
|
||||
|
||||
if response.status_code != 201:
|
||||
raise httpx.HTTPStatusError(
|
||||
f"Nango API error: {response.status_code}",
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
return response.json()
|
||||
|
||||
async def process_webhook(
|
||||
self, raw_body: bytes, signature: str = None
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Process incoming Nango webhook request.
|
||||
|
||||
Args:
|
||||
raw_body: The raw request body as bytes
|
||||
signature: Optional signature from request headers
|
||||
|
||||
Returns:
|
||||
Dict with status and message
|
||||
"""
|
||||
# Decode and parse the request body
|
||||
try:
|
||||
body_text = raw_body.decode("utf-8")
|
||||
webhook_json = json.loads(body_text) if body_text else {}
|
||||
logger.debug(f"received webhook from nango: {webhook_json}")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"JSON decode error: {e} body_text: {body_text}")
|
||||
raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}")
|
||||
|
||||
# Verify webhook signature
|
||||
if not self._verify_webhook_signature(body_text, signature):
|
||||
raise HTTPException(status_code=401, detail="Invalid webhook signature")
|
||||
|
||||
# Parse webhook data
|
||||
try:
|
||||
webhook_data = NangoWebhookRequest(**webhook_json)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse webhook data: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid webhook format: {str(e)}"
|
||||
)
|
||||
|
||||
# Extract user and organization IDs from the webhook payload
|
||||
end_user = webhook_data.endUser
|
||||
if (
|
||||
not end_user
|
||||
or "endUserId" not in end_user
|
||||
or "organizationId" not in end_user
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Missing endUser information in webhook payload"
|
||||
)
|
||||
|
||||
user_id = int(end_user["endUserId"])
|
||||
organization_id = int(end_user["organizationId"])
|
||||
|
||||
# Use the connectionId as the integration_id since it's unique per integration
|
||||
integration_id = webhook_data.connectionId
|
||||
|
||||
# Initialize connection_details
|
||||
connection_details = {}
|
||||
|
||||
# Fetch connection details if type is auth and provider is slack
|
||||
if webhook_data.type == "auth":
|
||||
connection_details = await self._fetch_connection_details(
|
||||
integration_id, webhook_data.provider
|
||||
)
|
||||
|
||||
# Create the integration in the database
|
||||
integration = await db_client.create_integration(
|
||||
integration_id=integration_id,
|
||||
organization_id=organization_id,
|
||||
provider=webhook_data.provider,
|
||||
created_by=user_id,
|
||||
is_active=True,
|
||||
connection_details=connection_details,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Integration created successfully with ID: {integration.id}",
|
||||
}
|
||||
|
||||
async def _fetch_connection_details(
|
||||
self, connection_id: str, provider_key: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch connection details from Nango API for a given connection ID.
|
||||
|
||||
Args:
|
||||
connection_id: The connection ID from the webhook
|
||||
|
||||
Returns:
|
||||
Connection details as a dictionary
|
||||
"""
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.secret_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
url = f"{self.base_url}/connection/{connection_id}/?provider_config_key={provider_key}"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"Failed to fetch connection details: {response.status_code} - {response.text}"
|
||||
)
|
||||
raise httpx.HTTPStatusError(
|
||||
f"Nango API error while fetching connection: {response.status_code}",
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
connection_details = response.json()
|
||||
return connection_details
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"HTTP error while fetching connection details: {e}")
|
||||
# Return empty dict if API call fails, but log the error
|
||||
return {}
|
||||
|
||||
async def get_access_token(
|
||||
self, connection_id: str, provider_config_key: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the latest access token for a connection from Nango.
|
||||
|
||||
Args:
|
||||
connection_id: The connection ID
|
||||
provider_config_key: The provider config key (e.g., 'google-sheet')
|
||||
|
||||
Returns:
|
||||
Dict containing access token and other connection details
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.secret_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
url = f"{self.base_url}/connection/{connection_id}?provider_config_key={provider_config_key}"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"Failed to get access token: {response.status_code} - {response.text}"
|
||||
)
|
||||
raise httpx.HTTPStatusError(
|
||||
f"Nango API error: {response.status_code}",
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
return response.json()
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"HTTP error while getting access token: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# Create a singleton instance
|
||||
nango_service = NangoService()
|
||||
128
api/services/integrations/registry.py
Normal file
128
api/services/integrations/registry.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from api.services.integrations.base import (
|
||||
IntegrationCompletionContext,
|
||||
IntegrationNodeRegistration,
|
||||
IntegrationPackageSpec,
|
||||
IntegrationRuntimeContext,
|
||||
)
|
||||
from api.services.workflow.node_data import BaseNodeData
|
||||
|
||||
_PACKAGE_REGISTRY: dict[str, IntegrationPackageSpec] = {}
|
||||
|
||||
|
||||
def register_package(spec: IntegrationPackageSpec) -> IntegrationPackageSpec:
|
||||
existing = _PACKAGE_REGISTRY.get(spec.name)
|
||||
if existing is not None and existing is not spec:
|
||||
raise ValueError(
|
||||
f"Duplicate integration package registration for {spec.name!r}"
|
||||
)
|
||||
_PACKAGE_REGISTRY[spec.name] = spec
|
||||
return spec
|
||||
|
||||
|
||||
def _ensure_loaded() -> None:
|
||||
from api.services.integrations.loader import ensure_integrations_loaded
|
||||
|
||||
ensure_integrations_loaded()
|
||||
|
||||
|
||||
def all_packages() -> list[IntegrationPackageSpec]:
|
||||
_ensure_loaded()
|
||||
return [_PACKAGE_REGISTRY[name] for name in sorted(_PACKAGE_REGISTRY)]
|
||||
|
||||
|
||||
def get_package(name: str) -> IntegrationPackageSpec | None:
|
||||
_ensure_loaded()
|
||||
return _PACKAGE_REGISTRY.get(name)
|
||||
|
||||
|
||||
def get_node_registration(type_name: str) -> IntegrationNodeRegistration | None:
|
||||
_ensure_loaded()
|
||||
for package in _PACKAGE_REGISTRY.values():
|
||||
for node in package.nodes:
|
||||
if node.type_name == type_name:
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def get_node_data_model(type_name: str) -> type[BaseNodeData] | None:
|
||||
registration = get_node_registration(type_name)
|
||||
return registration.data_model if registration else None
|
||||
|
||||
|
||||
def get_node_spec(type_name: str):
|
||||
registration = get_node_registration(type_name)
|
||||
return registration.node_spec if registration else None
|
||||
|
||||
|
||||
def get_node_secret_fields(type_name: str) -> tuple[str, ...]:
|
||||
registration = get_node_registration(type_name)
|
||||
return registration.sensitive_fields if registration else ()
|
||||
|
||||
|
||||
def all_node_specs():
|
||||
_ensure_loaded()
|
||||
specs = []
|
||||
for package in all_packages():
|
||||
specs.extend(node.node_spec for node in package.nodes)
|
||||
return specs
|
||||
|
||||
|
||||
def all_routers():
|
||||
_ensure_loaded()
|
||||
routers = []
|
||||
for package in all_packages():
|
||||
routers.extend(package.routers)
|
||||
return routers
|
||||
|
||||
|
||||
def create_runtime_sessions(
|
||||
context: IntegrationRuntimeContext,
|
||||
):
|
||||
_ensure_loaded()
|
||||
sessions = []
|
||||
for package in all_packages():
|
||||
if package.create_runtime_sessions is None:
|
||||
continue
|
||||
sessions.extend(package.create_runtime_sessions(context))
|
||||
return sessions
|
||||
|
||||
|
||||
def iter_completion_packages(
|
||||
workflow_definition: dict[str, Any],
|
||||
):
|
||||
_ensure_loaded()
|
||||
nodes = workflow_definition.get("nodes", []) if workflow_definition else []
|
||||
for package in all_packages():
|
||||
node_types = {node.type_name for node in package.nodes}
|
||||
package_nodes = [
|
||||
node
|
||||
for node in nodes
|
||||
if isinstance(node, dict) and node.get("type") in node_types
|
||||
]
|
||||
if package_nodes:
|
||||
yield package, package_nodes
|
||||
|
||||
|
||||
def has_completion_handlers(workflow_definition: dict[str, Any]) -> bool:
|
||||
return any(
|
||||
package.run_completion is not None
|
||||
for package, _nodes in iter_completion_packages(workflow_definition)
|
||||
)
|
||||
|
||||
|
||||
async def run_completion_handlers(
|
||||
*,
|
||||
context: IntegrationCompletionContext,
|
||||
) -> dict[str, Any]:
|
||||
results: dict[str, Any] = {}
|
||||
for package, nodes in iter_completion_packages(context.workflow_definition):
|
||||
if package.run_completion is None:
|
||||
continue
|
||||
package_result = await package.run_completion(nodes, context)
|
||||
if package_result:
|
||||
results.update(package_result)
|
||||
return results
|
||||
19
api/services/integrations/tuner/__init__.py
Normal file
19
api/services/integrations/tuner/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from api.services.integrations.base import IntegrationPackageSpec
|
||||
from api.services.integrations.registry import register_package
|
||||
|
||||
from .completion import run_completion
|
||||
from .node import NODE
|
||||
from .runtime import create_runtime_sessions
|
||||
|
||||
PACKAGE = register_package(
|
||||
IntegrationPackageSpec(
|
||||
name="tuner",
|
||||
nodes=(NODE,),
|
||||
create_runtime_sessions=create_runtime_sessions,
|
||||
run_completion=run_completion,
|
||||
)
|
||||
)
|
||||
|
||||
__all__ = ["PACKAGE"]
|
||||
71
api/services/integrations/tuner/client.py
Normal file
71
api/services/integrations/tuner/client.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class TunerDeliveryConfig(BaseModel):
|
||||
base_url: str
|
||||
api_key: str
|
||||
workspace_id: int
|
||||
agent_id: str
|
||||
|
||||
@field_validator("api_key", "agent_id")
|
||||
@classmethod
|
||||
def _must_not_be_empty(cls, value: str) -> str:
|
||||
if not value or not value.strip():
|
||||
raise ValueError("must not be empty")
|
||||
return value
|
||||
|
||||
@field_validator("workspace_id")
|
||||
@classmethod
|
||||
def _workspace_must_be_positive(cls, value: int) -> int:
|
||||
if value <= 0:
|
||||
raise ValueError("must be a positive integer")
|
||||
return value
|
||||
|
||||
|
||||
async def post_call(
|
||||
config: TunerDeliveryConfig,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
url = (
|
||||
f"{config.base_url}/api/v1/public/call"
|
||||
f"?workspace_id={config.workspace_id}"
|
||||
f"&agent_remote_identifier={config.agent_id}"
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {config.api_key}"}
|
||||
|
||||
logger.info(
|
||||
"[tuner] posting completed call {} to workspace {} / agent {}",
|
||||
payload.get("call_id"),
|
||||
config.workspace_id,
|
||||
config.agent_id,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
|
||||
if response.status_code == 409:
|
||||
logger.info("[tuner] call {} already exists in tuner", payload.get("call_id"))
|
||||
return {"status": "duplicate", "status_code": response.status_code}
|
||||
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
"[tuner] POST failed for call {} with status {}: {}",
|
||||
payload.get("call_id"),
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
logger.info(
|
||||
"[tuner] POST succeeded for call {} with status {}",
|
||||
payload.get("call_id"),
|
||||
response.status_code,
|
||||
)
|
||||
return {"status": "delivered", "status_code": response.status_code}
|
||||
182
api/services/integrations/tuner/collector.py
Normal file
182
api/services/integrations/tuner/collector.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.frames.frames import (
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
CancelFrame,
|
||||
EndFrame,
|
||||
FunctionCallInProgressFrame,
|
||||
FunctionCallResultFrame,
|
||||
MetricsFrame,
|
||||
StartFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
VADUserStoppedSpeakingFrame,
|
||||
)
|
||||
from pipecat.observers.base_observer import BaseObserver, FramePushed
|
||||
from pipecat.observers.turn_tracking_observer import TurnTrackingObserver
|
||||
from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from tuner_pipecat_sdk.accumulator import CallAccumulator
|
||||
from tuner_pipecat_sdk.payload_builder import build_payload
|
||||
|
||||
from api.enums import WorkflowRunMode
|
||||
|
||||
TUNER_RECORDING_PLACEHOLDER = "pipecat://no-recording"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PayloadConfig:
|
||||
call_id: str
|
||||
call_type: str
|
||||
recording_url: str
|
||||
asr_model: str
|
||||
llm_model: str
|
||||
tts_model: str
|
||||
sip_call_id: str | None = None
|
||||
sip_headers: dict[str, str] | None = None
|
||||
agent_version: int | None = None
|
||||
|
||||
|
||||
def mode_to_tuner_call_type(mode: str | None) -> str:
|
||||
if mode in {
|
||||
WorkflowRunMode.WEBRTC.value,
|
||||
WorkflowRunMode.SMALLWEBRTC.value,
|
||||
}:
|
||||
return "web_call"
|
||||
return "phone_call"
|
||||
|
||||
|
||||
class TunerCollector(BaseObserver):
|
||||
"""Collect runtime call metadata and build a deferred Tuner payload."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workflow_run_id: int,
|
||||
call_type: str,
|
||||
asr_model: str = "",
|
||||
llm_model: str = "",
|
||||
tts_model: str = "",
|
||||
agent_version: int | None = None,
|
||||
max_frames: int = 500,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._call_id = str(workflow_run_id)
|
||||
self._call_type = call_type
|
||||
self._asr_model = asr_model
|
||||
self._llm_model = llm_model
|
||||
self._tts_model = tts_model
|
||||
self._agent_version = agent_version
|
||||
self._acc = CallAccumulator()
|
||||
self._acc.call_start_abs_ns = time.time_ns()
|
||||
self._context_provider: Callable[[], list[dict[str, Any]]] | None = None
|
||||
self._processed_frames: set[int] = set()
|
||||
self._frame_history: deque[int] = deque(maxlen=max_frames)
|
||||
|
||||
def attach_context(self, provider: Callable[[], list[dict[str, Any]]]) -> None:
|
||||
self._context_provider = provider
|
||||
|
||||
def set_disconnection_reason(self, reason: str | None) -> None:
|
||||
if reason:
|
||||
self._acc.set_disconnection_reason(reason)
|
||||
|
||||
def attach_turn_tracking_observer(
|
||||
self, turn_tracker: TurnTrackingObserver | None
|
||||
) -> None:
|
||||
if turn_tracker is None:
|
||||
return
|
||||
|
||||
@turn_tracker.event_handler("on_turn_started")
|
||||
async def _on_turn_started(_tracker: Any, turn_number: int) -> None:
|
||||
self._acc.on_turn_started(turn_number, time.time_ns())
|
||||
|
||||
@turn_tracker.event_handler("on_turn_ended")
|
||||
async def _on_turn_ended(
|
||||
_tracker: Any, turn_number: int, _duration: float, was_interrupted: bool
|
||||
) -> None:
|
||||
self._acc.on_turn_ended(turn_number, was_interrupted)
|
||||
|
||||
def attach_latency_observer(
|
||||
self, latency_observer: UserBotLatencyObserver | None
|
||||
) -> None:
|
||||
if latency_observer is None:
|
||||
return
|
||||
|
||||
@latency_observer.event_handler("on_latency_measured")
|
||||
async def _on_latency_measured(_observer: Any, latency: float) -> None:
|
||||
self._acc.on_latency_measured(latency)
|
||||
|
||||
@latency_observer.event_handler("on_latency_breakdown")
|
||||
async def _on_latency_breakdown(_observer: Any, breakdown: Any) -> None:
|
||||
self._acc.on_latency_breakdown(breakdown)
|
||||
|
||||
async def on_push_frame(self, data: FramePushed):
|
||||
if data.direction != FrameDirection.DOWNSTREAM:
|
||||
return
|
||||
|
||||
if data.frame.id in self._processed_frames:
|
||||
return
|
||||
|
||||
self._processed_frames.add(data.frame.id)
|
||||
self._frame_history.append(data.frame.id)
|
||||
if len(self._processed_frames) > len(self._frame_history):
|
||||
self._processed_frames = set(self._frame_history)
|
||||
|
||||
frame = data.frame
|
||||
timestamp_ns = data.timestamp
|
||||
|
||||
if isinstance(frame, StartFrame):
|
||||
self._acc.on_start(timestamp_ns)
|
||||
elif isinstance(frame, FunctionCallInProgressFrame):
|
||||
self._acc.on_function_call_in_progress(frame, timestamp_ns)
|
||||
elif isinstance(frame, FunctionCallResultFrame):
|
||||
self._acc.on_function_call_result(frame.tool_call_id, timestamp_ns)
|
||||
elif isinstance(frame, MetricsFrame):
|
||||
self._acc.on_metrics_frame(frame)
|
||||
elif isinstance(frame, UserStartedSpeakingFrame):
|
||||
self._acc.on_user_started_speaking(timestamp_ns)
|
||||
elif isinstance(frame, UserStoppedSpeakingFrame):
|
||||
self._acc.on_user_stopped_speaking(timestamp_ns)
|
||||
self._acc.on_user_turn_stopped(timestamp_ns)
|
||||
elif isinstance(frame, BotStartedSpeakingFrame):
|
||||
self._acc.on_bot_started_speaking(timestamp_ns)
|
||||
elif isinstance(frame, BotStoppedSpeakingFrame):
|
||||
self._acc.on_bot_stopped(timestamp_ns)
|
||||
elif isinstance(frame, VADUserStoppedSpeakingFrame):
|
||||
self._acc.on_vad_stopped(timestamp_ns)
|
||||
elif isinstance(frame, (CancelFrame, EndFrame)):
|
||||
self._acc.on_call_end(timestamp_ns)
|
||||
|
||||
def build_payload_snapshot(
|
||||
self,
|
||||
*,
|
||||
recording_url: str = TUNER_RECORDING_PLACEHOLDER,
|
||||
) -> dict[str, Any] | None:
|
||||
if self._context_provider is None:
|
||||
logger.warning(
|
||||
"[tuner] no context provider attached; skipping payload snapshot"
|
||||
)
|
||||
return None
|
||||
|
||||
transcript = list(self._context_provider())
|
||||
payload = build_payload(
|
||||
self._acc,
|
||||
_PayloadConfig(
|
||||
call_id=self._call_id,
|
||||
call_type=self._call_type,
|
||||
recording_url=recording_url,
|
||||
asr_model=self._asr_model,
|
||||
llm_model=self._llm_model,
|
||||
tts_model=self._tts_model,
|
||||
agent_version=self._agent_version,
|
||||
),
|
||||
transcript,
|
||||
)
|
||||
return payload.to_dict()
|
||||
76
api/services/integrations/tuner/completion.py
Normal file
76
api/services/integrations/tuner/completion.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from api.constants import BACKEND_API_ENDPOINT, TUNER_BASE_URL
|
||||
from api.services.integrations.base import IntegrationCompletionContext
|
||||
|
||||
from .client import TunerDeliveryConfig, post_call
|
||||
from .collector import TUNER_RECORDING_PLACEHOLDER
|
||||
from .node import TunerNodeData
|
||||
|
||||
|
||||
def _build_recording_url(
|
||||
context: IntegrationCompletionContext,
|
||||
) -> str | None:
|
||||
workflow_run = context.workflow_run
|
||||
if context.public_token:
|
||||
base_url = f"{BACKEND_API_ENDPOINT}/api/v1/public/download/workflow/{context.public_token}"
|
||||
return f"{base_url}/recording" if workflow_run.recording_url else None
|
||||
return workflow_run.recording_url
|
||||
|
||||
|
||||
async def run_completion(
|
||||
nodes: list[dict[str, Any]],
|
||||
context: IntegrationCompletionContext,
|
||||
) -> dict[str, Any]:
|
||||
results: dict[str, Any] = {}
|
||||
payload_snapshot = (context.workflow_run.logs or {}).get("tuner_payload")
|
||||
recording_url = _build_recording_url(context) or TUNER_RECORDING_PLACEHOLDER
|
||||
|
||||
for node in nodes:
|
||||
node_id = node.get("id", "unknown")
|
||||
try:
|
||||
tuner_data = TunerNodeData.model_validate(node.get("data", {}))
|
||||
except Exception as exc:
|
||||
logger.warning(f"Tuner node #{node_id} failed validation, skipping: {exc}")
|
||||
results[f"tuner_{node_id}"] = {"error": "validation_failed"}
|
||||
continue
|
||||
|
||||
if not tuner_data.tuner_enabled:
|
||||
logger.debug(f"Tuner node '{tuner_data.name}' is disabled, skipping")
|
||||
continue
|
||||
|
||||
if not payload_snapshot:
|
||||
logger.warning(
|
||||
f"Tuner payload snapshot missing for node '{tuner_data.name}' (#{node_id})"
|
||||
)
|
||||
results[f"tuner_{node_id}"] = {"error": "missing_payload_snapshot"}
|
||||
continue
|
||||
|
||||
payload = copy.deepcopy(payload_snapshot)
|
||||
payload["recording_url"] = recording_url
|
||||
|
||||
try:
|
||||
config = TunerDeliveryConfig(
|
||||
base_url=TUNER_BASE_URL,
|
||||
api_key=tuner_data.tuner_api_key or "",
|
||||
workspace_id=tuner_data.tuner_workspace_id or 0,
|
||||
agent_id=tuner_data.tuner_agent_id or "",
|
||||
)
|
||||
delivery = await post_call(config, payload)
|
||||
results[f"tuner_{node_id}"] = {
|
||||
**delivery,
|
||||
"workspace_id": tuner_data.tuner_workspace_id,
|
||||
"agent_id": tuner_data.tuner_agent_id,
|
||||
"exported_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error(f"Tuner export failed for node '{tuner_data.name}': {exc}")
|
||||
results[f"tuner_{node_id}"] = {"error": str(exc)}
|
||||
|
||||
return results
|
||||
139
api/services/integrations/tuner/node.py
Normal file
139
api/services/integrations/tuner/node.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pydantic import model_validator
|
||||
|
||||
from api.services.integrations.base import IntegrationNodeRegistration
|
||||
from api.services.workflow.node_data import BaseNodeData
|
||||
from api.services.workflow.node_specs._base import (
|
||||
GraphConstraints,
|
||||
NodeCategory,
|
||||
NodeExample,
|
||||
PropertyType,
|
||||
)
|
||||
from api.services.workflow.node_specs.model_spec import (
|
||||
build_spec,
|
||||
node_spec,
|
||||
spec_field,
|
||||
)
|
||||
|
||||
|
||||
@node_spec(
|
||||
name="tuner",
|
||||
display_name="Tuner",
|
||||
description="Export the completed call to Tuner for Agent Observability",
|
||||
llm_hint=(
|
||||
"Tuner is a post-call observability export. It does not participate in the "
|
||||
"conversation graph and should not be connected to other nodes."
|
||||
),
|
||||
category=NodeCategory.integration,
|
||||
icon="Activity",
|
||||
examples=[
|
||||
NodeExample(
|
||||
name="tuner_export",
|
||||
data={
|
||||
"name": "Primary Tuner Export",
|
||||
"tuner_enabled": True,
|
||||
"tuner_agent_id": "sales-bot-prod",
|
||||
"tuner_workspace_id": 42,
|
||||
"tuner_api_key": "tuner_live_xxxxxxxx",
|
||||
},
|
||||
)
|
||||
],
|
||||
graph_constraints=GraphConstraints(
|
||||
min_incoming=0,
|
||||
max_incoming=0,
|
||||
min_outgoing=0,
|
||||
max_outgoing=0,
|
||||
),
|
||||
property_order=(
|
||||
"name",
|
||||
"tuner_enabled",
|
||||
"tuner_agent_id",
|
||||
"tuner_workspace_id",
|
||||
"tuner_api_key",
|
||||
),
|
||||
field_overrides={
|
||||
"name": {
|
||||
"spec_default": "Tuner",
|
||||
"description": "Short identifier for this Tuner export configuration.",
|
||||
},
|
||||
"tuner_enabled": {
|
||||
"display_name": "Enabled",
|
||||
"description": "When false, Dograh skips exporting this call to Tuner.",
|
||||
},
|
||||
"tuner_agent_id": {
|
||||
"display_name": "Tuner Agent ID",
|
||||
"description": "The agent identifier registered in your Tuner workspace.",
|
||||
"required": True,
|
||||
},
|
||||
"tuner_workspace_id": {
|
||||
"display_name": "Tuner Workspace ID",
|
||||
"description": "Your numeric Tuner workspace ID.",
|
||||
"required": True,
|
||||
"min_value": 1,
|
||||
},
|
||||
"tuner_api_key": {
|
||||
"display_name": "Tuner API Key",
|
||||
"description": "Bearer token used when posting completed calls to Tuner.",
|
||||
"required": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
class TunerNodeData(BaseNodeData):
|
||||
tuner_enabled: bool = spec_field(
|
||||
default=True,
|
||||
ui_type=PropertyType.boolean,
|
||||
display_name="Enabled",
|
||||
description="When false, Dograh skips exporting this call to Tuner.",
|
||||
)
|
||||
tuner_agent_id: str | None = spec_field(
|
||||
default=None,
|
||||
ui_type=PropertyType.string,
|
||||
display_name="Tuner Agent ID",
|
||||
description="The agent identifier registered in your Tuner workspace.",
|
||||
)
|
||||
tuner_workspace_id: int | None = spec_field(
|
||||
default=None,
|
||||
gt=0,
|
||||
ui_type=PropertyType.number,
|
||||
display_name="Tuner Workspace ID",
|
||||
description="Your numeric Tuner workspace ID.",
|
||||
)
|
||||
tuner_api_key: str | None = spec_field(
|
||||
default=None,
|
||||
ui_type=PropertyType.string,
|
||||
display_name="Tuner API Key",
|
||||
description="Bearer token used when posting completed calls to Tuner.",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_enabled_config(self):
|
||||
if not self.tuner_enabled:
|
||||
return self
|
||||
|
||||
missing: list[str] = []
|
||||
if not self.tuner_agent_id or not self.tuner_agent_id.strip():
|
||||
missing.append("tuner_agent_id")
|
||||
if self.tuner_workspace_id is None:
|
||||
missing.append("tuner_workspace_id")
|
||||
if not self.tuner_api_key or not self.tuner_api_key.strip():
|
||||
missing.append("tuner_api_key")
|
||||
|
||||
if missing:
|
||||
fields = ", ".join(missing)
|
||||
raise ValueError(
|
||||
f"Tuner node is enabled but missing required fields: {fields}"
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
SPEC = build_spec(TunerNodeData)
|
||||
|
||||
|
||||
NODE = IntegrationNodeRegistration(
|
||||
type_name="tuner",
|
||||
data_model=TunerNodeData,
|
||||
node_spec=SPEC,
|
||||
sensitive_fields=("tuner_api_key",),
|
||||
)
|
||||
101
api/services/integrations/tuner/runtime.py
Normal file
101
api/services/integrations/tuner/runtime.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from api.services.configuration.registry import ServiceProviders
|
||||
from api.services.integrations.base import (
|
||||
IntegrationRuntimeContext,
|
||||
IntegrationRuntimeSession,
|
||||
)
|
||||
|
||||
from .collector import TunerCollector, mode_to_tuner_call_type
|
||||
|
||||
|
||||
def _format_model_label(provider: str | None, model: str | None) -> str:
|
||||
if provider and model:
|
||||
return f"{provider}/{model}"
|
||||
if model:
|
||||
return model
|
||||
return provider or ""
|
||||
|
||||
|
||||
def _resolve_model_labels(context: IntegrationRuntimeContext) -> tuple[str, str, str]:
|
||||
user_config = context.user_config
|
||||
|
||||
if context.is_realtime and user_config.realtime:
|
||||
realtime_provider = user_config.realtime.provider
|
||||
realtime_model = user_config.realtime.model
|
||||
llm_model = _format_model_label(realtime_provider, realtime_model)
|
||||
if realtime_provider in {
|
||||
ServiceProviders.GOOGLE_REALTIME.value,
|
||||
ServiceProviders.GOOGLE_VERTEX_REALTIME.value,
|
||||
ServiceProviders.OPENAI_REALTIME.value,
|
||||
}:
|
||||
return "", llm_model, ""
|
||||
return "", llm_model, ""
|
||||
|
||||
return (
|
||||
_format_model_label(
|
||||
getattr(user_config.stt, "provider", None),
|
||||
getattr(user_config.stt, "model", None),
|
||||
),
|
||||
_format_model_label(
|
||||
getattr(user_config.llm, "provider", None),
|
||||
getattr(user_config.llm, "model", None),
|
||||
),
|
||||
_format_model_label(
|
||||
getattr(user_config.tts, "provider", None),
|
||||
getattr(user_config.tts, "model", None),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TunerRuntimeSession(IntegrationRuntimeSession):
|
||||
name = "tuner"
|
||||
|
||||
def __init__(self, collector: TunerCollector) -> None:
|
||||
self._collector = collector
|
||||
|
||||
def attach(self, task: Any) -> None:
|
||||
self._collector.attach_turn_tracking_observer(task.turn_tracking_observer)
|
||||
self._collector.attach_latency_observer(task.user_bot_latency_observer)
|
||||
task.add_observer(self._collector)
|
||||
|
||||
async def on_call_finished(
|
||||
self,
|
||||
*,
|
||||
gathered_context: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
self._collector.set_disconnection_reason(
|
||||
gathered_context.get("call_disposition")
|
||||
)
|
||||
payload = self._collector.build_payload_snapshot()
|
||||
if payload is None:
|
||||
return None
|
||||
return {"tuner_payload": payload}
|
||||
|
||||
|
||||
def create_runtime_sessions(
|
||||
context: IntegrationRuntimeContext,
|
||||
) -> list[IntegrationRuntimeSession]:
|
||||
tuner_nodes = [
|
||||
node
|
||||
for node in context.workflow_graph.nodes.values()
|
||||
if node.node_type == "tuner" and getattr(node.data, "tuner_enabled", True)
|
||||
]
|
||||
if not tuner_nodes:
|
||||
return []
|
||||
|
||||
asr_model, llm_model, tts_model = _resolve_model_labels(context)
|
||||
|
||||
collector = TunerCollector(
|
||||
workflow_run_id=context.workflow_run_id,
|
||||
call_type=mode_to_tuner_call_type(context.workflow_run.mode),
|
||||
asr_model=asr_model,
|
||||
llm_model=llm_model,
|
||||
tts_model=tts_model,
|
||||
agent_version=getattr(context.run_definition, "version_number", None),
|
||||
)
|
||||
collector.attach_context(context.context_messages_provider)
|
||||
|
||||
return [TunerRuntimeSession(collector)]
|
||||
Loading…
Add table
Add a link
Reference in a new issue