diff --git a/.dockerignore b/.dockerignore index 8439270..38086c3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,4 @@ api/.env evals/ +api/mcp_server/ts_validator/node_modules/ +sdk/ diff --git a/.gitignore b/.gitignore index 0e6b619..3a8af6d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ venv/ .playwright-mcp coturn/ *.wav -dograh_pcm_cache/ \ No newline at end of file +dograh_pcm_cache/ +node_modules/ \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile index 21f5f88..6e8831a 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -13,11 +13,6 @@ RUN apt-get update && apt-get install -y \ # Copy and install requirements COPY api/requirements.txt . -# Install CPU-only PyTorch FIRST to prevent CUDA/NVIDIA dependencies -# This satisfies torch dependency before other packages try to pull GPU version -RUN pip install --user --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu && \ - rm -rf /root/.cache/pip - # Install dependencies to user directory for easy copying RUN pip install --user --no-cache-dir -r requirements.txt && \ # Clean up pip cache after installation @@ -25,27 +20,54 @@ RUN pip install --user --no-cache-dir -r requirements.txt && \ # Copy and install pipecat from local submodule COPY pipecat /tmp/pipecat -RUN pip install --user --no-cache-dir '/tmp/pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,local-smart-turn-v3,speechmatics,openrouter,camb]' && \ +RUN pip install --user --no-cache-dir '/tmp/pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb]' && \ + # Swap opencv-python (pulled by pipecat[webrtc]) for opencv-python-headless + # to drop X11/Qt dependencies that otherwise require libxcb etc. in runner. + pip uninstall -y opencv-python && \ + pip install --user --no-cache-dir opencv-python-headless && \ # Pre-download NLTK punkt_tab tokenizer data (required by pipecat at runtime) python -c "import nltk; nltk.download('punkt_tab', quiet=True)" && \ # Clean up pip cache and temporary pipecat directory rm -rf /root/.cache/pip /tmp/pipecat -# Remove unnecessary Python cache files from installed packages +# Strip cache files, test/example dirs, and type stubs from installed packages RUN find /root/.local -type f -name '*.pyc' -delete && \ - find /root/.local -type d -name '__pycache__' -delete && \ - find /root/.local -type f -name '*.pyo' -delete + find /root/.local -type d -name '__pycache__' -prune -exec rm -rf {} + && \ + find /root/.local -type f -name '*.pyo' -delete && \ + find /root/.local -type d \( -name tests -o -name test -o -name examples \) -prune -exec rm -rf {} + && \ + find /root/.local -name '*.pyi' -delete -# Stage 2: Runtime - Minimal image with only runtime dependencies +# Stage 2: Node deps for ts_validator (built with full node:22-slim, only +# node_modules is copied into the runner). +FROM node:22-slim AS ts-deps +WORKDIR /ts_validator +COPY api/mcp_server/ts_validator/package*.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +# Stage 3: Static ffmpeg binary (avoids apt ffmpeg pulling mesa/libllvm for +# hardware acceleration we don't use server-side). +FROM debian:trixie-slim AS ffmpeg-static +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl ca-certificates xz-utils \ + && curl -fsSL -o /tmp/ffmpeg.tar.xz https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \ + && mkdir -p /tmp/ffmpeg \ + && tar -xJf /tmp/ffmpeg.tar.xz -C /tmp/ffmpeg --strip-components=1 \ + && mv /tmp/ffmpeg/ffmpeg /tmp/ffmpeg/ffprobe /usr/local/bin/ \ + && chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe + +# Stage 4: Runtime - Minimal image with only runtime dependencies FROM python:3.12-slim AS runner WORKDIR /app -# Only install ffmpeg (runtime dependency) -RUN apt-get update && apt-get install -y --no-install-recommends \ - ffmpeg \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* +# Static ffmpeg + ffprobe (used by audio_converter, audio_file_cache, etc.) +COPY --from=ffmpeg-static /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg +COPY --from=ffmpeg-static /usr/local/bin/ffprobe /usr/local/bin/ffprobe + +# Node.js 22 binary only (ts_validator subprocess needs node >=22.6 for +# native TypeScript stripping; see api/mcp_server/ts_bridge.py). python:3.12-slim +# already provides libstdc++6, libgcc-s1, and ca-certificates that node needs. +COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node # Copy Python packages from builder stage COPY --from=builder /root/.local /root/.local @@ -65,6 +87,10 @@ ENV PYTHONUNBUFFERED=1 COPY ./api ./api COPY ./scripts/start_services_dev.sh ./scripts/start_services_dev.sh +# ts_validator Node deps (built in ts-deps stage with full node:22-slim image). +# The validator runs as a short-lived subprocess from api/mcp_server/ts_bridge.py. +COPY --from=ts-deps /ts_validator/node_modules ./api/mcp_server/ts_validator/node_modules + # Product documentation — read at runtime by the MCP docs tools # (search_dograh_docs / fetch_dograh_doc) so agents can learn Dograh. COPY ./docs ./docs diff --git a/api/app.py b/api/app.py index 973c08c..f60d0ec 100644 --- a/api/app.py +++ b/api/app.py @@ -27,7 +27,7 @@ from fastapi.middleware.cors import CORSMiddleware from loguru import logger from api.constants import REDIS_URL -from api.mcp import mcp +from api.mcp_server import mcp from api.routes.main import router as main_router from api.services.pipecat.tracing_config import ( handle_langfuse_sync, diff --git a/api/mcp/__init__.py b/api/mcp/__init__.py deleted file mode 100644 index 72dbd23..0000000 --- a/api/mcp/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from api.mcp.server import mcp - -__all__ = ["mcp"] diff --git a/api/mcp/auth.py b/api/mcp/auth.py deleted file mode 100644 index 00fcbec..0000000 --- a/api/mcp/auth.py +++ /dev/null @@ -1,25 +0,0 @@ -from fastapi import HTTPException -from fastmcp.server.dependencies import get_http_headers - -from api.db.models import UserModel -from api.services.auth.depends import _handle_api_key_auth - - -async def authenticate_mcp_request() -> UserModel: - """Resolve the authenticated Dograh user for an MCP tool invocation. - - Accepts either `X-API-Key: ` or `Authorization: Bearer `, - reusing the API-key flow from `api.services.auth.depends`. - """ - headers = get_http_headers() - api_key = headers.get("x-api-key") - if not api_key: - auth = headers.get("authorization", "") - if auth.lower().startswith("bearer "): - api_key = auth.split(" ", 1)[1].strip() - if not api_key: - raise HTTPException( - status_code=401, - detail="Missing API key — send X-API-Key or Authorization: Bearer ", - ) - return await _handle_api_key_auth(api_key) diff --git a/api/mcp/server.py b/api/mcp/server.py deleted file mode 100644 index 91fcfda..0000000 --- a/api/mcp/server.py +++ /dev/null @@ -1,6 +0,0 @@ -from fastmcp import FastMCP - -mcp = FastMCP("dograh") - -from api.mcp.tools import docs as _docs # noqa: E402, F401 -from api.mcp.tools import workflows as _workflows # noqa: E402, F401 diff --git a/api/mcp/tools/docs.py b/api/mcp/tools/docs.py deleted file mode 100644 index f722082..0000000 --- a/api/mcp/tools/docs.py +++ /dev/null @@ -1,115 +0,0 @@ -import re -from functools import lru_cache -from pathlib import Path - -from fastapi import HTTPException -from rank_bm25 import BM25Okapi - -from api.mcp.server import mcp - -DOCS_ROOT = Path(__file__).resolve().parents[3] / "docs" - -_TOKEN_RE = re.compile(r"[A-Za-z0-9_]+") -_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL) -_TITLE_RE = re.compile(r"^title:\s*['\"]?(.+?)['\"]?\s*$", re.MULTILINE) -_H1_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE) - - -def _tokenize(text: str) -> list[str]: - return [t.lower() for t in _TOKEN_RE.findall(text)] - - -def _extract_title(path: Path, body: str) -> str: - fm_match = _FRONTMATTER_RE.match(body) - if fm_match: - title_match = _TITLE_RE.search(fm_match.group(1)) - if title_match: - return title_match.group(1).strip() - h1_match = _H1_RE.search(body) - if h1_match: - return h1_match.group(1).strip() - return path.stem.replace("-", " ").title() - - -def _strip_frontmatter(body: str) -> str: - return _FRONTMATTER_RE.sub("", body, count=1) - - -@lru_cache(maxsize=1) -def _load_index() -> tuple[list[dict], BM25Okapi]: - """Read every docs/**/*.mdx file once and build a BM25 index. - - Cached for the process lifetime — docs rarely change between restarts. - """ - docs: list[dict] = [] - corpus: list[list[str]] = [] - - for path in sorted(DOCS_ROOT.rglob("*.mdx")): - body = path.read_text(encoding="utf-8") - rel = path.relative_to(DOCS_ROOT).as_posix() - title = _extract_title(path, body) - content = _strip_frontmatter(body) - docs.append({"path": rel, "title": title, "content": content}) - corpus.append(_tokenize(f"{title} {content}")) - - return docs, BM25Okapi(corpus) - - -def _snippet(content: str, query_tokens: list[str], width: int = 240) -> str: - lowered = content.lower() - for tok in query_tokens: - idx = lowered.find(tok) - if idx >= 0: - start = max(0, idx - width // 2) - end = min(len(content), start + width) - return ( - ("…" if start > 0 else "") - + content[start:end].strip() - + ("…" if end < len(content) else "") - ) - return content[:width].strip() + ("…" if len(content) > width else "") - - -@mcp.tool -async def search_dograh_docs(query: str, limit: int = 5) -> list[dict]: - """Search Dograh's product documentation. - - Returns the top matches as {path, title, snippet}. Pass the returned - `path` to `fetch_dograh_doc` to read the full page. Use this first - when you need to learn how a Dograh feature works before building - against it. - """ - docs, bm25 = _load_index() - tokens = _tokenize(query) - if not tokens: - return [] - - scores = bm25.get_scores(tokens) - ranked = sorted(zip(scores, docs), key=lambda pair: pair[0], reverse=True)[:limit] - - return [ - { - "path": doc["path"], - "title": doc["title"], - "snippet": _snippet(doc["content"], tokens), - "score": round(float(score), 3), - } - for score, doc in ranked - if score > 0 - ] - - -@mcp.tool -async def fetch_dograh_doc(path: str) -> dict: - """Fetch the full content of a Dograh docs page by its path - (e.g. `core-concepts/workflows.mdx`), as returned by `search_dograh_docs`. - """ - docs, _ = _load_index() - for doc in docs: - if doc["path"] == path: - return { - "path": doc["path"], - "title": doc["title"], - "content": doc["content"], - } - raise HTTPException(status_code=404, detail=f"Doc not found: {path}") diff --git a/api/mcp_server/__init__.py b/api/mcp_server/__init__.py new file mode 100644 index 0000000..19458da --- /dev/null +++ b/api/mcp_server/__init__.py @@ -0,0 +1,3 @@ +from api.mcp_server.server import mcp + +__all__ = ["mcp"] diff --git a/api/mcp_server/auth.py b/api/mcp_server/auth.py new file mode 100644 index 0000000..33a4a46 --- /dev/null +++ b/api/mcp_server/auth.py @@ -0,0 +1,46 @@ +from fastapi import HTTPException +from fastmcp.server.dependencies import get_http_headers +from opentelemetry import trace + +from api.db.models import UserModel +from api.services.auth.depends import _handle_api_key_auth + + +async def authenticate_mcp_request() -> UserModel: + """Resolve the authenticated Dograh user for an MCP tool invocation. + + Accepts either `X-API-Key: ` or `Authorization: Bearer `, + reusing the API-key flow from `api.services.auth.depends`. + + Tags the currently-active OTel span with the resolved organization + and user identifiers. `_OrgRoutingExporter` reads `dograh.org_id` + at export time to dispatch the span to the right Langfuse project; + the `langfuse.user.id` / `langfuse.session.id` attributes make the + span filterable in the Langfuse UI. + """ + headers = get_http_headers() + api_key = headers.get("x-api-key") + if not api_key: + auth = headers.get("authorization", "") + if auth.lower().startswith("bearer "): + api_key = auth.split(" ", 1)[1].strip() + if not api_key: + raise HTTPException( + status_code=401, + detail="Missing API key — send X-API-Key or Authorization: Bearer ", + ) + user = await _handle_api_key_auth(api_key) + + span = trace.get_current_span() + if span.is_recording(): + org_id = user.selected_organization_id + # Intentionally NOT `dograh.org_id` — that attribute triggers the + # per-org Langfuse routing for pipeline spans, and MCP traffic + # should land in the default (developer-facing) project only. + # Exposed under `mcp.org_id` for Langfuse UI filtering without + # affecting the router. + span.set_attribute("mcp.org_id", str(org_id)) + span.set_attribute("mcp.user_id", str(user.id)) + span.set_attribute("langfuse.user.id", str(user.id)) + + return user diff --git a/api/mcp_server/instructions.py b/api/mcp_server/instructions.py new file mode 100644 index 0000000..ecb8772 --- /dev/null +++ b/api/mcp_server/instructions.py @@ -0,0 +1,124 @@ +"""Top-level orchestration guide surfaced to every MCP session. + +Sent to the client via `FastMCP(instructions=...)` — the client bakes +this into its system prompt, so every LLM session sees it before the +first tool call. Prefer procedural orchestration here (call order, error +handling, hard constraints). Design-level per-field guidance belongs in +each `PropertySpec.llm_hint`; it flows out through `get_node_type` and +doesn't need to be repeated here. + +Extend based on real LLM failures — every bullet below ideally maps to a +mistake the system has seen at least once. +""" + +DOGRAH_MCP_INSTRUCTIONS = """\ +You build and edit Dograh voice-AI workflows by emitting TypeScript that +uses the `@dograh/sdk` package. Workflows are stored as JSON; this server +projects them to TypeScript for editing and parses them back on save. + +## Call order + +1. `list_workflows` — locate the target workflow. +2. `get_workflow_code(workflow_id)` — fetch the current source (draft if + one exists, otherwise published). +3. (optional) `list_node_types` / `get_node_type(name)` — consult before + adding or editing a node type whose fields aren't already visible in + the current code. +4. Mutate the code in place. Preserve existing nodes, edges, and variable + names unless the task requires removing or renaming them. +5. `save_workflow(workflow_id, code)` — persist as a new draft. The + published version is untouched. + +## Allowed source shape + +The parser is AST-only and rejects anything outside this grammar. At the +top level, only three statement forms are accepted: + + import ... from "..."; // any import + const = ; // bindings (see below) + wf.edge(, , { label, condition }); // bare edge calls + +`` is one of: + new Workflow({ name: "..." }) + wf.addTyped(({ ...fields }) [, { position: [x, y] }]) + wf.add({ type: "", ...fields [, position: [x, y]] }) + +No functions, arrow fns, loops, conditionals, ternaries, spreads, +destructuring, template interpolation, `export`, or `.map`/`.forEach`. +Data-position values must be plain literals (strings, numbers, booleans, +null, arrays/objects of same). A single `new Workflow(...)` per file — +the `name` you pass there is the workflow's display name and is applied +on save (renames propagate immediately; definition changes go to draft). + +## Adding edges — explicit syntax + + wf.edge(source, target, { label: "...", condition: "..." }); + +Rules: +- `source` and `target` are the **bare variable identifiers** bound by + `wf.addTyped(...)` / `wf.add(...)` — not strings, not `.id`, not inline + factories. Both must be declared earlier in the file. +- `label` is a short tag (≤4 words) shown in call logs to identify the + branch: `"qualified"`, `"wrap up"`, `"retry"`. +- `condition` is a full natural-language predicate the runtime evaluates + against the live conversation: `"caller confirmed interest in a demo"`, + not `"interested"`. Condition clarity determines routing accuracy. +- Both fields are required and must be non-empty strings. +- Edges are directional; emit one `wf.edge(...)` per outgoing branch. +- Place all edges after all node bindings; group by source node. + +Example: + + const greet = wf.addTyped(startCall({ name: "Greet", prompt: "Hi!" })); + const done = wf.addTyped(endCall({ name: "Done", prompt: "Bye." })); + wf.edge(greet, done, { + label: "wrap up", + condition: "user acknowledged the greeting and is ready to end" + }); + +## Hard graph constraints + +- Exactly one `startCall` node per workflow; no incoming edges. +- `endCall` nodes have no outgoing edges. +- `globalNode` has no incoming or outgoing edges; its prompt is prepended + to every other node's prompt at runtime when that node sets + `add_global_prompt=true`. +- Every non-global node must be reachable from `startCall`. + +## Iterating on errors + +`save_workflow` returns one of: + - `parse_error` Disallowed construct (see grammar above) or + malformed TypeScript. + - `validation_error` Node data failed spec validation (unknown field, + missing required, wrong type, bad `options` value). + - `graph_validation` Structural rule broken (missing startCall, + unreachable node, edge to/from wrong node type). + - `bridge_error` Internal — retry once, then surface to the user. + +Every error carries `line` and `column`. Fix at that location and +resubmit the **complete source** — this tool does not accept patches. + +## Field conventions + +- `data.name` is the canonical identifier. Pick a descriptive name + (`"Qualify Budget"`, not `"Node1"`) — the generated code uses it as + the variable name and call logs reference it. +- Reference fields take UUIDs, not human names: + `tool_refs`, `document_refs` → from `list_tools`, `list_documents` + `credential_ref` → from `list_credentials` + `recording_ref` → from `list_recordings` +- `mention_textarea` fields (prompts, greetings, etc.) accept + `{{template_variables}}` — values resolved at runtime from + `pre_call_fetch`, caller context, or earlier extraction passes. + +## Style + +- Prefer `wf.addTyped(factory({ ... }))` over `wf.add({ type, ... })`. +- Only include fields whose values differ from the spec default — the + parser re-applies defaults on save, so extras are noise. +- Omit `position`; the server reconciles positions against the previous + saved workflow and lays out new nodes automatically. +- Add nodes in call-flow order (start → intermediate → end) so the + generated code reads top-to-bottom, with all edges after all nodes. +""" diff --git a/api/mcp_server/server.py b/api/mcp_server/server.py new file mode 100644 index 0000000..d680252 --- /dev/null +++ b/api/mcp_server/server.py @@ -0,0 +1,11 @@ +from fastmcp import FastMCP + +from api.mcp_server.instructions import DOGRAH_MCP_INSTRUCTIONS + +mcp = FastMCP("dograh", instructions=DOGRAH_MCP_INSTRUCTIONS) + +from api.mcp_server.tools import catalog as _catalog # noqa: E402, F401 +from api.mcp_server.tools import get_workflow_code as _get_workflow_code # noqa: E402, F401 +from api.mcp_server.tools import node_types as _node_types # noqa: E402, F401 +from api.mcp_server.tools import save_workflow as _save_workflow # noqa: E402, F401 +from api.mcp_server.tools import workflows as _workflows # noqa: E402, F401 diff --git a/api/mcp/tools/__init__.py b/api/mcp_server/tools/__init__.py similarity index 100% rename from api/mcp/tools/__init__.py rename to api/mcp_server/tools/__init__.py diff --git a/api/mcp_server/tools/catalog.py b/api/mcp_server/tools/catalog.py new file mode 100644 index 0000000..a87f9b9 --- /dev/null +++ b/api/mcp_server/tools/catalog.py @@ -0,0 +1,113 @@ +"""MCP discovery tools for the reference catalogs. + +Node properties of type `tool_refs`, `document_refs`, `recording_ref`, and +`credential_ref` carry UUIDs that resolve against these catalogs. LLMs must +list the catalog before populating those fields with real UUIDs. +""" + +from api.db import db_client +from api.mcp_server.auth import authenticate_mcp_request +from api.mcp_server.server import mcp +from api.mcp_server.tracing import traced_tool + + +@mcp.tool +@traced_tool +async def list_tools(status: str | None = "active") -> list[dict]: + """List tools the agent can invoke during a call. + + Returns each tool's `tool_uuid` (use this in node `tool_uuids` properties), + `name`, `description`, and `category`. Pass `status=None` to include + archived tools. + """ + user = await authenticate_mcp_request() + tools = await db_client.get_tools_for_organization( + organization_id=user.selected_organization_id, + status=status, + ) + return [ + { + "tool_uuid": t.tool_uuid, + "name": t.name, + "description": t.description or "", + "category": t.category, + } + for t in tools + ] + + +@mcp.tool +@traced_tool +async def list_documents() -> list[dict]: + """List knowledge-base documents the agent can reference during a call. + + Returns each document's `document_uuid` (use this in node + `document_uuids` properties), `filename`, and `processing_status`. + """ + user = await authenticate_mcp_request() + documents = await db_client.get_documents_for_organization( + organization_id=user.selected_organization_id, + ) + return [ + { + "document_uuid": d.document_uuid, + "filename": d.filename, + "processing_status": d.processing_status, + "total_chunks": d.total_chunks, + } + for d in documents + ] + + +@mcp.tool +@traced_tool +async def list_credentials() -> list[dict]: + """List external credentials available for webhook auth and pre-call fetch. + + Returns each credential's `credential_uuid` (use this in node + `credential_uuid` / `pre_call_fetch_credential_uuid` properties), `name`, + `description`, and `credential_type`. + """ + user = await authenticate_mcp_request() + credentials = await db_client.get_credentials_for_organization( + organization_id=user.selected_organization_id, + ) + return [ + { + "credential_uuid": c.credential_uuid, + "name": c.name, + "description": c.description or "", + "credential_type": c.credential_type, + } + for c in credentials + ] + + +@mcp.tool +@traced_tool +async def list_recordings(workflow_id: int | None = None) -> list[dict]: + """List pre-recorded audio files available for greetings and edge + transition speech. + + Returns each recording's `recording_id` (use this in + `greeting_recording_id` / `transition_speech_recording_id` properties), + `transcript`, and TTS metadata. Pass `workflow_id` to filter to one + workflow's recordings. + """ + user = await authenticate_mcp_request() + recordings = await db_client.get_recordings( + organization_id=user.selected_organization_id, + workflow_id=workflow_id, + ) + return [ + { + "id": r.id, + "recording_id": r.recording_id, + "workflow_id": r.workflow_id, + "transcript": r.transcript, + "tts_provider": r.tts_provider, + "tts_model": r.tts_model, + "tts_voice_id": r.tts_voice_id, + } + for r in recordings + ] diff --git a/api/mcp_server/tools/get_workflow_code.py b/api/mcp_server/tools/get_workflow_code.py new file mode 100644 index 0000000..311c7ed --- /dev/null +++ b/api/mcp_server/tools/get_workflow_code.py @@ -0,0 +1,71 @@ +"""MCP tool that returns a workflow as SDK TypeScript code. + +Companion to `save_workflow`: the LLM calls `get_workflow_code` to see +the current state of a workflow as editable code, mutates it, and calls +`save_workflow` with the new code. Storage stays JSON; the TS form is +an ephemeral projection for the LLM edit loop. + +Selection priority: latest draft → latest published → legacy +`workflow.workflow_definition`. That matches the UI's "whichever is the +working copy" behavior so the LLM sees what a human editor would see. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException + +from api.db import db_client +from api.mcp_server.auth import authenticate_mcp_request +from api.mcp_server.server import mcp +from api.mcp_server.tracing import traced_tool +from api.mcp_server.ts_bridge import TsBridgeError, generate_code + + +@mcp.tool +@traced_tool +async def get_workflow_code(workflow_id: int) -> dict[str, Any]: + """Return the workflow as SDK TypeScript code the LLM can edit. + + Output shape: + {"code": "", "workflow_id": int, "version": "draft" | "published" | "legacy"} + + The LLM edits `code`, then calls `save_workflow(workflow_id, code)`. + """ + user = await authenticate_mcp_request() + + workflow = await db_client.get_workflow( + workflow_id, organization_id=user.selected_organization_id + ) + if not workflow: + raise HTTPException(status_code=404, detail=f"Workflow {workflow_id} not found") + + # Draft wins over published — editing a draft is the normal flow. + # `current_definition` (is_current=True) is the published row, so we + # fetch the draft explicitly. If the latest draft was just published, + # no draft row exists and we fall through to `released_definition`. + draft = await db_client.get_draft_version(workflow_id) + released = workflow.released_definition + + if draft is not None and draft.workflow_json: + payload = draft.workflow_json + source = "draft" + elif released is not None and released.workflow_json: + payload = released.workflow_json + source = "published" + else: + payload = workflow.workflow_definition or {} + source = "legacy" + + try: + code = await generate_code(payload, workflow_name=workflow.name or "") + except TsBridgeError as e: + raise HTTPException(status_code=500, detail=f"Failed to generate code: {e}") + + return { + "workflow_id": workflow_id, + "name": workflow.name or "", + "version": source, + "code": code, + } diff --git a/api/mcp_server/tools/node_types.py b/api/mcp_server/tools/node_types.py new file mode 100644 index 0000000..f29c940 --- /dev/null +++ b/api/mcp_server/tools/node_types.py @@ -0,0 +1,57 @@ +"""MCP discovery tools for node specifications. + +LLMs call these tools first to learn the available node-type catalog and +each node's property schema before composing or modifying a workflow. +""" + +from fastapi import HTTPException + +from api.mcp_server.auth import authenticate_mcp_request +from api.mcp_server.server import mcp +from api.mcp_server.tracing import traced_tool +from api.services.workflow.node_specs import SPEC_VERSION, all_specs, get_spec + + +@mcp.tool +@traced_tool +async def list_node_types() -> dict: + """List every available node type with a brief summary. + + Use this first to discover what nodes exist, then call `get_node_type` + for the full schema of any node you intend to use. + + Returns: + A dict with `spec_version` (pin against this in any generated workflow + code) and `node_types` (list of {name, display_name, description, + category}). + """ + await authenticate_mcp_request() + return { + "spec_version": SPEC_VERSION, + "node_types": [ + { + "name": spec.name, + "display_name": spec.display_name, + "description": spec.description, + "category": spec.category.value, + } + for spec in all_specs() + ], + } + + +@mcp.tool +@traced_tool +async def get_node_type(name: str) -> dict: + """Fetch the full schema for a node type, including every property's + type, default, conditional visibility rules, and LLM-readable + description, plus worked examples. + + Use the property `description` and the `examples` list to understand + semantics — types alone are not enough. + """ + await authenticate_mcp_request() + spec = get_spec(name) + if spec is None: + raise HTTPException(status_code=404, detail=f"Unknown node type: {name!r}") + return spec.model_dump(mode="json") diff --git a/api/mcp_server/tools/save_workflow.py b/api/mcp_server/tools/save_workflow.py new file mode 100644 index 0000000..c05df55 --- /dev/null +++ b/api/mcp_server/tools/save_workflow.py @@ -0,0 +1,168 @@ +"""MCP tool that accepts LLM-authored SDK TypeScript and saves it as a draft. + +Execution flow: + 1. Parse via the Node TS validator — AST-only, never executes the code. + Returns either a workflow JSON or per-location parse/validate errors. + 2. Pydantic validation via `ReactFlowDTO.model_validate` (defence in + depth; the parser is already spec-driven, but the DTO layer is the + authoritative wire-format gate). + 3. Graph validation via `WorkflowGraph`. + 4. Save as a new draft via `db_client.save_workflow_draft` — the + published version stays intact, so edits are rollback-safe. + +Error codes surfaced to the LLM: + parse_error — TS parse failed or a disallowed construct was used + validation_error — node data failed spec validation (unknown field, + missing required, wrong type, option out of range) + schema_validation — ReactFlowDTO Pydantic rejection (rare; parser bug) + graph_validation — semantic graph rule broken (e.g. no start node) + bridge_error — Node subprocess failed before returning JSON + +All LLM-facing errors include file:line:column where available so the +LLM can correct its code directly. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException +from loguru import logger +from pydantic import ValidationError as PydanticValidationError + +from api.db import db_client +from api.mcp_server.auth import authenticate_mcp_request +from api.mcp_server.server import mcp +from api.mcp_server.tracing import traced_tool +from api.mcp_server.ts_bridge import TsBridgeError, parse_code +from api.services.workflow.dto import ReactFlowDTO +from api.services.workflow.layout import reconcile_positions +from api.services.workflow.workflow import WorkflowGraph + + +async def _previous_workflow_json(workflow: Any) -> dict[str, Any] | None: + """Same selection priority as `get_workflow_code` — the version the + LLM saw is the version we reconcile against. + + `current_definition` (is_current=True) is the published row, so the + draft must be fetched explicitly. If no draft exists (e.g. the last + draft was just published), fall through to `released_definition`. + """ + draft = await db_client.get_draft_version(workflow.id) + if draft is not None and draft.workflow_json: + return draft.workflow_json + released = workflow.released_definition + if released is not None and released.workflow_json: + return released.workflow_json + return workflow.workflow_definition or None + + +def _error_result(code: str, message: str, **extra: Any) -> dict[str, Any]: + return {"saved": False, "error_code": code, "error": message, **extra} + + +def _format_errors(errors: list[dict[str, Any]]) -> str: + parts: list[str] = [] + for e in errors: + loc = "" + line = e.get("line") + col = e.get("column") + if line is not None: + loc = f" (line {line}" + (f", col {col}" if col is not None else "") + ")" + parts.append(f"{e.get('message', '')}{loc}") + return "\n".join(parts) + + +@mcp.tool +@traced_tool +async def save_workflow(workflow_id: int, code: str) -> dict[str, Any]: + """Parse SDK TypeScript and save the resulting workflow as a draft. + + `code` is TypeScript source using `@dograh/sdk`. Fetch the current + code first via `get_workflow_code(workflow_id)`, edit it, then pass + the full updated source here. + + Example code: + import { Workflow } from "@dograh/sdk"; + import { startCall, endCall } from "@dograh/sdk/typed"; + + const wf = new Workflow({ name: "lead_qualification" }); + const greeting = wf.addTyped(startCall({ name: "Greeting", prompt: "Hi!" })); + const done = wf.addTyped(endCall({ name: "Done", prompt: "Bye." })); + wf.edge(greeting, done, { label: "done", condition: "conversation complete" }); + + On success the draft version is saved; the published version is + untouched. + """ + user = await authenticate_mcp_request() + + workflow = await db_client.get_workflow( + workflow_id, organization_id=user.selected_organization_id + ) + if not workflow: + raise HTTPException(status_code=404, detail=f"Workflow {workflow_id} not found") + + # 1. Parse + spec-validate via the Node TS validator. + try: + parsed = await parse_code(code) + except TsBridgeError as e: + logger.warning(f"ts_bridge failure: {e}") + return _error_result("bridge_error", str(e)) + + if not parsed.get("ok"): + stage = parsed.get("stage", "parse") + errs = parsed.get("errors") or [] + code_key = "parse_error" if stage == "parse" else "validation_error" + return _error_result(code_key, _format_errors(errs), errors=errs) + + payload = parsed["workflow"] + new_name = (parsed.get("workflowName") or "").strip() + + # 1b. Reconcile node positions against the previously-stored workflow. + # The parser drops positions by design (LLMs don't place nodes well); + # here we fill them back in from what was there before, and pick + # approximate placements for newly-introduced nodes. + payload = reconcile_positions(payload, await _previous_workflow_json(workflow)) + + # 2. Pydantic shape check (defence in depth — parser is spec-driven). + try: + dto = ReactFlowDTO.model_validate(payload) + except PydanticValidationError as e: + return _error_result("schema_validation", str(e)) + + # 3. Graph-level semantic validation (start-node count, edge shape). + try: + WorkflowGraph(dto) + except (ValueError, Exception) as e: # WorkflowGraph raises ValueError + return _error_result("graph_validation", str(e)) + + # 4a. If the `new Workflow({ name })` in the edited source differs from + # the stored name, rename the workflow. Name is a workflow-level field + # (not versioned), so this takes effect immediately. + name_changed = bool(new_name) and new_name != workflow.name + if name_changed: + await db_client.update_workflow( + workflow_id=workflow_id, + name=new_name, + workflow_definition=None, + template_context_variables=None, + workflow_configurations=None, + organization_id=user.selected_organization_id, + ) + + # 4b. Save as a new draft (existing published version stays intact). + draft = await db_client.save_workflow_draft( + workflow_id=workflow_id, + workflow_definition=payload, + ) + + return { + "saved": True, + "workflow_id": workflow_id, + "version_number": draft.version_number, + "status": draft.status, + "node_count": len(payload["nodes"]), + "edge_count": len(payload["edges"]), + "name": new_name or workflow.name, + "renamed": name_changed, + } diff --git a/api/mcp/tools/workflows.py b/api/mcp_server/tools/workflows.py similarity index 76% rename from api/mcp/tools/workflows.py rename to api/mcp_server/tools/workflows.py index c697baa..7580511 100644 --- a/api/mcp/tools/workflows.py +++ b/api/mcp_server/tools/workflows.py @@ -1,17 +1,20 @@ from fastapi import HTTPException from api.db import db_client -from api.mcp.auth import authenticate_mcp_request -from api.mcp.server import mcp +from api.mcp_server.auth import authenticate_mcp_request +from api.mcp_server.server import mcp +from api.mcp_server.tracing import traced_tool @mcp.tool -async def list_workflows(status: str | None = None) -> list[dict]: +@traced_tool +async def list_workflows(status: str | None = "active") -> list[dict]: """List agents (workflows) in the caller's organization. Returns id, name, status, and created_at for each agent. Use - `get_workflow` to fetch a single agent's full definition. Pass - `status="active"` or `status="archived"` to filter. + `get_workflow` to fetch a single agent's full definition. Defaults + to active agents; pass `status="archived"` to list archived agents, + or `status=None` to list all. """ user = await authenticate_mcp_request() workflows = await db_client.get_all_workflows_for_listing( @@ -30,6 +33,7 @@ async def list_workflows(status: str | None = None) -> list[dict]: @mcp.tool +@traced_tool async def get_workflow(workflow_id: int) -> dict: """Fetch a single agent by id, including its current published definition.""" user = await authenticate_mcp_request() diff --git a/api/mcp_server/tracing.py b/api/mcp_server/tracing.py new file mode 100644 index 0000000..f3327a8 --- /dev/null +++ b/api/mcp_server/tracing.py @@ -0,0 +1,87 @@ +"""OTel tracing for MCP tool invocations. + +The project-wide tracing setup in +`api/services/pipecat/tracing_config.py` already routes spans to +per-organization Langfuse projects based on the `dograh.org_id` span +attribute. This module plugs MCP tool calls into that pipeline: + + @mcp.tool + @traced_tool + async def my_tool(...): ... + +Each decorated invocation produces one span named `mcp.` with +Langfuse-rendered input/output. Organization and user attributes are +stamped separately by `authenticate_mcp_request` when it runs inside +the tool body — the decorator's span is the `current_span` at that +point, so the attributes land on the right span and the router export +dispatches to the correct Langfuse project. +""" + +from __future__ import annotations + +import json +from functools import wraps +from typing import Any, Awaitable, Callable, TypeVar + +from opentelemetry import trace +from opentelemetry.context import Context +from opentelemetry.trace import Status, StatusCode + +R = TypeVar("R") + +_TRACER = trace.get_tracer("dograh.mcp") +# Langfuse truncates long payloads anyway; cap here to keep span size +# bounded. Tune up if you find tool outputs consistently clipped. +_MAX_ATTR_LEN = 8000 + + +def _safe_json(value: Any) -> str: + try: + return json.dumps(value, default=str, ensure_ascii=False) + except Exception: # noqa: BLE001 + return str(value) + + +def traced_tool(fn: Callable[..., Awaitable[R]]) -> Callable[..., Awaitable[R]]: + """Wrap an MCP tool so each invocation produces a span. + + Captures tool name, input kwargs, output, and exceptions. Stacks + below `@mcp.tool` so FastMCP sees the wrapped function when + introspecting the tool schema (`functools.wraps` preserves the + signature the framework reads). + """ + + @wraps(fn) + async def wrapper(*args: Any, **kwargs: Any) -> R: + # Each MCP tool call is its own root trace. Passing an empty + # `Context()` severs the inherited parent so the span doesn't + # graft onto whatever other trace happens to be active (e.g. + # the FastAPI request span, or a client-propagated context). + # One trace per tool invocation makes Langfuse diffing and + # per-org filtering clean. + with _TRACER.start_as_current_span( + f"mcp.{fn.__name__}", + context=Context(), + ) as span: + span.set_attribute("mcp.tool.name", fn.__name__) + # Explicit trace-name override so the Langfuse UI shows + # `mcp.` at the top of the trace instead of whatever + # the framework happens to name the root span. + span.set_attribute("langfuse.trace.name", f"mcp.{fn.__name__}") + span.set_attribute( + "langfuse.observation.input", + _safe_json(kwargs)[:_MAX_ATTR_LEN], + ) + try: + result = await fn(*args, **kwargs) + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR, str(e))) + raise + span.set_attribute( + "langfuse.observation.output", + _safe_json(result)[:_MAX_ATTR_LEN], + ) + return result + + return wrapper diff --git a/api/mcp_server/ts_bridge.py b/api/mcp_server/ts_bridge.py new file mode 100644 index 0000000..ec9de3d --- /dev/null +++ b/api/mcp_server/ts_bridge.py @@ -0,0 +1,93 @@ +"""Python-side bridge to the Node TS validator. + +Spawns `node api/mcp_server/ts_validator/src/index.ts` as a short-lived +subprocess per call, streams a JSON request on stdin, reads a JSON +response from stdout. The validator never executes LLM code — it either +emits TypeScript from a workflow JSON (`generate`) or parses LLM-authored +TS back into a workflow JSON via AST walking (`parse`). + +The subprocess startup cost is ~100-200ms per call. Fine for MCP tool +rates; if it ever matters, the validator can be promoted to a long-lived +worker over a unix socket without changing this interface. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + +from api.services.workflow.node_specs import all_specs + +_VALIDATOR_ENTRY = Path(__file__).resolve().parent / "ts_validator" / "src" / "index.ts" + + +class TsBridgeError(Exception): + """The Node subprocess failed before producing a JSON response.""" + + +def _specs_payload() -> list[dict[str, Any]]: + return [s.model_dump(mode="json") for s in all_specs()] + + +async def _invoke(request: dict[str, Any]) -> dict[str, Any]: + proc = await asyncio.create_subprocess_exec( + "node", + str(_VALIDATOR_ENTRY), + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate(json.dumps(request).encode("utf-8")) + if proc.returncode != 0 and not stdout: + raise TsBridgeError( + f"ts_validator exited {proc.returncode}: " + f"{stderr.decode('utf-8', errors='replace')}" + ) + try: + return json.loads(stdout.decode("utf-8")) + except json.JSONDecodeError as e: + raise TsBridgeError( + f"ts_validator emitted non-JSON: {stdout!r} (stderr: {stderr!r})" + ) from e + + +async def generate_code(workflow: dict[str, Any], *, workflow_name: str = "") -> str: + """Emit SDK TypeScript source from a workflow JSON payload. + + Raises `TsBridgeError` if the validator can't produce code (unknown + node type, dangling edge reference, etc.) — these are bugs at the + caller layer, not user input, so we fail loudly. + """ + result = await _invoke( + { + "command": "generate", + "workflow": workflow, + "specs": _specs_payload(), + "workflowName": workflow_name, + } + ) + if not result.get("ok"): + errs = result.get("errors") or [{"message": "unknown failure"}] + raise TsBridgeError( + "generate_code failed: " + "; ".join(e.get("message", "") for e in errs) + ) + return result["code"] + + +async def parse_code(code: str) -> dict[str, Any]: + """Parse LLM-authored TS back into a workflow JSON. + + Returns the raw validator response — `{"ok": True, "workflow": {...}}` + on success, `{"ok": False, "stage": "parse" | "validate", "errors": [...]}` + on author-side failure. Author-side failures are surfaced to the LLM + verbatim so it can iterate; callers should not re-wrap them. + """ + return await _invoke( + { + "command": "parse", + "code": code, + "specs": _specs_payload(), + } + ) diff --git a/api/mcp_server/ts_validator/.gitignore b/api/mcp_server/ts_validator/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/api/mcp_server/ts_validator/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/api/mcp_server/ts_validator/package-lock.json b/api/mcp_server/ts_validator/package-lock.json new file mode 100644 index 0000000..b26fec6 --- /dev/null +++ b/api/mcp_server/ts_validator/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": "dograh-ts-validator", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dograh-ts-validator", + "version": "0.0.0", + "dependencies": { + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=22.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/api/mcp_server/ts_validator/package.json b/api/mcp_server/ts_validator/package.json new file mode 100644 index 0000000..4c32194 --- /dev/null +++ b/api/mcp_server/ts_validator/package.json @@ -0,0 +1,13 @@ +{ + "name": "dograh-ts-validator", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Node helper invoked by the Python MCP server. Converts workflow JSON to SDK TypeScript code (generate) and parses LLM-authored TS back into a validated workflow JSON (parse). Runs as a short-lived subprocess over stdin/stdout.", + "dependencies": { + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=22.6" + } +} diff --git a/api/mcp_server/ts_validator/src/generate.ts b/api/mcp_server/ts_validator/src/generate.ts new file mode 100644 index 0000000..031b1a4 --- /dev/null +++ b/api/mcp_server/ts_validator/src/generate.ts @@ -0,0 +1,304 @@ +// JSON → TypeScript source. Emits flat code the LLM can read and edit: +// imports, a `Workflow` construction, one `addTyped` per node, one `edge` +// per edge. Variable names are derived from `data.name` (falling back to +// the node id) and deduplicated so the AST round-trips back through +// `parse.ts` into the same workflow JSON. + +import type { + GenerateResult, + NodeSpec, + PropertySpec, + WireWorkflow, +} from "./types.ts"; + +export function generateCode( + workflow: WireWorkflow, + specs: NodeSpec[], + opts: { workflowName?: string } = {}, +): GenerateResult { + const specByName = new Map(specs.map((s) => [s.name, s])); + + // Catch unknown node types up-front — otherwise we'd emit an import + // line for a factory that doesn't exist. + for (const n of workflow.nodes) { + if (!specByName.has(n.type)) { + return { + ok: false, + errors: [ + { + message: `Unknown node type in workflow: "${n.type}"`, + }, + ], + }; + } + } + + const factoryNames = [ + ...new Set(workflow.nodes.map((n) => n.type)), + ].sort(); + const nodeVarById = new Map(); + const usedNames = new Set(); + + const lines: string[] = []; + lines.push(`import { Workflow } from "@dograh/sdk";`); + if (factoryNames.length > 0) { + lines.push( + `import { ${factoryNames.join(", ")} } from "@dograh/sdk/typed";`, + ); + } + lines.push(""); + const wfName = opts.workflowName ?? ""; + lines.push( + `const wf = new Workflow(${renderObject({ name: wfName }, 0)});`, + ); + lines.push(""); + + for (const node of workflow.nodes) { + const varName = pickVarName(node, usedNames); + nodeVarById.set(node.id, varName); + + const spec = specByName.get(node.type)!; + // Strip legacy/UI-state fields the spec doesn't know about + // (e.g. `invalid`, `selected`, `dragging`, `is_start`, + // `validationMessage`). They accumulated in stored workflow + // data before the parser enforced spec validation, and are + // pure noise from the LLM's perspective — dropping them keeps + // the editing surface clean and avoids a pointless save-time + // rejection round-trip. + const knownOnly = stripUnknown(node.data, spec); + const data = stripDefaults(knownOnly, spec); + const factoryArg = renderObject(data, 0); + + // Positions are intentionally omitted — LLMs don't place nodes + // sensibly, so we let a downstream auto-layout pass (future + // enhancement) assign coordinates on save. Existing positions + // in the DB are preserved by `parse.ts` defaulting to {0,0} + // and the save path leaving pre-existing node positions alone. + lines.push( + `const ${varName} = wf.addTyped(${node.type}(${factoryArg}));`, + ); + } + + if (workflow.edges.length > 0) { + lines.push(""); + } + for (const edge of workflow.edges) { + const src = nodeVarById.get(edge.source); + const tgt = nodeVarById.get(edge.target); + if (!src || !tgt) { + return { + ok: false, + errors: [ + { + message: + `Edge ${edge.id} references unknown node ` + + `(source=${edge.source}, target=${edge.target}).`, + }, + ], + }; + } + const cleanedEdge = pickEdgeFields(edge.data); + const edgeOpts = renderObject(cleanedEdge, 0); + lines.push(`wf.edge(${src}, ${tgt}, ${edgeOpts});`); + } + + return { ok: true, code: lines.join("\n") + "\n" }; +} + +// ─── helpers ────────────────────────────────────────────────────────── + +function pickVarName( + node: { id: string; data: Record }, + used: Set, +): string { + const seed = + typeof node.data["name"] === "string" && node.data["name"].trim() + ? (node.data["name"] as string) + : `node_${node.id}`; + const base = sanitizeIdentifier(seed); + let candidate = base; + let i = 2; + while (used.has(candidate) || RESERVED.has(candidate)) { + candidate = `${base}_${i++}`; + } + used.add(candidate); + return candidate; +} + +function sanitizeIdentifier(raw: string): string { + const cleaned = raw + .trim() + .replace(/[^a-zA-Z0-9_]+/g, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase(); + if (!cleaned) return "node"; + if (/^[0-9]/.test(cleaned)) return `n_${cleaned}`; + return cleaned; +} + +const RESERVED = new Set([ + "wf", + "const", + "let", + "var", + "new", + "function", + "class", + "import", + "export", + "return", + "if", + "else", + "for", + "while", + "do", + "switch", + "case", + "break", + "continue", + "default", + "throw", + "try", + "catch", + "finally", + "await", + "async", + "true", + "false", + "null", + "undefined", + "this", + "super", + "in", + "of", + "typeof", + "instanceof", + "delete", + "void", + "yield", + "Workflow", +]); + +// Drop keys not declared in the spec. Handles nested `fixed_collection` +// rows by recursing through sub-property specs. Anything that isn't in +// the spec is legacy/UI state and should never reach the LLM. +function stripUnknown( + data: Record, + spec: NodeSpec, +): Record { + const known = new Map(); + for (const p of spec.properties ?? []) known.set(p.name, p); + + const out: Record = {}; + for (const [k, v] of Object.entries(data)) { + const prop = known.get(k); + if (!prop) continue; // drop unknown + if (prop.type === "fixed_collection" && Array.isArray(v)) { + const rowSpec: NodeSpec = { + name: prop.name, + properties: prop.properties ?? [], + }; + out[k] = v.map((row) => + row && typeof row === "object" && !Array.isArray(row) + ? stripUnknown(row as Record, rowSpec) + : row, + ); + } else { + out[k] = v; + } + } + return out; +} + +// Edge schema is fixed (no NodeSpec for edges). Mirrors the allowed +// fields on `Workflow.edge(...)` in both SDKs. +const KNOWN_EDGE_FIELDS = new Set([ + "label", + "condition", + "transition_speech", + "transition_speech_type", + "transition_speech_recording_id", +]); + +function pickEdgeFields( + data: Record, +): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(data)) { + if (KNOWN_EDGE_FIELDS.has(k)) out[k] = v; + } + return out; +} + +// Drop keys whose value equals the spec default — keeps emitted code tight. +function stripDefaults( + data: Record, + spec: NodeSpec, +): Record { + const out: Record = {}; + const defaults = new Map(); + for (const prop of spec.properties ?? []) { + if (prop.default !== undefined && prop.default !== null) { + defaults.set(prop.name, prop.default); + } + } + for (const [k, v] of Object.entries(data)) { + if (defaults.has(k) && deepEqual(defaults.get(k), v)) continue; + out[k] = v; + } + return out; +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== typeof b) return false; + if (a === null || b === null) return false; + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((el, i) => deepEqual(el, b[i])); + } + if (typeof a === "object" && typeof b === "object") { + const ak = Object.keys(a as object).sort(); + const bk = Object.keys(b as object).sort(); + if (ak.length !== bk.length) return false; + if (ak.some((k, i) => k !== bk[i])) return false; + return ak.every((k) => + deepEqual( + (a as Record)[k], + (b as Record)[k], + ), + ); + } + return false; +} + +// Object renderer biased for readability — strings use single-line JSON, +// nested objects/arrays indent one level per depth. +function renderObject(obj: Record, depth: number): string { + const keys = Object.keys(obj); + if (keys.length === 0) return "{}"; + const pad = " ".repeat(depth + 1); + const closingPad = " ".repeat(depth); + const parts = keys.map((k) => { + const v = renderValue(obj[k], depth + 1); + return `${pad}${k}: ${v}`; + }); + return `{\n${parts.join(",\n")},\n${closingPad}}`; +} + +function renderValue(v: unknown, depth: number): string { + if (v === null || v === undefined) return "null"; + if (typeof v === "string") return JSON.stringify(v); + if (typeof v === "number" || typeof v === "boolean") return String(v); + if (Array.isArray(v)) { + if (v.length === 0) return "[]"; + const pad = " ".repeat(depth + 1); + const closingPad = " ".repeat(depth); + const items = v.map((el) => `${pad}${renderValue(el, depth + 1)}`); + return `[\n${items.join(",\n")},\n${closingPad}]`; + } + if (typeof v === "object") { + return renderObject(v as Record, depth); + } + return JSON.stringify(v); +} diff --git a/api/mcp_server/ts_validator/src/index.ts b/api/mcp_server/ts_validator/src/index.ts new file mode 100644 index 0000000..3154fa9 --- /dev/null +++ b/api/mcp_server/ts_validator/src/index.ts @@ -0,0 +1,74 @@ +// Stdin/stdout dispatch. Reads a single JSON request, routes to +// generate or parse, writes a single JSON response. Exits 0 on request +// success (including validation failures — those are in the JSON), and +// exits 1 only on internal errors (bad input JSON, unhandled exception). + +import { generateCode } from "./generate.ts"; +import { parseCode } from "./parse.ts"; +import type { NodeSpec, WireWorkflow } from "./types.ts"; + +interface GenerateRequest { + command: "generate"; + workflow: WireWorkflow; + specs: NodeSpec[]; + workflowName?: string; +} + +interface ParseRequest { + command: "parse"; + code: string; + specs: NodeSpec[]; +} + +type Request = GenerateRequest | ParseRequest; + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString("utf-8"); +} + +function writeResult(payload: unknown): void { + process.stdout.write(JSON.stringify(payload)); +} + +async function main(): Promise { + const input = await readStdin(); + let req: Request; + try { + req = JSON.parse(input) as Request; + } catch (e) { + writeResult({ + ok: false, + stage: "internal", + errors: [{ message: `Invalid JSON on stdin: ${(e as Error).message}` }], + }); + process.exit(1); + } + + if (req.command === "generate") { + writeResult(generateCode(req.workflow, req.specs, { workflowName: req.workflowName })); + return; + } + if (req.command === "parse") { + writeResult(parseCode(req.code, req.specs)); + return; + } + writeResult({ + ok: false, + stage: "internal", + errors: [{ message: `Unknown command: ${(req as { command?: unknown }).command}` }], + }); + process.exit(1); +} + +main().catch((err: unknown) => { + writeResult({ + ok: false, + stage: "internal", + errors: [{ message: (err as Error).stack ?? String(err) }], + }); + process.exit(1); +}); diff --git a/api/mcp_server/ts_validator/src/parse.ts b/api/mcp_server/ts_validator/src/parse.ts new file mode 100644 index 0000000..6e6b7b9 --- /dev/null +++ b/api/mcp_server/ts_validator/src/parse.ts @@ -0,0 +1,612 @@ +// TypeScript → workflow JSON. +// +// Parses LLM-authored SDK code with the TypeScript compiler, walks the +// AST statement by statement, and builds up a workflow JSON from the +// recognized SDK patterns: +// +// const wf = new Workflow({ name: "..." }); +// const X = wf.addTyped(startCall({ ...fields })); +// const Y = wf.add({ type: "endCall", ...fields }); +// wf.edge(X, Y, { label: "...", condition: "..." }); +// +// No code is executed. Any top-level statement that doesn't match one +// of the recognized shapes is a parse error with a file:line:col pointer +// so the LLM can iterate. Node data is validated against the spec +// catalog before returning. + +import ts from "typescript"; + +import type { + NodeSpec, + ParseErrorItem, + ParseResult, + PropertySpec, + WireEdge, + WireNode, +} from "./types.ts"; + +export function parseCode(code: string, specs: NodeSpec[]): ParseResult { + const specByName = new Map(specs.map((s) => [s.name, s])); + const sourceFile = ts.createSourceFile( + "workflow.ts", + code, + ts.ScriptTarget.ESNext, + true, + ts.ScriptKind.TS, + ); + + const errors: ParseErrorItem[] = []; + const nodes: WireNode[] = []; + const edges: WireEdge[] = []; + const nodeRefs = new Map(); + let workflowVar: string | null = null; + let workflowName = ""; + let nextId = 1; + + const addError = (node: ts.Node, message: string): void => { + const pos = sourceFile.getLineAndCharacterOfPosition(node.getStart()); + errors.push({ + message, + line: pos.line + 1, + column: pos.character + 1, + }); + }; + + for (const stmt of sourceFile.statements) { + if (ts.isImportDeclaration(stmt)) continue; // imports are harmless + if ( + ts.isExportAssignment(stmt) || + stmt.kind === ts.SyntaxKind.EmptyStatement + ) { + continue; + } + + // `const X = ...;` or `wf.edge(...);` + if (ts.isVariableStatement(stmt)) { + handleVariableStatement(stmt); + continue; + } + if (ts.isExpressionStatement(stmt)) { + handleExpressionStatement(stmt); + continue; + } + addError( + stmt, + `Only imports, \`const X = ...\` bindings, and \`wf.edge(...)\` calls are allowed at the top level. Found: ${ts.SyntaxKind[stmt.kind]}.`, + ); + } + + function handleVariableStatement(stmt: ts.VariableStatement): void { + const modifiers = ts.getModifiers(stmt); + if (modifiers && modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) { + addError(stmt, "`export` is not allowed on workflow bindings."); + return; + } + if ((stmt.declarationList.flags & ts.NodeFlags.Const) === 0) { + addError(stmt, "Use `const` for all bindings."); + return; + } + for (const decl of stmt.declarationList.declarations) { + if (!ts.isIdentifier(decl.name)) { + addError(decl, "Destructuring is not allowed — use a single identifier."); + continue; + } + if (!decl.initializer) { + addError(decl, "Bindings must have an initializer."); + continue; + } + const varName = decl.name.text; + handleBinding(varName, decl.initializer, decl); + } + } + + function handleBinding( + varName: string, + initializer: ts.Expression, + origin: ts.Node, + ): void { + const expr = unwrapAwait(initializer); + + // `const wf = new Workflow({ name: "..." })` + if (ts.isNewExpression(expr)) { + if (!ts.isIdentifier(expr.expression) || expr.expression.text !== "Workflow") { + addError(origin, "Only `new Workflow(...)` is supported for object construction."); + return; + } + if (workflowVar) { + addError(origin, `A Workflow is already bound (as \`${workflowVar}\`). Only one Workflow is allowed.`); + return; + } + const args = expr.arguments ?? ts.factory.createNodeArray(); + if (args.length > 0) { + const val = literalToJs(args[0]!, addError); + if ( + val && + typeof val === "object" && + !Array.isArray(val) && + typeof (val as Record)["name"] === "string" + ) { + workflowName = (val as Record)["name"] as string; + } + } + workflowVar = varName; + return; + } + + // `const X = wf.addTyped(factory({...}))` or `wf.add({ type: "...", ... })` + if (ts.isCallExpression(expr)) { + const call = expr; + const callee = call.expression; + + // Must be `wf.XYZ(...)` — property access off the workflow var + if ( + !ts.isPropertyAccessExpression(callee) || + !ts.isIdentifier(callee.expression) || + (workflowVar !== null && callee.expression.text !== workflowVar) + ) { + addError( + origin, + `Expected \`${workflowVar ?? "wf"}.addTyped(...)\` or \`${workflowVar ?? "wf"}.add(...)\`.`, + ); + return; + } + if (!workflowVar) { + addError(origin, "Workflow must be constructed before adding nodes."); + return; + } + + const method = callee.name.text; + if (method === "addTyped") { + handleAddTyped(varName, call, origin); + } else if (method === "add") { + handleAddGeneric(varName, call, origin); + } else { + addError( + origin, + `Unsupported method \`${method}\`. Use \`addTyped\` or \`add\`.`, + ); + } + return; + } + + addError( + origin, + "Only `new Workflow(...)`, `wf.addTyped(...)`, and `wf.add(...)` are allowed as binding initializers.", + ); + } + + function handleAddTyped( + varName: string, + call: ts.CallExpression, + origin: ts.Node, + ): void { + if (call.arguments.length < 1 || call.arguments.length > 2) { + addError(origin, "`addTyped` takes 1 or 2 arguments."); + return; + } + const inner = call.arguments[0]!; + if (!ts.isCallExpression(inner) || !ts.isIdentifier(inner.expression)) { + addError( + origin, + "`addTyped` must be called with a factory invocation, e.g. `addTyped(startCall({ ... }))`.", + ); + return; + } + const factoryName = inner.expression.text; + if (!specByName.has(factoryName)) { + addError( + origin, + `Unknown node type: \`${factoryName}\`. Check the list of registered node types.`, + ); + return; + } + const factoryArgs = inner.arguments; + let data: Record = {}; + if (factoryArgs.length > 0) { + const parsed = literalToJs(factoryArgs[0]!, addError); + if (parsed !== undefined) { + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + addError(inner, "Factory argument must be an object literal."); + return; + } + data = parsed as Record; + } + } + // Optional position arg + const position = extractPositionArg(call.arguments[1], addError); + bindNode(varName, factoryName, data, position, origin); + } + + function handleAddGeneric( + varName: string, + call: ts.CallExpression, + origin: ts.Node, + ): void { + if (call.arguments.length !== 1) { + addError(origin, "`add` takes exactly 1 object argument."); + return; + } + const parsed = literalToJs(call.arguments[0]!, addError); + if (parsed === undefined) return; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + addError(origin, "`add` argument must be an object literal."); + return; + } + const obj = parsed as Record; + const typeValue = obj["type"]; + if (typeof typeValue !== "string") { + addError(origin, "`add({ type, ... })` requires a string `type` field."); + return; + } + if (!specByName.has(typeValue)) { + addError(origin, `Unknown node type: \`${typeValue}\`.`); + return; + } + let position: { x: number; y: number } | undefined; + if (obj["position"] !== undefined) { + const p = obj["position"]; + if ( + Array.isArray(p) && + p.length === 2 && + typeof p[0] === "number" && + typeof p[1] === "number" + ) { + position = { x: p[0], y: p[1] }; + } else { + addError( + origin, + "`position` must be a [x, y] tuple of numbers.", + ); + return; + } + } + const { type: _ignored, position: _ignored2, ...rest } = obj; + bindNode(varName, typeValue, rest, position, origin); + } + + function bindNode( + varName: string, + type: string, + data: Record, + position: { x: number; y: number } | undefined, + origin: ts.Node, + ): void { + if (nodeRefs.has(varName)) { + addError(origin, `Variable \`${varName}\` is already bound.`); + return; + } + const node: WireNode = { + id: String(nextId++), + type, + position: position ?? { x: 0, y: 0 }, + data, + }; + nodes.push(node); + nodeRefs.set(varName, node); + } + + function handleExpressionStatement(stmt: ts.ExpressionStatement): void { + const expr = unwrapAwait(stmt.expression); + if (!ts.isCallExpression(expr)) { + addError(stmt, "Only `wf.edge(...)` calls are allowed as bare statements."); + return; + } + const callee = expr.expression; + if ( + !ts.isPropertyAccessExpression(callee) || + !ts.isIdentifier(callee.expression) || + (workflowVar !== null && callee.expression.text !== workflowVar) || + callee.name.text !== "edge" + ) { + addError(stmt, "Only `wf.edge(source, target, opts)` is allowed as a bare statement."); + return; + } + if (expr.arguments.length !== 3) { + addError(stmt, "`edge` takes exactly 3 arguments: (source, target, opts)."); + return; + } + const [srcArg, tgtArg, optsArg] = expr.arguments; + if (!ts.isIdentifier(srcArg!) || !ts.isIdentifier(tgtArg!)) { + addError(stmt, "`edge` source and target must be variable identifiers bound by `addTyped`/`add`."); + return; + } + const src = nodeRefs.get(srcArg.text); + const tgt = nodeRefs.get(tgtArg.text); + if (!src) { + addError(srcArg, `Unknown node variable: \`${srcArg.text}\`.`); + return; + } + if (!tgt) { + addError(tgtArg, `Unknown node variable: \`${tgtArg.text}\`.`); + return; + } + const opts = literalToJs(optsArg!, addError); + if (opts === undefined) return; + if (typeof opts !== "object" || opts === null || Array.isArray(opts)) { + addError(stmt, "`edge` options must be an object literal."); + return; + } + const optsObj = opts as Record; + if (typeof optsObj["label"] !== "string" || (optsObj["label"] as string).trim() === "") { + addError(stmt, "`edge` requires a non-empty `label` string."); + return; + } + if (typeof optsObj["condition"] !== "string" || (optsObj["condition"] as string).trim() === "") { + addError(stmt, "`edge` requires a non-empty `condition` string."); + return; + } + edges.push({ + id: `${src.id}-${tgt.id}`, + source: src.id, + target: tgt.id, + data: optsObj, + }); + } + + // ── terminate early on parse errors ────────────────────────────── + if (errors.length > 0) { + return { ok: false, stage: "parse", errors }; + } + + if (!workflowVar) { + return { + ok: false, + stage: "parse", + errors: [ + { + message: + "No Workflow construction found. Expected `const wf = new Workflow({ name: \"...\" });`.", + }, + ], + }; + } + + // ── spec-driven node validation ───────────────────────────────── + const validationErrors: ParseErrorItem[] = []; + for (const node of nodes) { + const spec = specByName.get(node.type)!; + const validated = validateNodeData( + spec, + node.data, + (msg) => validationErrors.push({ message: `[${node.type}] ${msg}` }), + ); + if (validated !== null) node.data = validated; + } + if (validationErrors.length > 0) { + return { ok: false, stage: "validate", errors: validationErrors }; + } + + return { + ok: true, + workflow: { + nodes, + edges, + viewport: { x: 0, y: 0, zoom: 1 }, + }, + workflowName, + }; +} + +// ─── helpers ────────────────────────────────────────────────────────── + +function unwrapAwait(expr: ts.Expression): ts.Expression { + return ts.isAwaitExpression(expr) ? expr.expression : expr; +} + +function extractPositionArg( + arg: ts.Expression | undefined, + addError: (n: ts.Node, m: string) => void, +): { x: number; y: number } | undefined { + if (!arg) return undefined; + const parsed = literalToJs(arg, addError); + if (parsed === undefined || parsed === null) return undefined; + if ( + typeof parsed === "object" && + !Array.isArray(parsed) && + Array.isArray((parsed as Record)["position"]) + ) { + const p = (parsed as Record)["position"] as unknown[]; + if (p.length === 2 && typeof p[0] === "number" && typeof p[1] === "number") { + return { x: p[0], y: p[1] }; + } + } + addError(arg, "Optional second arg must be `{ position: [x, y] }`."); + return undefined; +} + +// Convert an expression to a plain JS value. Accepts: string, number, +// boolean, null, undefined (→ undefined), array/object literals of the +// same. Rejects any expression with runtime semantics (identifiers other +// than `true/false/null/undefined`, function calls, arrow fns, etc.). +function literalToJs( + expr: ts.Expression, + addError: (n: ts.Node, m: string) => void, +): unknown { + if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) { + return expr.text; + } + if (ts.isNumericLiteral(expr)) return Number(expr.text); + if (expr.kind === ts.SyntaxKind.TrueKeyword) return true; + if (expr.kind === ts.SyntaxKind.FalseKeyword) return false; + if (expr.kind === ts.SyntaxKind.NullKeyword) return null; + if (ts.isIdentifier(expr) && expr.text === "undefined") return undefined; + if (ts.isPrefixUnaryExpression(expr)) { + if (expr.operator === ts.SyntaxKind.MinusToken) { + const inner = literalToJs(expr.operand, addError); + if (typeof inner === "number") return -inner; + } + if (expr.operator === ts.SyntaxKind.PlusToken) { + const inner = literalToJs(expr.operand, addError); + if (typeof inner === "number") return inner; + } + addError(expr, "Unsupported unary operator; only numeric negation is allowed."); + return undefined; + } + if (ts.isArrayLiteralExpression(expr)) { + const out: unknown[] = []; + for (const el of expr.elements) { + if (el.kind === ts.SyntaxKind.OmittedExpression) { + addError(el, "Sparse arrays are not allowed."); + return undefined; + } + if (ts.isSpreadElement(el)) { + addError(el, "Spread elements are not allowed in array literals."); + return undefined; + } + const v = literalToJs(el, addError); + if (v === undefined && el.kind !== ts.SyntaxKind.Identifier) { + return undefined; + } + out.push(v); + } + return out; + } + if (ts.isObjectLiteralExpression(expr)) { + const out: Record = {}; + for (const prop of expr.properties) { + if (!ts.isPropertyAssignment(prop)) { + addError(prop, "Only plain `key: value` properties are allowed (no methods, shorthand, spread, or computed keys)."); + return undefined; + } + let key: string; + if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) { + key = prop.name.text; + } else { + addError(prop.name, "Property keys must be identifiers or string literals."); + return undefined; + } + const val = literalToJs(prop.initializer, addError); + if (val === undefined && prop.initializer.kind !== ts.SyntaxKind.Identifier) { + // treat explicit `undefined` as omission + continue; + } + out[key] = val; + } + return out; + } + if (ts.isTemplateExpression(expr)) { + addError(expr, "Template literals with interpolation are not allowed — use plain strings."); + return undefined; + } + addError(expr, `Unsupported expression (${ts.SyntaxKind[expr.kind]}). Only literals are allowed in data positions.`); + return undefined; +} + +// Spec-driven validation, mirrors the shape of +// `sdk/python/src/dograh_sdk/_validation.py` but lightweight — applies +// defaults for missing optionals, catches unknown keys, enforces `options` +// membership, and type-shapes the scalar and `fixed_collection` cases. +function validateNodeData( + spec: NodeSpec, + data: Record, + addError: (message: string) => void, +): Record | null { + const out: Record = {}; + const known = new Map(); + for (const p of spec.properties ?? []) known.set(p.name, p); + + for (const key of Object.keys(data)) { + if (!known.has(key)) { + addError(`Unknown field: \`${key}\`.`); + return null; + } + } + + for (const [key, prop] of known) { + if (key in data) { + out[key] = data[key]; + } else if (prop.default !== undefined && prop.default !== null) { + out[key] = prop.default; + } else if (prop.required) { + addError(`Missing required field: \`${key}\`.`); + return null; + } + } + + for (const [key, prop] of known) { + if (!(key in out)) continue; + const value = out[key]; + const err = checkPropertyShape(prop, value); + if (err) { + addError(`Field \`${key}\`: ${err}`); + return null; + } + } + + return out; +} + +function checkPropertyShape(prop: PropertySpec, value: unknown): string | null { + switch (prop.type) { + case "string": + case "mention_textarea": + case "url": + case "recording_ref": + case "credential_ref": + if (typeof value !== "string") return `expected string, got ${jsTypeOf(value)}.`; + return null; + case "number": + if (typeof value !== "number") return `expected number, got ${jsTypeOf(value)}.`; + return null; + case "boolean": + if (typeof value !== "boolean") return `expected boolean, got ${jsTypeOf(value)}.`; + return null; + case "tool_refs": + case "document_refs": + case "multi_options": + if (!Array.isArray(value)) return `expected array, got ${jsTypeOf(value)}.`; + for (const el of value) { + if (prop.type === "multi_options") { + if (!isInOptions(prop, el)) { + return `value \`${JSON.stringify(el)}\` is not in the allowed options.`; + } + } else if (typeof el !== "string") { + return `array elements must be strings.`; + } + } + return null; + case "options": + if (!isInOptions(prop, value)) { + return `value \`${JSON.stringify(value)}\` is not in the allowed options.`; + } + return null; + case "json": + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return `expected JSON object, got ${jsTypeOf(value)}.`; + } + return null; + case "fixed_collection": + if (!Array.isArray(value)) return `expected array of rows, got ${jsTypeOf(value)}.`; + for (let i = 0; i < value.length; i++) { + const row = value[i]; + if (typeof row !== "object" || row === null || Array.isArray(row)) { + return `row ${i}: expected object, got ${jsTypeOf(row)}.`; + } + for (const sub of prop.properties ?? []) { + const subVal = (row as Record)[sub.name]; + if (subVal === undefined) { + if (sub.required && (sub.default === undefined || sub.default === null)) { + return `row ${i}: missing required field \`${sub.name}\`.`; + } + continue; + } + const subErr = checkPropertyShape(sub, subVal); + if (subErr) return `row ${i}, \`${sub.name}\`: ${subErr}`; + } + } + return null; + default: + return null; // Unknown types pass — forward compat. + } +} + +function isInOptions(prop: PropertySpec, value: unknown): boolean { + if (!prop.options) return true; + return prop.options.some((o) => o.value === value); +} + +function jsTypeOf(v: unknown): string { + if (v === null) return "null"; + if (Array.isArray(v)) return "array"; + return typeof v; +} diff --git a/api/mcp_server/ts_validator/src/types.ts b/api/mcp_server/ts_validator/src/types.ts new file mode 100644 index 0000000..8edfdd5 --- /dev/null +++ b/api/mcp_server/ts_validator/src/types.ts @@ -0,0 +1,57 @@ +// Shared shapes used by both generate and parse. Mirror the `ReactFlowDTO` +// wire format on the Python side (`api/services/workflow/dto.py`) and the +// node-spec JSON served by `/api/v1/node-types` / dumped by +// `python -m api.services.workflow.node_specs`. + +export interface PropertyOption { + value: string | number | boolean; + label: string; +} + +export interface PropertySpec { + name: string; + type: string; + required?: boolean; + default?: unknown; + options?: PropertyOption[]; + properties?: PropertySpec[]; +} + +export interface NodeSpec { + name: string; + properties: PropertySpec[]; +} + +export interface WireNode { + id: string; + type: string; + position: { x: number; y: number }; + data: Record; +} + +export interface WireEdge { + id: string; + source: string; + target: string; + data: Record; +} + +export interface WireWorkflow { + nodes: WireNode[]; + edges: WireEdge[]; + viewport: { x: number; y: number; zoom: number }; +} + +export interface ParseErrorItem { + message: string; + line?: number; + column?: number; +} + +export type GenerateResult = + | { ok: true; code: string } + | { ok: false; errors: ParseErrorItem[] }; + +export type ParseResult = + | { ok: true; workflow: WireWorkflow; workflowName: string } + | { ok: false; stage: "parse" | "validate"; errors: ParseErrorItem[] }; diff --git a/api/mcp_server/ts_validator/tsconfig.json b/api/mcp_server/ts_validator/tsconfig.json new file mode 100644 index 0000000..9e944bd --- /dev/null +++ b/api/mcp_server/ts_validator/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"] +} diff --git a/api/pytest.ini b/api/pytest.ini index 1dfb323..c60607b 100644 --- a/api/pytest.ini +++ b/api/pytest.ini @@ -6,7 +6,7 @@ testpaths = tests python_files = test_*.py *_test.py python_classes = Test* python_functions = test_* -addopts = -v --tb=short -s +addopts = -v --tb=short -s --import-mode=importlib markers = asyncio: mark test as an async test slow: mark test as slow running \ No newline at end of file diff --git a/api/requirements.dev.txt b/api/requirements.dev.txt index 216f7b6..9758ee4 100644 --- a/api/requirements.dev.txt +++ b/api/requirements.dev.txt @@ -4,4 +4,6 @@ pytest==8.3.5 pytest-asyncio==0.26.0 pre-commit==4.2.0 watchfiles==1.1.0 -python-dotenv==1.2.1 \ No newline at end of file +python-dotenv==1.2.1 +datamodel-code-generator==0.56.1 +-e ./sdk/python diff --git a/api/requirements.txt b/api/requirements.txt index 525b34f..22a2f0b 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -13,10 +13,8 @@ python-multipart==0.0.20 sentry-sdk[fastapi]==2.38.0 sqlalchemy[asyncio]==2.0.43 msgpack==1.1.2 -docling[rapidocr]==2.68.0 pgvector==0.4.2 bcrypt==5.0.0 email-validator==2.3.0 posthog==7.11.1 fastmcp==3.2.4 -rank-bm25==0.2.2 diff --git a/api/routes/credentials.py b/api/routes/credentials.py index 15fa95b..618c973 100644 --- a/api/routes/credentials.py +++ b/api/routes/credentials.py @@ -9,6 +9,7 @@ from pydantic import BaseModel from api.db import db_client from api.db.models import UserModel from api.enums import WebhookCredentialType +from api.sdk_expose import sdk_expose from api.services.auth.depends import get_user router = APIRouter(prefix="/credentials") @@ -107,7 +108,13 @@ def build_credential_response(credential) -> CredentialResponse: ) -@router.get("/") +@router.get( + "/", + **sdk_expose( + method="list_credentials", + description="List webhook credentials available to the authenticated organization.", + ), +) async def list_credentials( user: UserModel = Depends(get_user), ) -> List[CredentialResponse]: diff --git a/api/routes/knowledge_base.py b/api/routes/knowledge_base.py index e05c4cd..95f64b8 100644 --- a/api/routes/knowledge_base.py +++ b/api/routes/knowledge_base.py @@ -17,6 +17,7 @@ from api.schemas.knowledge_base import ( DocumentUploadResponseSchema, ProcessDocumentRequestSchema, ) +from api.sdk_expose import sdk_expose from api.services.auth.depends import get_user from api.services.posthog_client import capture_event from api.services.storage import storage_fs @@ -135,6 +136,7 @@ async def process_document( document.id, request.s3_key, user.selected_organization_id, + str(user.provider_id), 128, # max_tokens (default) request.retrieval_mode, ) @@ -190,6 +192,10 @@ async def process_document( "/documents", response_model=DocumentListResponseSchema, summary="List documents", + **sdk_expose( + method="list_documents", + description="List knowledge base documents available to the authenticated organization.", + ), ) async def list_documents( status: Annotated[ diff --git a/api/routes/main.py b/api/routes/main.py index 94b188c..f57a41d 100644 --- a/api/routes/main.py +++ b/api/routes/main.py @@ -8,6 +8,7 @@ from api.routes.credentials import router as credentials_router from api.routes.integration import router as integration_router from api.routes.knowledge_base import router as knowledge_base_router from api.routes.looptalk import router as looptalk_router +from api.routes.node_types import router as node_types_router from api.routes.organization import router as organization_router from api.routes.organization_usage import router as organization_usage_router from api.routes.public_agent import router as public_agent_router @@ -54,6 +55,7 @@ router.include_router(workflow_embed_router) router.include_router(knowledge_base_router) router.include_router(workflow_recording_router) router.include_router(auth_router) +router.include_router(node_types_router) class HealthResponse(BaseModel): diff --git a/api/routes/node_types.py b/api/routes/node_types.py new file mode 100644 index 0000000..f69005d --- /dev/null +++ b/api/routes/node_types.py @@ -0,0 +1,67 @@ +"""API for the node-spec catalog. + +Exposes the registered NodeSpecs (one per node type) so frontend renderers +and the LLM SDK can build forms / typed constructors from a single source +of truth. + +Endpoints: + GET /node-types → list every registered NodeSpec + GET /node-types/{name} → single NodeSpec by name +""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from api.db.models import UserModel +from api.sdk_expose import sdk_expose +from api.services.auth.depends import get_user +from api.services.workflow.node_specs import ( + SPEC_VERSION, + NodeSpec, + all_specs, + get_spec, +) + +router = APIRouter(prefix="/node-types") + + +class NodeTypesResponse(BaseModel): + spec_version: str + node_types: list[NodeSpec] + + +@router.get( + "", + response_model=NodeTypesResponse, + **sdk_expose( + method="list_node_types", + description="List every registered node type with its spec. Pinned to spec_version.", + ), +) +async def list_node_types( + _user: UserModel = Depends(get_user), +) -> NodeTypesResponse: + """List every registered NodeSpec. + + SDK clients should pin to `spec_version` and warn if the server reports + a higher version than what they were generated against. + """ + return NodeTypesResponse(spec_version=SPEC_VERSION, node_types=all_specs()) + + +@router.get( + "/{name}", + response_model=NodeSpec, + **sdk_expose( + method="get_node_type", + description="Fetch a single node spec by name.", + ), +) +async def get_node_type( + name: str, + _user: UserModel = Depends(get_user), +) -> NodeSpec: + spec = get_spec(name) + if spec is None: + raise HTTPException(status_code=404, detail=f"Unknown node type: {name!r}") + return spec diff --git a/api/routes/telephony.py b/api/routes/telephony.py index 96a592d..645c6a5 100644 --- a/api/routes/telephony.py +++ b/api/routes/telephony.py @@ -29,6 +29,7 @@ from api.db.workflow_client import WorkflowClient from api.db.workflow_run_client import WorkflowRunClient from api.enums import CallType, OrganizationConfigurationKey, WorkflowRunState from api.errors.telephony_errors import TelephonyError +from api.sdk_expose import sdk_expose from api.services.auth.depends import get_user from api.services.campaign.campaign_call_dispatcher import campaign_call_dispatcher from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher @@ -139,7 +140,13 @@ class StatusCallbackRequest(BaseModel): ) -@router.post("/initiate-call") +@router.post( + "/initiate-call", + **sdk_expose( + method="test_phone_call", + description="Place a test call from a workflow to a phone number.", + ), +) async def initiate_call( request: InitiateCallRequest, user: UserModel = Depends(get_user) ): diff --git a/api/routes/tool.py b/api/routes/tool.py index 202772f..80fb45d 100644 --- a/api/routes/tool.py +++ b/api/routes/tool.py @@ -10,6 +10,7 @@ from pydantic import BaseModel, Field, field_validator from api.db import db_client from api.db.models import UserModel from api.enums import PostHogEvent, ToolCategory, ToolStatus +from api.sdk_expose import sdk_expose from api.services.auth.depends import get_user from api.services.posthog_client import capture_event @@ -276,7 +277,13 @@ def validate_status(status: str) -> None: ) -@router.get("/") +@router.get( + "/", + **sdk_expose( + method="list_tools", + description="List tools available to the authenticated organization.", + ), +) async def list_tools( status: Optional[str] = None, category: Optional[str] = None, diff --git a/api/routes/workflow.py b/api/routes/workflow.py index e4d3de8..f5dbdf2 100644 --- a/api/routes/workflow.py +++ b/api/routes/workflow.py @@ -16,6 +16,7 @@ from api.db.models import UserModel from api.db.workflow_template_client import WorkflowTemplateClient from api.enums import CallType, PostHogEvent, StorageBackend from api.schemas.workflow import WorkflowRunResponseSchema +from api.sdk_expose import sdk_expose from api.services.auth.depends import get_user from api.services.campaign.report import generate_workflow_report_csv from api.services.configuration.check_validity import UserConfigurationValidator @@ -27,7 +28,7 @@ from api.services.configuration.resolve import resolve_effective_config from api.services.mps_service_key_client import mps_service_key_client from api.services.posthog_client import capture_event from api.services.storage import storage_fs -from api.services.workflow.dto import ReactFlowDTO +from api.services.workflow.dto import ReactFlowDTO, sanitize_workflow_definition from api.services.workflow.duplicate import duplicate_workflow from api.services.workflow.errors import ItemKind, WorkflowError from api.services.workflow.workflow import WorkflowGraph @@ -453,7 +454,13 @@ async def get_workflow_count( ) -@router.get("/fetch") +@router.get( + "/fetch", + **sdk_expose( + method="list_workflows", + description="List all workflows in the authenticated organization.", + ), +) async def get_workflows( user: UserModel = Depends(get_user), status: Optional[str] = Query( @@ -499,7 +506,13 @@ async def get_workflows( ] -@router.get("/fetch/{workflow_id}") +@router.get( + "/fetch/{workflow_id}", + **sdk_expose( + method="get_workflow", + description="Get a single workflow by ID (returns draft if one exists, else published).", + ), +) async def get_workflow( workflow_id: int, user: UserModel = Depends(get_user), @@ -701,7 +714,13 @@ async def update_workflow_status( raise HTTPException(status_code=500, detail=str(e)) -@router.put("/{workflow_id}") +@router.put( + "/{workflow_id}", + **sdk_expose( + method="update_workflow", + description="Update a workflow's name and/or definition. Saves as a new draft.", + ), +) async def update_workflow( workflow_id: int, request: UpdateWorkflowRequest, @@ -721,8 +740,10 @@ async def update_workflow( HTTPException: If the workflow is not found or if there's a database error """ try: - # Restore real API keys where the incoming definition has masked placeholders - workflow_definition = request.workflow_definition + # Strip UI runtime-only fields (invalid, validationMessage, etc.) from + # node.data / edge.data before anything touches the DB — the UI sends + # nodes wholesale from the React Flow store, which carries those. + workflow_definition = sanitize_workflow_definition(request.workflow_definition) if workflow_definition: existing_workflow = await db_client.get_workflow( workflow_id, organization_id=user.selected_organization_id diff --git a/api/routes/workflow_recording.py b/api/routes/workflow_recording.py index 9f30997..6c59129 100644 --- a/api/routes/workflow_recording.py +++ b/api/routes/workflow_recording.py @@ -19,6 +19,7 @@ from api.schemas.workflow_recording import ( RecordingUpdateRequestSchema, RecordingUploadResponseSchema, ) +from api.sdk_expose import sdk_expose from api.services.auth.depends import get_user from api.services.mps_service_key_client import mps_service_key_client from api.services.storage import storage_fs @@ -165,6 +166,10 @@ async def create_recordings( "/", response_model=RecordingListResponseSchema, summary="List recordings", + **sdk_expose( + method="list_recordings", + description="List workflow recordings available to the authenticated organization.", + ), ) async def list_recordings( workflow_id: Annotated[ diff --git a/api/sdk_expose.py b/api/sdk_expose.py new file mode 100644 index 0000000..cd35c11 --- /dev/null +++ b/api/sdk_expose.py @@ -0,0 +1,38 @@ +"""Opt-in marker for exposing a FastAPI route through the Dograh SDK. + +The generated SDK client (`sdk/python/src/dograh_sdk/_generated_client.py` +and the TypeScript equivalent) is built by walking the backend's OpenAPI +schema and picking up any operation tagged with `x-sdk-method`. That +means `generate_sdk.sh` stays in sync with the real HTTP paths — no more +hand-typed URL strings drifting out of date. + +Usage: + + from api.sdk_expose import sdk_expose + + @router.post("/initiate-call", **sdk_expose( + method="test_phone_call", + description="Place a test call from a workflow to a phone number.", + )) + async def initiate_call(...): ... + +Anything not wrapped in `sdk_expose` is invisible to the SDK — deliberate, +so the SDK surface stays small and auditable. +""" + +from __future__ import annotations + +from typing import Any + + +def sdk_expose(*, method: str, description: str = "") -> dict[str, Any]: + """Return FastAPI route kwargs that tag the operation for SDK codegen. + + `method` becomes the SDK method name in both Python and TypeScript + (converted to snake_case / camelCase as appropriate by the codegen). + `description` is emitted as the method docstring. + """ + extra: dict[str, Any] = {"x-sdk-method": method} + if description: + extra["x-sdk-description"] = description + return {"openapi_extra": extra} diff --git a/api/services/gen_ai/embedding/openai_service.py b/api/services/gen_ai/embedding/openai_service.py index 89a58a3..2b54644 100644 --- a/api/services/gen_ai/embedding/openai_service.py +++ b/api/services/gen_ai/embedding/openai_service.py @@ -1,35 +1,22 @@ """OpenAI embedding service. -This module provides document processing capabilities using: -- OpenAI's text-embedding-3-small for embeddings (1536 dimensions) -- Docling for document conversion and chunking -- pgvector for vector similarity search +Embeds text and performs vector similarity search via the local database. +Document conversion and chunking now live in the Model Proxy Service (MPS); +this file no longer pulls docling/transformers. """ -import os -from pathlib import Path from typing import Any, Dict, List, Optional -from docling.chunking import HybridChunker -from docling.document_converter import DocumentConverter -from docling_core.transforms.chunker.tokenizer.huggingface import HuggingFaceTokenizer from loguru import logger from openai import AsyncOpenAI -from transformers import AutoTokenizer from api.db.db_client import DBClient -from api.db.models import KnowledgeBaseChunkModel from .base import BaseEmbeddingService -# Model configuration DEFAULT_MODEL_ID = "text-embedding-3-small" EMBEDDING_DIMENSION = 1536 # Dimension for text-embedding-3-small -# For chunking, we'll use the same tokenizer as SentenceTransformer -# since OpenAI uses similar tokenization -TOKENIZER_MODEL = "sentence-transformers/all-MiniLM-L6-v2" - class EmbeddingAPIKeyNotConfiguredError(Exception): """Raised when OpenAI API key is not configured for embeddings.""" @@ -49,24 +36,20 @@ class OpenAIEmbeddingService(BaseEmbeddingService): db_client: DBClient, api_key: Optional[str] = None, model_id: str = DEFAULT_MODEL_ID, - max_tokens: int = 512, base_url: Optional[str] = None, ): """Initialize the OpenAI embedding service. Args: - db_client: Database client for storing documents and chunks + db_client: Database client for vector similarity search. api_key: OpenAI API key. If not provided, the client will not be - initialized and operations will fail with a clear error. - model_id: OpenAI embedding model ID (default: text-embedding-3-small) - max_tokens: Maximum number of tokens per chunk (default: 512) - base_url: Optional base URL for the API (e.g. for OpenRouter) + initialized and operations will fail with a clear error. + model_id: OpenAI embedding model ID (default: text-embedding-3-small). + base_url: Optional base URL for the API (e.g. for OpenRouter). """ self.db = db_client self.model_id = model_id - self.max_tokens = max_tokens - # Only initialize OpenAI client if API key is provided self._api_key_configured = bool(api_key) if self._api_key_configured: client_kwargs = {"api_key": api_key} @@ -81,35 +64,6 @@ class OpenAIEmbeddingService(BaseEmbeddingService): "Operations will fail until API key is configured in Model Configurations." ) - # Initialize tokenizer for chunking - # We use a HuggingFace tokenizer for consistent chunking - logger.info( - f"Loading tokenizer for chunking: {TOKENIZER_MODEL} with max_tokens={max_tokens}" - ) - try: - self.tokenizer = HuggingFaceTokenizer( - tokenizer=AutoTokenizer.from_pretrained( - TOKENIZER_MODEL, - local_files_only=True, - ), - max_tokens=max_tokens, - ) - logger.info("Loaded tokenizer from cache") - except Exception as e: - logger.warning(f"Tokenizer not in cache, downloading: {e}") - self.tokenizer = HuggingFaceTokenizer( - tokenizer=AutoTokenizer.from_pretrained(TOKENIZER_MODEL), - max_tokens=max_tokens, - ) - logger.info("Tokenizer downloaded and cached") - - # Initialize chunker - logger.info(f"Initializing HybridChunker with max_tokens={max_tokens}") - self.chunker = HybridChunker(tokenizer=self.tokenizer) - - # Initialize document converter - self.converter = DocumentConverter() - def get_model_id(self) -> str: """Return the model identifier.""" return self.model_id @@ -126,28 +80,17 @@ class OpenAIEmbeddingService(BaseEmbeddingService): async def embed_texts(self, texts: List[str]) -> List[List[float]]: """Embed a batch of texts using OpenAI API. - Args: - texts: List of text strings to embed - - Returns: - List of embedding vectors (each vector is a list of floats) - Raises: - EmbeddingAPIKeyNotConfiguredError: If API key is not configured + EmbeddingAPIKeyNotConfiguredError: If API key is not configured. """ self._ensure_api_key_configured() try: - # OpenAI API call response = await self.client.embeddings.create( input=texts, model=self.model_id, ) - - # Extract embeddings from response - embeddings = [item.embedding for item in response.data] - return embeddings - + return [item.embedding for item in response.data] except Exception as e: logger.error(f"Error generating OpenAI embeddings: {e}") raise @@ -155,14 +98,8 @@ class OpenAIEmbeddingService(BaseEmbeddingService): async def embed_query(self, query: str) -> List[float]: """Embed a single query text using OpenAI API. - Args: - query: Query text to embed - - Returns: - Embedding vector as list of floats - Raises: - EmbeddingAPIKeyNotConfiguredError: If API key is not configured + EmbeddingAPIKeyNotConfiguredError: If API key is not configured. """ self._ensure_api_key_configured() embeddings = await self.embed_texts([query]) @@ -177,201 +114,17 @@ class OpenAIEmbeddingService(BaseEmbeddingService): ) -> List[Dict[str, Any]]: """Search for similar chunks using vector similarity. - Args: - query: Search query text - organization_id: Organization ID for scoping - limit: Maximum number of results to return - document_uuids: Optional list of document UUIDs to filter by - - Returns: - List of dictionaries with chunk data and similarity scores - Raises: - EmbeddingAPIKeyNotConfiguredError: If API key is not configured + EmbeddingAPIKeyNotConfiguredError: If API key is not configured. """ self._ensure_api_key_configured() - # Generate query embedding query_embedding = await self.embed_query(query) - # Perform vector similarity search - results = await self.db.search_similar_chunks( + return await self.db.search_similar_chunks( query_embedding=query_embedding, organization_id=organization_id, limit=limit, document_uuids=document_uuids, embedding_model=self.model_id, ) - - return results - - async def process_document( - self, - file_path: str, - organization_id: int, - created_by: int, - custom_metadata: dict = None, - ): - """Process a document: convert, chunk, embed, and store in database. - - Args: - file_path: Path to the document file - organization_id: Organization ID for scoping - created_by: User ID who uploaded the document - custom_metadata: Optional custom metadata dictionary - - Returns: - The created document record - """ - try: - # Extract file metadata - filename = Path(file_path).name - file_hash = self.db.compute_file_hash(file_path) - file_size = os.path.getsize(file_path) - mime_type = self.db.get_mime_type(file_path) - - # Check if document already exists - existing_doc = await self.db.get_document_by_hash( - file_hash, organization_id - ) - if existing_doc: - logger.info(f"Document already exists: {filename} (hash: {file_hash})") - return existing_doc - - # Create document record - doc_record = await self.db.create_document( - organization_id=organization_id, - created_by=created_by, - filename=filename, - file_size_bytes=file_size, - file_hash=file_hash, - mime_type=mime_type, - custom_metadata=custom_metadata or {}, - ) - - logger.info(f"Processing document with OpenAI embeddings: {filename}") - - # Update status to processing - await self.db.update_document_status(doc_record.id, "processing") - - # Step 1: Convert document using docling - logger.info("Converting document with docling...") - conversion_result = self.converter.convert(file_path) - doc = conversion_result.document - - # Store docling metadata - docling_metadata = { - "num_pages": len(doc.pages) if hasattr(doc, "pages") else None, - "document_type": type(doc).__name__, - } - - # Step 2: Chunk the document - logger.info(f"Chunking document with max_tokens={self.max_tokens}...") - chunks = list(self.chunker.chunk(dl_doc=doc)) - total_chunks = len(chunks) - - logger.info(f"Generated {total_chunks} chunks") - - # Step 3: Process each chunk - chunk_texts = [] - chunk_records = [] - token_counts = [] - - for i, chunk in enumerate(chunks): - # Get chunk text - chunk_text = chunk.text - - # Get contextualized text - contextualized_text = self.chunker.contextualize(chunk=chunk) - - # Calculate token count - text_to_tokenize = ( - contextualized_text if contextualized_text else chunk_text - ) - token_count = len( - self.tokenizer.tokenizer.encode( - text_to_tokenize, add_special_tokens=False - ) - ) - token_counts.append(token_count) - - # Prepare chunk metadata - chunk_metadata = {} - if hasattr(chunk, "meta") and chunk.meta: - chunk_metadata = { - "doc_items": ( - [str(item) for item in chunk.meta.doc_items] - if hasattr(chunk.meta, "doc_items") - else [] - ), - "headings": ( - chunk.meta.headings - if hasattr(chunk.meta, "headings") - else [] - ), - } - - # Create chunk record (without embedding yet) - chunk_record = KnowledgeBaseChunkModel( - document_id=doc_record.id, - organization_id=organization_id, - chunk_text=chunk_text, - contextualized_text=contextualized_text, - chunk_index=i, - chunk_metadata=chunk_metadata, - embedding_model=self.model_id, - embedding_dimension=EMBEDDING_DIMENSION, - token_count=token_count, - ) - - chunk_records.append(chunk_record) - chunk_texts.append(text_to_tokenize) - - # Log chunk statistics - if token_counts: - avg_tokens = sum(token_counts) / len(token_counts) - min_tokens = min(token_counts) - max_tokens = max(token_counts) - logger.info("Chunk token statistics:") - logger.info(f" - Average: {avg_tokens:.1f} tokens") - logger.info(f" - Min: {min_tokens} tokens") - logger.info(f" - Max: {max_tokens} tokens") - - # Step 4: Generate embeddings using OpenAI API - logger.info(f"Generating embeddings using OpenAI ({self.model_id})...") - embeddings = await self.embed_texts(chunk_texts) - - # Step 5: Attach embeddings to chunk records - for chunk_record, embedding in zip(chunk_records, embeddings): - chunk_record.embedding = embedding - - # Step 6: Save all chunks in batch - logger.info("Storing chunks in database...") - await self.db.create_chunks_batch(chunk_records) - - # Update document status to completed - await self.db.update_document_status( - doc_record.id, - "completed", - total_chunks=total_chunks, - docling_metadata=docling_metadata, - ) - - logger.info(f"Successfully processed document: {filename}") - logger.info(f" - Total chunks: {total_chunks}") - logger.info(f" - Embedding model: {self.model_id}") - logger.info(f" - Document ID: {doc_record.id}") - logger.info(f" - Document UUID: {doc_record.document_uuid}") - - return doc_record - - except Exception as e: - logger.error(f"Error processing document with OpenAI: {e}") - - # Update document status to failed if it exists - if "doc_record" in locals(): - await self.db.update_document_status( - doc_record.id, "failed", error_message=str(e) - ) - - raise diff --git a/api/services/mps_service_key_client.py b/api/services/mps_service_key_client.py index 9648428..6c478d1 100644 --- a/api/services/mps_service_key_client.py +++ b/api/services/mps_service_key_client.py @@ -487,6 +487,71 @@ class MPSServiceKeyClient: response=response, ) + async def process_document( + self, + file_path: str, + filename: str, + content_type: str, + retrieval_mode: str = "chunked", + max_tokens: int = 128, + chunk_overlap_tokens: int = 0, + merge_peers: bool = True, + tokenizer_model: Optional[str] = None, + correlation_id: Optional[str] = None, + organization_id: Optional[int] = None, + created_by: Optional[str] = None, + ) -> dict: + """Convert + chunk a document via MPS /document/process. + + Returns a dict matching DocumentProcessResponse in MPS: + { + "mode": "chunked" | "full_document", + "docling_metadata": {...}, + "full_text": str | None, # populated only in full_document mode + "chunks": [...], # populated only in chunked mode + } + + Timeout is 300s to match the ALB idle_timeout configured in + infrastructure/mps/main.tf. Raises on non-2xx responses. + """ + data = { + "retrieval_mode": retrieval_mode, + "max_tokens": str(max_tokens), + "chunk_overlap_tokens": str(chunk_overlap_tokens), + "merge_peers": str(merge_peers).lower(), + } + if tokenizer_model is not None: + data["tokenizer_model"] = tokenizer_model + if correlation_id: + data["correlation_id"] = correlation_id + + headers = self._get_headers(organization_id, created_by) + # Remove JSON content-type so httpx sets the correct multipart boundary. + headers.pop("Content-Type", None) + + async with httpx.AsyncClient(timeout=httpx.Timeout(300.0)) as client: + with open(file_path, "rb") as fh: + files = {"file": (filename, fh.read(), content_type)} + + response = await client.post( + f"{self.base_url}/api/v1/document/process", + files=files, + data=data, + headers=headers, + ) + + if response.status_code == 200: + return response.json() + + logger.error( + f"Failed to process document: {response.status_code} - {response.text}" + ) + raise httpx.HTTPStatusError( + f"Failed to process document: {response.text}", + request=response.request, + response=response, + ) + async def call_workflow_api( self, call_type: str, diff --git a/api/services/pipecat/audio_playback.py b/api/services/pipecat/audio_playback.py index f0b058e..ab0e504 100644 --- a/api/services/pipecat/audio_playback.py +++ b/api/services/pipecat/audio_playback.py @@ -97,6 +97,7 @@ async def play_audio( queue_frame: Callable[[Frame], Awaitable[None]], transcript: Optional[str] = None, append_to_context: bool = False, + persist_to_logs: bool = False, ) -> None: """Play raw PCM-16 audio once. @@ -115,6 +116,8 @@ async def play_audio( transcript: Optional transcript of the recording. append_to_context: Whether the transcript should be appended to the LLM assistant context. Defaults to False. + persist_to_logs: Whether the transcript should be written to the + app-level logs buffer by observers. Defaults to False. """ context_id = str(uuid.uuid4()) await queue_frame(TTSStartedFrame(context_id=context_id)) @@ -123,6 +126,7 @@ async def play_audio( text=transcript, aggregated_by="recording", context_id=context_id ) tts_text.append_to_context = append_to_context + tts_text.persist_to_logs = persist_to_logs await queue_frame(tts_text) await queue_frame( TTSAudioRawFrame( diff --git a/api/services/pipecat/realtime_feedback_observer.py b/api/services/pipecat/realtime_feedback_observer.py index 35b393d..68b1b88 100644 --- a/api/services/pipecat/realtime_feedback_observer.py +++ b/api/services/pipecat/realtime_feedback_observer.py @@ -42,6 +42,7 @@ from pipecat.frames.frames import ( MetricsFrame, StopFrame, TranscriptionFrame, + TTSSpeakFrame, TTSTextFrame, UserMuteStartedFrame, UserMuteStoppedFrame, @@ -230,8 +231,22 @@ class RealtimeFeedbackObserver(BaseObserver): }, } ) + # Handle engine-queued speech (transition/tool messages) marked for + # log persistence. The downstream TTSTextFrame(s) from the TTS service + # still stream to WS as normal; we persist the full utterance once here + # to avoid word-level log entries from word-timestamp providers. + elif isinstance(frame, TTSSpeakFrame): + if getattr(frame, "persist_to_logs", False): + await self._append_to_buffer( + { + "type": RealtimeFeedbackType.BOT_TEXT.value, + "payload": {"text": frame.text}, + } + ) # Handle bot TTS text - respect pts timing, WebSocket only - # Complete turn text is persisted via register_turn_handlers + # Complete turn text is persisted via register_turn_handlers, + # except for frames explicitly flagged persist_to_logs (e.g. recording + # transcripts from play_audio) which bypass the aggregator path. elif isinstance(frame, TTSTextFrame): message = { "type": RealtimeFeedbackType.BOT_TEXT.value, @@ -249,6 +264,9 @@ class RealtimeFeedbackObserver(BaseObserver): await self._ensure_clock_task() await self._clock_queue.put((frame.pts, frame.id, message)) + elif getattr(frame, "persist_to_logs", False): + # No pts + explicit persistence request (recording transcript). + await self._send_message(message) else: # No pts, send immediately await self._send_ws(message) diff --git a/api/services/pipecat/tracing_config.py b/api/services/pipecat/tracing_config.py index e2db809..20b6a7a 100644 --- a/api/services/pipecat/tracing_config.py +++ b/api/services/pipecat/tracing_config.py @@ -94,6 +94,14 @@ class _OrgRoutingExporter(SpanExporter): org_buckets = {} for span in spans: + # Drop fastmcp's built-in auto-instrumentation spans + # (`tools/call `, etc.) — our `@traced_tool` decorator + # in `api/mcp_server/tracing.py` produces the spans we want. Keeping + # both would just double every trace. + scope = getattr(span, "instrumentation_scope", None) + if scope is not None and scope.name == "fastmcp": + continue + org_id = span.attributes.get("dograh.org_id") if span.attributes else None if org_id and str(org_id) in self._org_exporters: org_buckets.setdefault(str(org_id), []).append(span) diff --git a/api/services/workflow/dto.py b/api/services/workflow/dto.py index 1fc3258..563013b 100644 --- a/api/services/workflow/dto.py +++ b/api/services/workflow/dto.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import List, Optional +from typing import Annotated, List, Literal, Optional, Union from pydantic import BaseModel, Field, ValidationError, model_validator @@ -42,17 +42,48 @@ class RetryConfigDTO(BaseModel): retry_delay_seconds: int = 5 -class NodeDataDTO(BaseModel): +# ───────────────────────────────────────────────────────────────────────── +# Per-type node data classes. +# +# Shared fields are factored out as Pydantic mixins; per-type classes +# inherit only the mixins they need so mistyped fields raise at validation +# time and downstream consumers get accurate types. `is_start` / `is_end` +# live on every variant so the WorkflowGraph can identify boundary nodes +# without dispatching on type. +# ───────────────────────────────────────────────────────────────────────── + + +class _NodeDataBase(BaseModel): name: str = Field(..., min_length=1) - prompt: Optional[str] = Field(default=None) - is_static: bool = False is_start: bool = False is_end: bool = False + + +class _PromptedNodeDataMixin(BaseModel): + prompt: Optional[str] = Field(default=None) + is_static: bool = False allow_interrupt: bool = False + add_global_prompt: bool = True + + +class _ExtractionNodeDataMixin(BaseModel): extraction_enabled: bool = False extraction_prompt: Optional[str] = None extraction_variables: Optional[list[ExtractionVariableDTO]] = None - add_global_prompt: bool = True + + +class _ToolDocumentRefsMixin(BaseModel): + tool_uuids: Optional[List[str]] = None + document_uuids: Optional[List[str]] = None + + +class StartCallNodeData( + _NodeDataBase, + _PromptedNodeDataMixin, + _ExtractionNodeDataMixin, + _ToolDocumentRefsMixin, +): + is_start: bool = True greeting: Optional[str] = None greeting_type: Optional[str] = None # 'text' or 'audio' greeting_recording_id: Optional[str] = None @@ -61,14 +92,38 @@ class NodeDataDTO(BaseModel): detect_voicemail: bool = False delayed_start: bool = False delayed_start_duration: Optional[float] = None - # Pre-call fetch (start node only) pre_call_fetch_enabled: bool = False pre_call_fetch_url: Optional[str] = None pre_call_fetch_credential_uuid: Optional[str] = None - tool_uuids: Optional[List[str]] = None - document_uuids: Optional[List[str]] = None + + +class AgentNodeData( + _NodeDataBase, + _PromptedNodeDataMixin, + _ExtractionNodeDataMixin, + _ToolDocumentRefsMixin, +): + pass + + +class EndCallNodeData( + _NodeDataBase, + _PromptedNodeDataMixin, + _ExtractionNodeDataMixin, +): + is_end: bool = True + + +class GlobalNodeData(_NodeDataBase, _PromptedNodeDataMixin): + pass + + +class TriggerNodeData(_NodeDataBase): trigger_path: Optional[str] = None - # Webhook node specific fields + enabled: bool = True + + +class WebhookNodeData(_NodeDataBase): enabled: bool = True http_method: Optional[str] = None endpoint_url: Optional[str] = None @@ -76,30 +131,129 @@ class NodeDataDTO(BaseModel): custom_headers: Optional[list[CustomHeaderDTO]] = None payload_template: Optional[dict] = None retry_config: Optional[RetryConfigDTO] = None - # QA node specific fields + + +class QANodeData(_NodeDataBase): qa_enabled: bool = True - qa_system_prompt: Optional[str] = None + qa_use_workflow_llm: bool = True + qa_provider: Optional[str] = None qa_model: Optional[str] = None + qa_api_key: Optional[str] = None + qa_endpoint: Optional[str] = None + qa_system_prompt: Optional[str] = None qa_min_call_duration: int = 15 qa_voicemail_calls: bool = False qa_sample_rate: int = 100 -class RFNodeDTO(BaseModel): +# Union of every per-type data class — useful as a type annotation on +# consumers that handle any node data without dispatching on type. Cannot +# be called as a constructor; use the per-type class directly. +NodeDataDTO = Union[ + StartCallNodeData, + AgentNodeData, + EndCallNodeData, + GlobalNodeData, + TriggerNodeData, + WebhookNodeData, + QANodeData, +] + + +# ───────────────────────────────────────────────────────────────────────── +# Per-type RF nodes. +# +# RFNodeDTO is a discriminated Union over `type`. Pydantic dispatches to +# the right variant when validating wire JSON. Direct instantiation must +# use the concrete per-type class (StartCallRFNode, AgentRFNode, ...). +# ───────────────────────────────────────────────────────────────────────── + + +class _RFNodeBase(BaseModel): id: str - type: NodeType = Field(default=NodeType.agentNode) position: Position - data: NodeDataDTO + + +def _require_prompt(data, type_label: str) -> None: + prompt = getattr(data, "prompt", None) + if not prompt or len(prompt.strip()) == 0: + raise ValueError(f"Prompt is required for {type_label} nodes") + + +class StartCallRFNode(_RFNodeBase): + type: Literal["startCall"] = "startCall" + data: StartCallNodeData @model_validator(mode="after") - def _validate_prompt_required(self): - """Require prompt for all node types except trigger, webhook, and qa.""" - if self.type not in (NodeType.trigger, NodeType.webhook, NodeType.qa): - if not self.data.prompt or len(self.data.prompt.strip()) == 0: - raise ValueError("Prompt is required for non-trigger nodes") + def _validate(self): + _require_prompt(self.data, "start") return self +class AgentRFNode(_RFNodeBase): + type: Literal["agentNode"] = "agentNode" + data: AgentNodeData + + @model_validator(mode="after") + def _validate(self): + _require_prompt(self.data, "agent") + return self + + +class EndCallRFNode(_RFNodeBase): + type: Literal["endCall"] = "endCall" + data: EndCallNodeData + + @model_validator(mode="after") + def _validate(self): + _require_prompt(self.data, "end") + return self + + +class GlobalRFNode(_RFNodeBase): + type: Literal["globalNode"] = "globalNode" + data: GlobalNodeData + + @model_validator(mode="after") + def _validate(self): + _require_prompt(self.data, "global") + return self + + +class TriggerRFNode(_RFNodeBase): + type: Literal["trigger"] = "trigger" + data: TriggerNodeData + + +class WebhookRFNode(_RFNodeBase): + type: Literal["webhook"] = "webhook" + data: WebhookNodeData + + +class QARFNode(_RFNodeBase): + type: Literal["qa"] = "qa" + data: QANodeData + + +RFNodeDTO = Annotated[ + Union[ + StartCallRFNode, + AgentRFNode, + EndCallRFNode, + GlobalRFNode, + TriggerRFNode, + WebhookRFNode, + QARFNode, + ], + Field(discriminator="type"), +] + + +# ───────────────────────────────────────────────────────────────────────── +# Edges +# ───────────────────────────────────────────────────────────────────────── + + class EdgeDataDTO(BaseModel): label: str = Field(..., min_length=1) condition: str = Field(..., min_length=1) @@ -144,3 +298,60 @@ class ReactFlowDTO(BaseModel): ) return self + + +# Node type → per-type data class. Keeps sanitize_workflow_definition in +# step with RFNodeDTO's discriminated union. +_NODE_DATA_CLASSES: dict[str, type[BaseModel]] = { + NodeType.startNode.value: StartCallNodeData, + NodeType.agentNode.value: AgentNodeData, + NodeType.endNode.value: EndCallNodeData, + NodeType.globalNode.value: GlobalNodeData, + NodeType.trigger.value: TriggerNodeData, + NodeType.webhook.value: WebhookNodeData, + NodeType.qa.value: QANodeData, +} + + +def sanitize_workflow_definition(definition: dict | None) -> dict | None: + """Strip unknown fields from each node.data and edge.data so UI-only + runtime state (`invalid`, `validationMessage`, etc.) doesn't leak into + persisted workflow JSON. + + Only `.data` is filtered — top-level keys on nodes/edges/definition + (viewport, ReactFlow-computed width/height, etc.) are preserved as-is. + This is a stripper, not a validator: it doesn't enforce required fields + or run model_validators, so partial drafts save cleanly. + """ + if not definition: + return definition + + out = dict(definition) + raw_nodes = out.get("nodes") + if isinstance(raw_nodes, list): + out["nodes"] = [_sanitize_node(n) for n in raw_nodes] + raw_edges = out.get("edges") + if isinstance(raw_edges, list): + out["edges"] = [_sanitize_edge(e) for e in raw_edges] + return out + + +def _sanitize_node(node): + if not isinstance(node, dict): + return node + data_cls = _NODE_DATA_CLASSES.get(node.get("type")) + raw_data = node.get("data") + if not data_cls or not isinstance(raw_data, dict): + return node + allowed = data_cls.model_fields.keys() + return {**node, "data": {k: v for k, v in raw_data.items() if k in allowed}} + + +def _sanitize_edge(edge): + if not isinstance(edge, dict): + return edge + raw_data = edge.get("data") + if not isinstance(raw_data, dict): + return edge + allowed = EdgeDataDTO.model_fields.keys() + return {**edge, "data": {k: v for k, v in raw_data.items() if k in allowed}} diff --git a/api/services/workflow/layout.py b/api/services/workflow/layout.py new file mode 100644 index 0000000..d1c6f84 --- /dev/null +++ b/api/services/workflow/layout.py @@ -0,0 +1,105 @@ +"""Position reconciliation for LLM-edited workflows. + +`save_workflow` re-parses LLM-authored TypeScript into workflow JSON, +but the parser deliberately ignores positions (LLMs place nodes +poorly, and the authoring surface stays tighter without coordinates). +This module fills them back in by matching the newly-parsed nodes +against the previously-stored workflow: + + 1. Named match: (type, data.name) — most reliable + 2. Unnamed match: (type, nth-occurrence-in-order) — best effort + 3. New nodes: placed adjacent to their first incoming neighbor + (src.x + 400, src.y + 200), or (0, 0) if orphan + +The UI has a proper dagre-based re-layout button +(`ui/src/app/workflow/[workflowId]/utils/layoutNodes.ts`) users can +invoke when they want a clean pass. This module only aims to avoid +all-nodes-at-origin after a save. +""" + +from __future__ import annotations + +from typing import Any + +_DEFAULT_POSITION: dict[str, float] = {"x": 0.0, "y": 0.0} +# Horizontal / vertical offset for newly-introduced nodes relative to +# their first incoming neighbor. Chosen to roughly match the UI layout's +# node spacing without overlapping the neighbor's card. +_NEW_NODE_DX: float = 400.0 +_NEW_NODE_DY: float = 200.0 + + +def reconcile_positions( + new_wf: dict[str, Any], + previous_wf: dict[str, Any] | None, +) -> dict[str, Any]: + """Return `new_wf` with positions filled from `previous_wf` where + node identity matches, and approximate positions for genuinely new + nodes. Mutates and returns the same dict (callers typically want + the mutation).""" + if not previous_wf: + _place_new_nodes(new_wf) + return new_wf + + prev_nodes = previous_wf.get("nodes") or [] + named_positions: dict[tuple[str, str], dict[str, float]] = {} + unnamed_positions: dict[str, list[dict[str, float]]] = {} + + for n in prev_nodes: + t = n.get("type") or "" + name = ((n.get("data") or {}).get("name") or "").strip() + pos = n.get("position") or dict(_DEFAULT_POSITION) + if name: + named_positions[(t, name)] = pos + else: + unnamed_positions.setdefault(t, []).append(pos) + + unnamed_cursor: dict[str, int] = {} + + for node in new_wf.get("nodes") or []: + t = node.get("type") or "" + name = ((node.get("data") or {}).get("name") or "").strip() + + pos: dict[str, float] | None = None + if name: + pos = named_positions.get((t, name)) + if pos is None: + idx = unnamed_cursor.get(t, 0) + positions = unnamed_positions.get(t, []) + if idx < len(positions): + pos = positions[idx] + unnamed_cursor[t] = idx + 1 + if pos is not None: + node["position"] = dict(pos) + + _place_new_nodes(new_wf) + return new_wf + + +def _place_new_nodes(wf: dict[str, Any]) -> None: + """For nodes still at (0, 0) — i.e. unmatched by any previous + node — pick a position adjacent to the first incoming neighbor. + Runs after named/unnamed matching so only genuinely-new nodes are + affected.""" + nodes = wf.get("nodes") or [] + if not nodes: + return + id_to_node = {n["id"]: n for n in nodes} + edges = wf.get("edges") or [] + + for node in nodes: + pos = node.get("position") or {} + if pos.get("x") or pos.get("y"): + continue # already has a non-origin position + src_id = next( + (e["source"] for e in edges if e.get("target") == node["id"]), + None, + ) + if src_id and src_id in id_to_node: + src_pos = id_to_node[src_id].get("position") or dict(_DEFAULT_POSITION) + node["position"] = { + "x": float(src_pos.get("x", 0.0)) + _NEW_NODE_DX, + "y": float(src_pos.get("y", 0.0)) + _NEW_NODE_DY, + } + # Leaves truly orphan new nodes at (0, 0). The UI's re-layout + # pass will pull them into the graph on next edit. diff --git a/api/services/workflow/node_specs/__init__.py b/api/services/workflow/node_specs/__init__.py new file mode 100644 index 0000000..93f6ab0 --- /dev/null +++ b/api/services/workflow/node_specs/__init__.py @@ -0,0 +1,82 @@ +"""Node specification registry. + +Adding a new node type: +1. Create a new module under this package, define a `SPEC: NodeSpec`. +2. Add it to the imports + REGISTRY below. +3. The Pydantic discriminated-union variant in dto.py must use the same + `name` value as `SPEC.name`. +""" + +from __future__ import annotations + +from api.services.workflow.node_specs._base import ( + SPEC_VERSION, + DisplayOptions, + GraphConstraints, + NodeCategory, + NodeExample, + NodeSpec, + PropertyOption, + PropertySpec, + PropertyType, + evaluate_display_options, +) + +REGISTRY: dict[str, NodeSpec] = {} + + +def register(spec: NodeSpec) -> NodeSpec: + """Register a NodeSpec in the global registry. Returns the spec for + chaining at module top-level: `SPEC = register(NodeSpec(...))`.""" + if spec.name in REGISTRY: + raise ValueError( + f"Duplicate NodeSpec registration for {spec.name!r}. " + f"Each node type must have exactly one spec." + ) + REGISTRY[spec.name] = spec + return spec + + +def get_spec(name: str) -> NodeSpec | None: + return REGISTRY.get(name) + + +def all_specs() -> list[NodeSpec]: + """All registered specs, sorted by name for stable output.""" + return [REGISTRY[name] for name in sorted(REGISTRY)] + + +__all__ = [ + "SPEC_VERSION", + "REGISTRY", + "DisplayOptions", + "GraphConstraints", + "NodeCategory", + "NodeExample", + "NodeSpec", + "PropertyOption", + "PropertySpec", + "PropertyType", + "all_specs", + "evaluate_display_options", + "get_spec", + "register", +] + + +# Side-effect imports — each module's `register(SPEC)` call populates REGISTRY. +# Keep at module bottom so the registry helpers are defined first. +from api.services.workflow.node_specs import ( # noqa: E402, F401 + agent, + end_call, + global_node, + qa, + start_call, + trigger, + webhook, +) + +# Wire up registrations from the SPEC constants in each module. +for _module in (start_call, agent, end_call, global_node, trigger, webhook, qa): + register(_module.SPEC) +del _module diff --git a/api/services/workflow/node_specs/__main__.py b/api/services/workflow/node_specs/__main__.py new file mode 100644 index 0000000..b9693e2 --- /dev/null +++ b/api/services/workflow/node_specs/__main__.py @@ -0,0 +1,28 @@ +"""Dump the registered NodeSpecs to stdout as JSON. + +Used by `scripts/generate_sdk.sh` to feed both SDK codegens without +requiring a running backend. Shape matches the `/api/v1/node-types` +HTTP response so either source is interchangeable. + + python -m api.services.workflow.node_specs > specs.json +""" + +from __future__ import annotations + +import json +import sys + +from api.services.workflow.node_specs import SPEC_VERSION, all_specs + + +def main() -> None: + payload = { + "spec_version": SPEC_VERSION, + "node_types": [s.model_dump(mode="json") for s in all_specs()], + } + json.dump(payload, sys.stdout, indent=2, ensure_ascii=False) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() diff --git a/api/services/workflow/node_specs/_base.py b/api/services/workflow/node_specs/_base.py new file mode 100644 index 0000000..cbf044f --- /dev/null +++ b/api/services/workflow/node_specs/_base.py @@ -0,0 +1,224 @@ +"""Spec schema for node definitions. + +A `NodeSpec` is the single source of truth for a node type. It drives: +- Pydantic validation (the per-type DTOs in dto.py mirror these property types) +- The generic UI renderer (frontend reads specs via /api/v1/node-types) +- The LLM SDK (constructors and JSON-Schema derived from these specs) + +Every property's `description` is LLM-readable copy — treat it as production +documentation, not internal notes. Spec lint enforces non-empty descriptions +and example coverage. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field + +# Spec contract version. Bump when adding new PropertyType values or making +# breaking changes to the NodeSpec wire shape. SDK clients warn on mismatch. +SPEC_VERSION = "1.0.0" + + +class PropertyType(str, Enum): + """Bounded vocabulary of property types the renderer dispatches on. + + Adding a value here requires a matching arm in the frontend + `` switch and (where relevant) the SDK codegen template. + """ + + string = "string" + number = "number" + boolean = "boolean" + options = "options" # single-select dropdown + multi_options = "multi_options" # multi-select + fixed_collection = "fixed_collection" # repeating rows of sub-properties + json = "json" # arbitrary JSON object editor + + # Domain-specific reference types — values are UUIDs/keys looked up against + # a reference catalog (list_tools, list_documents, list_recordings, + # list_credentials). + tool_refs = "tool_refs" + document_refs = "document_refs" + recording_ref = "recording_ref" + credential_ref = "credential_ref" + + # Domain-specific input widgets + mention_textarea = "mention_textarea" # textarea with {{var}} mentions + url = "url" + + +class NodeCategory(str, Enum): + """Drives grouping in the AddNodePanel UI.""" + + call_node = "call_node" + global_node = "global_node" + trigger = "trigger" + integration = "integration" + + +class DisplayOptions(BaseModel): + """Conditional visibility rules. + + `show` keys are AND-combined: this property is visible only when EVERY + referenced field's value matches one of the listed values. + + `hide` keys are OR-combined: this property is hidden when ANY referenced + field's value matches one of the listed values. + + Example: + DisplayOptions(show={"extraction_enabled": [True]}) + DisplayOptions(show={"greeting_type": ["audio"]}) + """ + + show: Optional[dict[str, list[Any]]] = None + hide: Optional[dict[str, list[Any]]] = None + + model_config = ConfigDict(extra="forbid") + + +def evaluate_display_options( + rules: Optional[DisplayOptions | dict[str, Any]], + values: dict[str, Any], +) -> bool: + """Reference implementation of the display_options visibility check. + + Mirrored 1:1 in the TypeScript renderer + (`ui/src/components/flow/renderer/displayOptions.ts`). The golden + fixtures in `display_options_fixtures.json` lock the two + implementations together — update both whenever the semantics change. + """ + if rules is None: + return True + + if isinstance(rules, DisplayOptions): + show = rules.show + hide = rules.hide + else: + show = rules.get("show") + hide = rules.get("hide") + + if show: + for field, allowed in show.items(): + if values.get(field) not in allowed: + return False + + if hide: + for field, hidden in hide.items(): + if values.get(field) in hidden: + return False + + return True + + +class PropertyOption(BaseModel): + """An option in an `options` or `multi_options` dropdown.""" + + value: str | int | bool | float + label: str + description: Optional[str] = None + + model_config = ConfigDict(extra="forbid") + + +class PropertySpec(BaseModel): + """Single field on a node. + + `description` is HUMAN-FACING — shown under the field in the edit + dialog. Keep it concise and explain what the field does. + + `llm_hint` is LLM-FACING — appears only in the `get_node_type` MCP + response and in SDK schema output. Use it for catalog tool references + (e.g., "Use `list_recordings`"), array shape, expected value idioms, + or anything that would be noise in the UI. Optional; omit when the + `description` already suffices for both audiences. + """ + + name: str + type: PropertyType + display_name: str + description: str = Field( + ..., + min_length=1, + description="Human-facing explanation shown in the UI.", + ) + llm_hint: Optional[str] = Field( + default=None, + description="LLM-only guidance; omitted from the UI.", + ) + default: Any = None + required: bool = False + placeholder: Optional[str] = None + + display_options: Optional[DisplayOptions] = None + + # For `options` / `multi_options` + options: Optional[list[PropertyOption]] = None + + # For `fixed_collection` — sub-properties of each row + properties: Optional[list["PropertySpec"]] = None + + # Validation hints. Enforced by Pydantic where possible. + min_value: Optional[float] = None + max_value: Optional[float] = None + min_length: Optional[int] = None + max_length: Optional[int] = None + pattern: Optional[str] = None + + # Renderer hint, e.g. "textarea" vs single-line for `string`. + editor: Optional[str] = None + + # Free-form metadata for renderer-specific behavior. Use sparingly. + extra: dict[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict(extra="forbid") + + +PropertySpec.model_rebuild() + + +class NodeExample(BaseModel): + """A worked example LLMs can pattern-match. Keep small and realistic.""" + + name: str + description: Optional[str] = None + data: dict[str, Any] + + model_config = ConfigDict(extra="forbid") + + +class GraphConstraints(BaseModel): + """Per-node-type graph rules. WorkflowGraph enforces these at validation.""" + + min_incoming: Optional[int] = None + max_incoming: Optional[int] = None + min_outgoing: Optional[int] = None + max_outgoing: Optional[int] = None + + model_config = ConfigDict(extra="forbid") + + +class NodeSpec(BaseModel): + """Single source of truth for a node type.""" + + name: str # machine name; matches the Pydantic discriminator value + display_name: str + description: str = Field( + ..., + min_length=1, + description="Human-facing explanation shown in AddNodePanel.", + ) + llm_hint: Optional[str] = Field( + default=None, + description="LLM-only guidance; omitted from the UI.", + ) + category: NodeCategory + icon: str # lucide-react icon name (e.g., "Play") + version: str = "1.0.0" + properties: list[PropertySpec] + examples: list[NodeExample] = Field(default_factory=list) + graph_constraints: Optional[GraphConstraints] = None + + model_config = ConfigDict(extra="forbid") diff --git a/api/services/workflow/node_specs/agent.py b/api/services/workflow/node_specs/agent.py new file mode 100644 index 0000000..218fd9f --- /dev/null +++ b/api/services/workflow/node_specs/agent.py @@ -0,0 +1,168 @@ +"""Spec for the Agent node — the workhorse mid-call node where the LLM +executes a focused conversational step with optional tools and documents.""" + +from api.services.workflow.node_specs._base import ( + DisplayOptions, + GraphConstraints, + NodeCategory, + NodeExample, + NodeSpec, + PropertyOption, + PropertySpec, + PropertyType, +) + +SPEC = NodeSpec( + name="agentNode", + display_name="Agent Node", + description="Conversational step — the LLM runs one focused exchange.", + llm_hint=( + "Mid-call step executed by the LLM. Most workflows are a chain of " + "agent nodes connected by edges that describe transition conditions. " + "Each agent node can invoke tools and reference documents." + ), + category=NodeCategory.call_node, + icon="Headset", + properties=[ + PropertySpec( + name="name", + type=PropertyType.string, + display_name="Name", + description=( + "Short identifier for this step (e.g., 'Qualify Budget'). " + "Appears in call logs and edge transition tools." + ), + required=True, + min_length=1, + default="Agent", + ), + PropertySpec( + name="prompt", + type=PropertyType.mention_textarea, + display_name="Prompt", + description=( + "Agent system prompt for this step. Supports " + "{{template_variables}} from extraction or pre-call fetch." + ), + required=True, + min_length=1, + placeholder="Ask the caller about their budget and timeline.", + ), + PropertySpec( + name="allow_interrupt", + type=PropertyType.boolean, + display_name="Allow Interruption", + description=( + "When true, the user can interrupt the agent mid-utterance. " + "Set false for non-interruptible disclosures." + ), + default=True, + ), + PropertySpec( + name="add_global_prompt", + type=PropertyType.boolean, + display_name="Add Global Prompt", + description=( + "When true and a Global node exists, prepends the global " + "prompt to this node's prompt at runtime." + ), + default=True, + ), + PropertySpec( + name="extraction_enabled", + type=PropertyType.boolean, + display_name="Enable Variable Extraction", + description=( + "When true, runs an LLM extraction pass on transition out of " + "this node to capture variables from the conversation." + ), + default=False, + ), + PropertySpec( + name="extraction_prompt", + type=PropertyType.string, + display_name="Extraction Prompt", + description="Overall instructions guiding variable extraction.", + display_options=DisplayOptions(show={"extraction_enabled": [True]}), + editor="textarea", + ), + PropertySpec( + name="extraction_variables", + type=PropertyType.fixed_collection, + display_name="Variables to Extract", + description=( + "Each entry declares one variable to capture from the " + "conversation, with its name, type, and per-variable hint." + ), + display_options=DisplayOptions(show={"extraction_enabled": [True]}), + properties=[ + PropertySpec( + name="name", + type=PropertyType.string, + display_name="Variable Name", + description="snake_case identifier used downstream.", + required=True, + ), + PropertySpec( + name="type", + type=PropertyType.options, + display_name="Type", + description="Data type of the extracted value.", + required=True, + default="string", + options=[ + PropertyOption(value="string", label="String"), + PropertyOption(value="number", label="Number"), + PropertyOption(value="boolean", label="Boolean"), + ], + ), + PropertySpec( + name="prompt", + type=PropertyType.string, + display_name="Extraction Hint", + description="Per-variable hint describing what to look for.", + editor="textarea", + ), + ], + ), + PropertySpec( + name="tool_uuids", + type=PropertyType.tool_refs, + display_name="Tools", + description="Tools the agent can invoke during this step.", + llm_hint="List of tool UUIDs from `list_tools`.", + ), + PropertySpec( + name="document_uuids", + type=PropertyType.document_refs, + display_name="Knowledge Base Documents", + description="Documents the agent can reference during this step.", + llm_hint="List of document UUIDs from `list_documents`.", + ), + ], + examples=[ + NodeExample( + name="qualify_lead", + data={ + "name": "Qualify Budget", + "prompt": "Ask about budget and timeline. Capture both before transitioning.", + "allow_interrupt": True, + "extraction_enabled": True, + "extraction_prompt": "Extract budget amount and rough timeline.", + "extraction_variables": [ + { + "name": "budget_usd", + "type": "number", + "prompt": "Stated budget in USD", + }, + { + "name": "timeline", + "type": "string", + "prompt": "When they want to start", + }, + ], + }, + ), + ], + graph_constraints=GraphConstraints(min_incoming=1), +) diff --git a/api/services/workflow/node_specs/display_options_fixtures.json b/api/services/workflow/node_specs/display_options_fixtures.json new file mode 100644 index 0000000..ab40a88 --- /dev/null +++ b/api/services/workflow/node_specs/display_options_fixtures.json @@ -0,0 +1,123 @@ +{ + "_doc": "Golden fixtures for the display_options evaluator. Both the Python evaluator (api/services/workflow/node_specs/_base.py:evaluate_display_options) and the TypeScript evaluator (ui/src/components/flow/renderer/displayOptions.ts:evaluateDisplayOptions) must agree on every case here. Fixtures double as documentation for the show/hide semantics.", + "cases": [ + { + "name": "no_rules_visible", + "rules": null, + "values": {"a": 1}, + "expected": true + }, + { + "name": "empty_rules_visible", + "rules": {"show": null, "hide": null}, + "values": {}, + "expected": true + }, + { + "name": "show_match_visible", + "rules": {"show": {"extraction_enabled": [true]}}, + "values": {"extraction_enabled": true}, + "expected": true + }, + { + "name": "show_mismatch_hidden", + "rules": {"show": {"extraction_enabled": [true]}}, + "values": {"extraction_enabled": false}, + "expected": false + }, + { + "name": "show_missing_field_hidden", + "rules": {"show": {"extraction_enabled": [true]}}, + "values": {}, + "expected": false + }, + { + "name": "show_multiple_allowed_values", + "rules": {"show": {"greeting_type": ["text", "audio"]}}, + "values": {"greeting_type": "audio"}, + "expected": true + }, + { + "name": "show_multiple_keys_all_match", + "rules": { + "show": { + "qa_use_workflow_llm": [false], + "qa_provider": ["azure"] + } + }, + "values": {"qa_use_workflow_llm": false, "qa_provider": "azure"}, + "expected": true + }, + { + "name": "show_multiple_keys_one_mismatch_hides", + "rules": { + "show": { + "qa_use_workflow_llm": [false], + "qa_provider": ["azure"] + } + }, + "values": {"qa_use_workflow_llm": false, "qa_provider": "openai"}, + "expected": false + }, + { + "name": "hide_match_hides", + "rules": {"hide": {"locked": [true]}}, + "values": {"locked": true}, + "expected": false + }, + { + "name": "hide_mismatch_visible", + "rules": {"hide": {"locked": [true]}}, + "values": {"locked": false}, + "expected": true + }, + { + "name": "hide_missing_field_visible", + "rules": {"hide": {"locked": [true]}}, + "values": {}, + "expected": true + }, + { + "name": "hide_or_combined_either_hides", + "rules": {"hide": {"a": [1], "b": [2]}}, + "values": {"a": 0, "b": 2}, + "expected": false + }, + { + "name": "show_and_hide_both_required", + "rules": {"show": {"enabled": [true]}, "hide": {"locked": [true]}}, + "values": {"enabled": true, "locked": false}, + "expected": true + }, + { + "name": "show_and_hide_show_passes_hide_blocks", + "rules": {"show": {"enabled": [true]}, "hide": {"locked": [true]}}, + "values": {"enabled": true, "locked": true}, + "expected": false + }, + { + "name": "show_and_hide_show_fails_hide_irrelevant", + "rules": {"show": {"enabled": [true]}, "hide": {"locked": [true]}}, + "values": {"enabled": false, "locked": false}, + "expected": false + }, + { + "name": "scalar_int_strict", + "rules": {"show": {"sample_rate": [100]}}, + "values": {"sample_rate": 100}, + "expected": true + }, + { + "name": "scalar_int_mismatch", + "rules": {"show": {"sample_rate": [100]}}, + "values": {"sample_rate": 99}, + "expected": false + }, + { + "name": "scalar_string_strict", + "rules": {"show": {"http_method": ["POST", "PUT"]}}, + "values": {"http_method": "GET"}, + "expected": false + } + ] +} diff --git a/api/services/workflow/node_specs/end_call.py b/api/services/workflow/node_specs/end_call.py new file mode 100644 index 0000000..33129ed --- /dev/null +++ b/api/services/workflow/node_specs/end_call.py @@ -0,0 +1,141 @@ +"""Spec for the End Call node — terminal node that wraps up a conversation +and optionally extracts variables before hangup.""" + +from api.services.workflow.node_specs._base import ( + DisplayOptions, + GraphConstraints, + NodeCategory, + NodeExample, + NodeSpec, + PropertyOption, + PropertySpec, + PropertyType, +) + +SPEC = NodeSpec( + name="endCall", + display_name="End Call", + description="Closes the conversation and hangs up.", + llm_hint=( + "Terminal node that politely closes the conversation. Variable " + "extraction can run before hangup. A workflow can have multiple " + "endCall nodes reached via different edge conditions." + ), + category=NodeCategory.call_node, + icon="OctagonX", + properties=[ + PropertySpec( + name="name", + type=PropertyType.string, + display_name="Name", + description=( + "Short identifier shown in call logs. Should describe the " + "ending context (e.g., 'Successful close', 'Polite decline')." + ), + required=True, + min_length=1, + default="End Call", + ), + PropertySpec( + name="prompt", + type=PropertyType.mention_textarea, + display_name="Prompt", + description=( + "Agent system prompt for the closing exchange. Supports " + "{{template_variables}} from extraction or pre-call fetch." + ), + required=True, + min_length=1, + placeholder="Thank the caller and confirm next steps before ending the call.", + ), + PropertySpec( + name="add_global_prompt", + type=PropertyType.boolean, + display_name="Add Global Prompt", + description=( + "When true and a Global node exists, prepends the global " + "prompt to this node's prompt at runtime." + ), + default=False, + ), + PropertySpec( + name="extraction_enabled", + type=PropertyType.boolean, + display_name="Enable Variable Extraction", + description=( + "When true, runs an LLM extraction pass before hangup to " + "capture variables from the conversation." + ), + default=False, + ), + PropertySpec( + name="extraction_prompt", + type=PropertyType.string, + display_name="Extraction Prompt", + description=( + "Overall instructions guiding how variables should be " + "extracted from the conversation." + ), + display_options=DisplayOptions(show={"extraction_enabled": [True]}), + editor="textarea", + ), + PropertySpec( + name="extraction_variables", + type=PropertyType.fixed_collection, + display_name="Variables to Extract", + description=( + "Each entry declares one variable to capture from the " + "conversation, with its name, data type, and a per-variable " + "extraction hint." + ), + display_options=DisplayOptions(show={"extraction_enabled": [True]}), + properties=[ + PropertySpec( + name="name", + type=PropertyType.string, + display_name="Variable Name", + description="snake_case identifier used downstream.", + required=True, + ), + PropertySpec( + name="type", + type=PropertyType.options, + display_name="Type", + description="The data type of the extracted value.", + required=True, + default="string", + options=[ + PropertyOption(value="string", label="String"), + PropertyOption(value="number", label="Number"), + PropertyOption(value="boolean", label="Boolean"), + ], + ), + PropertySpec( + name="prompt", + type=PropertyType.string, + display_name="Extraction Hint", + description=( + "Per-variable hint describing what to look for in " + "the conversation." + ), + editor="textarea", + ), + ], + ), + ], + examples=[ + NodeExample( + name="successful_close", + data={ + "name": "Successful Close", + "prompt": "Confirm the appointment time, thank the caller, and end the call.", + "add_global_prompt": False, + }, + ), + ], + graph_constraints=GraphConstraints( + min_incoming=1, + min_outgoing=0, + max_outgoing=0, + ), +) diff --git a/api/services/workflow/node_specs/global_node.py b/api/services/workflow/node_specs/global_node.py new file mode 100644 index 0000000..bd983d7 --- /dev/null +++ b/api/services/workflow/node_specs/global_node.py @@ -0,0 +1,77 @@ +"""Spec for the Global node — system-level instructions appended to every +agent node that opts in via `add_global_prompt`.""" + +from api.services.workflow.node_specs._base import ( + GraphConstraints, + NodeCategory, + NodeExample, + NodeSpec, + PropertySpec, + PropertyType, +) + +SPEC = NodeSpec( + name="globalNode", + display_name="Global Node", + description="Persona/tone appended to every agent node's prompt.", + llm_hint=( + "System-level prompt appended to every prompted node whose " + "`add_global_prompt` is true. Use it for persona, tone, and shared " + "rules that apply across the entire conversation. At most one " + "global node per workflow." + ), + category=NodeCategory.global_node, + icon="Globe", + properties=[ + PropertySpec( + name="name", + type=PropertyType.string, + display_name="Name", + description=( + "Short identifier shown in the canvas and call logs. Has no " + "runtime effect." + ), + required=True, + min_length=1, + default="Global Node", + ), + PropertySpec( + name="prompt", + type=PropertyType.mention_textarea, + display_name="Global Prompt", + description=( + "Text appended to every prompted node's system prompt when " + "that node has `add_global_prompt=true`. Supports " + "{{template_variables}}." + ), + required=True, + min_length=1, + placeholder="You are a friendly assistant calling on behalf of {{company_name}}.", + default=( + "You are a helpful assistant whose mode of interaction with " + "the user is voice. So don't use any special characters which " + "can not be pronounced. Use short sentences and simple language." + ), + ), + ], + examples=[ + NodeExample( + name="basic_persona", + description="Establishes a consistent persona across the call.", + data={ + "name": "Persona", + "prompt": ( + "You are Sarah, a polite and warm representative from " + "Acme Corp. Always thank the caller for their time and " + "speak in short conversational sentences." + ), + }, + ), + ], + graph_constraints=GraphConstraints( + min_incoming=0, + max_incoming=0, + min_outgoing=0, + max_outgoing=0, + ), +) diff --git a/api/services/workflow/node_specs/qa.py b/api/services/workflow/node_specs/qa.py new file mode 100644 index 0000000..ebb95ab --- /dev/null +++ b/api/services/workflow/node_specs/qa.py @@ -0,0 +1,196 @@ +"""Spec for the QA Analysis node — runs an LLM quality review on the call +transcript after completion.""" + +from api.services.workflow.node_specs._base import ( + DisplayOptions, + NodeCategory, + NodeExample, + NodeSpec, + PropertyOption, + PropertySpec, + PropertyType, +) + +DEFAULT_QA_SYSTEM_PROMPT = """You are a QA analyst evaluating a specific segment of a voice AI conversation. + +## Node Purpose +{{node_summary}} + +## Previous Conversation Context (For start of conversation, previous conversation summary can be empty.) +{{previous_conversation_summary}} + +## Tags to evaluate + +Examine the conversation carefully and identify which of the following tags apply: + +- UNCLEAR_CONVERSATION - The conversation is not coherent or clear, messages don't connect logically +- ASSISTANT_IN_LOOP - The assistant asks the same question multiple times or gets stuck repeating itself +- ASSISTANT_REPLY_IMPROPER - The assistant did not reply properly to the user's question/query or seems confused by what the user said +- USER_FRUSTRATED - The user seems angry, frustrated, or is complaining about something in the call +- USER_NOT_UNDERSTANDING - The user explicitly says they don't understand or repeatedly asks for clarification +- HEARING_ISSUES - Either party can't hear the other ("hello?", "are you there?", "can you hear me?") +- DEAD_AIR - Unusually long silences in the conversation (use the timestamps to judge) +- USER_REQUESTING_FEATURE - The user asks for something the assistant can't fulfill +- ASSISTANT_LACKS_EMPATHY - The assistant ignores the user's personal situation or emotional state and continues pitching or pushing the agenda. +- USER_DETECTS_AI - The user suspects or identifies that they are talking to an AI/robot/bot rather than a real human. + +## Call metrics (pre-computed) + +Use these alongside the transcript for your analysis: +{{metrics}} + +## Output format + +Return ONLY a valid JSON object (no markdown): +{ + "tags": [ + { + "tag": "TAG_NAME", + "reason": "Short reason with evidence from the transcript" + } + ], + "overall_sentiment": "positive|neutral|negative", + "call_quality_score": <1-10>, + "summary": "1-2 sentence summary of this segment" +} + +If no tags apply, return an empty tags list. Always provide sentiment, score, and summary.""" + + +SPEC = NodeSpec( + name="qa", + display_name="QA Analysis", + description="Run LLM quality analysis on the call transcript.", + llm_hint=( + "Runs an LLM quality review on the call transcript after completion. " + "Per-node analysis splits the conversation by node and evaluates each " + "segment against the configured system prompt. Sampling, minimum " + "duration, and voicemail filters are supported." + ), + category=NodeCategory.integration, + icon="ClipboardCheck", + properties=[ + PropertySpec( + name="name", + type=PropertyType.string, + display_name="Name", + description="Short identifier for this QA configuration.", + required=True, + min_length=1, + default="QA Analysis", + ), + PropertySpec( + name="qa_enabled", + type=PropertyType.boolean, + display_name="Enabled", + description="When false, the QA run is skipped.", + default=True, + ), + PropertySpec( + name="qa_system_prompt", + type=PropertyType.string, + display_name="System Prompt", + description=( + "Instructions to the QA reviewer LLM. Supports placeholders: " + "`{node_summary}`, `{previous_conversation_summary}`, " + "`{transcript}`, `{metrics}`." + ), + editor="textarea", + default=DEFAULT_QA_SYSTEM_PROMPT, + ), + PropertySpec( + name="qa_min_call_duration", + type=PropertyType.number, + display_name="Minimum Call Duration (seconds)", + description="Calls shorter than this are skipped.", + default=15, + min_value=0, + ), + PropertySpec( + name="qa_voicemail_calls", + type=PropertyType.boolean, + display_name="Include Voicemail Calls", + description="When false, calls flagged as voicemail are skipped.", + default=False, + ), + PropertySpec( + name="qa_sample_rate", + type=PropertyType.number, + display_name="Sample Rate (%)", + description=( + "Percent of eligible calls QA'd. 100 means every call; lower " + "values use random sampling." + ), + default=100, + min_value=1, + max_value=100, + ), + # ---- LLM configuration ---- + PropertySpec( + name="qa_use_workflow_llm", + type=PropertyType.boolean, + display_name="Use Workflow's LLM", + description=( + "When true, the QA pass uses the same LLM the workflow runs " + "with. Set false to specify a separate provider/model." + ), + default=True, + ), + PropertySpec( + name="qa_provider", + type=PropertyType.options, + display_name="QA LLM Provider", + description="LLM provider used for the QA pass.", + display_options=DisplayOptions(show={"qa_use_workflow_llm": [False]}), + options=[ + PropertyOption(value="openai", label="OpenAI"), + PropertyOption(value="azure", label="Azure OpenAI"), + PropertyOption(value="openrouter", label="OpenRouter"), + PropertyOption(value="anthropic", label="Anthropic"), + ], + ), + PropertySpec( + name="qa_model", + type=PropertyType.string, + display_name="QA Model", + description=( + "Model identifier (e.g., 'gpt-4o', 'claude-sonnet-4-6'). " + "Provider-specific." + ), + display_options=DisplayOptions(show={"qa_use_workflow_llm": [False]}), + default="default", + ), + PropertySpec( + name="qa_api_key", + type=PropertyType.string, + display_name="API Key", + description="API key for the chosen provider.", + display_options=DisplayOptions(show={"qa_use_workflow_llm": [False]}), + ), + PropertySpec( + name="qa_endpoint", + type=PropertyType.url, + display_name="Azure Endpoint", + description="Required for the Azure provider.", + display_options=DisplayOptions( + show={"qa_use_workflow_llm": [False], "qa_provider": ["azure"]} + ), + ), + ], + examples=[ + NodeExample( + name="basic_qa", + data={ + "name": "Compliance Check", + "qa_enabled": True, + "qa_system_prompt": ( + "You are a compliance reviewer. Review the transcript and " + "produce a JSON object with `tags`, `summary`, " + "`call_quality_score`, and `overall_sentiment`." + ), + "qa_min_call_duration": 30, + "qa_sample_rate": 100, + }, + ), + ], +) diff --git a/api/services/workflow/node_specs/start_call.py b/api/services/workflow/node_specs/start_call.py new file mode 100644 index 0000000..b0f3075 --- /dev/null +++ b/api/services/workflow/node_specs/start_call.py @@ -0,0 +1,248 @@ +"""Spec for the Start Call node — the single entry point of every workflow. +Carries greeting, pre-call data fetch, and the same prompt/extraction/tools +fields as agent nodes.""" + +from api.services.workflow.node_specs._base import ( + DisplayOptions, + GraphConstraints, + NodeCategory, + NodeExample, + NodeSpec, + PropertyOption, + PropertySpec, + PropertyType, +) + +SPEC = NodeSpec( + name="startCall", + display_name="Start Call", + description="Entry point of the workflow — plays a greeting and opens the conversation.", + llm_hint=( + "The entry point of every workflow (exactly one required). Plays an " + "optional greeting, can fetch context from an external API before " + "the call begins, and executes the first conversational turn." + ), + category=NodeCategory.call_node, + icon="Play", + properties=[ + PropertySpec( + name="name", + type=PropertyType.string, + display_name="Name", + description="Short identifier shown in the canvas and call logs.", + required=True, + min_length=1, + default="Start Call", + ), + # ---- Greeting (variant via greeting_type) ---- + PropertySpec( + name="greeting_type", + type=PropertyType.options, + display_name="Greeting Type", + description=( + "Whether the optional greeting is spoken via TTS from text " + "or played from a pre-recorded audio file." + ), + default="text", + options=[ + PropertyOption(value="text", label="Text (TTS)"), + PropertyOption(value="audio", label="Pre-recorded Audio"), + ], + ), + PropertySpec( + name="greeting", + type=PropertyType.string, + display_name="Greeting Text", + description=( + "Text spoken via TTS at the start of the call. Supports " + "{{template_variables}}. Leave empty to skip the greeting." + ), + display_options=DisplayOptions(show={"greeting_type": ["text"]}), + editor="textarea", + placeholder="Hi {{first_name}}, this is Sarah from Acme.", + ), + PropertySpec( + name="greeting_recording_id", + type=PropertyType.recording_ref, + display_name="Greeting Recording", + description="Pre-recorded audio file played at the start of the call.", + llm_hint=( + "Value is the `recording_id` string. Use the `list_recordings` " + "MCP tool to discover available recordings." + ), + display_options=DisplayOptions(show={"greeting_type": ["audio"]}), + ), + PropertySpec( + name="prompt", + type=PropertyType.mention_textarea, + display_name="Prompt", + description=( + "Agent system prompt for the opening turn. Supports " + "{{template_variables}} from pre-call fetch and the initial context." + ), + required=True, + min_length=1, + placeholder="Greet the caller warmly and ask how you can help today.", + ), + # ---- Behavior toggles ---- + PropertySpec( + name="allow_interrupt", + type=PropertyType.boolean, + display_name="Allow Interruption", + description=("When true, the user can interrupt the agent mid-utterance."), + default=False, + ), + PropertySpec( + name="add_global_prompt", + type=PropertyType.boolean, + display_name="Add Global Prompt", + description=( + "When true and a Global node exists, prepends the global " + "prompt to this node's prompt at runtime." + ), + default=True, + ), + PropertySpec( + name="delayed_start", + type=PropertyType.boolean, + display_name="Delayed Start", + description=( + "When true, the agent waits before speaking after pickup. " + "Useful for outbound calls where the called party needs a " + "moment to settle." + ), + default=False, + ), + PropertySpec( + name="delayed_start_duration", + type=PropertyType.number, + display_name="Delay Duration (seconds)", + description="Seconds to wait before the agent speaks. 0.1–10.", + default=2.0, + min_value=0.1, + max_value=10.0, + display_options=DisplayOptions(show={"delayed_start": [True]}), + ), + # ---- Variable extraction ---- + PropertySpec( + name="extraction_enabled", + type=PropertyType.boolean, + display_name="Enable Variable Extraction", + description=( + "When true, runs an LLM extraction pass on transition out of " + "this node to capture variables from the opening turn." + ), + default=False, + ), + PropertySpec( + name="extraction_prompt", + type=PropertyType.string, + display_name="Extraction Prompt", + description="Overall instructions guiding variable extraction.", + display_options=DisplayOptions(show={"extraction_enabled": [True]}), + editor="textarea", + ), + PropertySpec( + name="extraction_variables", + type=PropertyType.fixed_collection, + display_name="Variables to Extract", + description=( + "Each entry declares one variable to capture, with its name, " + "data type, and per-variable extraction hint." + ), + display_options=DisplayOptions(show={"extraction_enabled": [True]}), + properties=[ + PropertySpec( + name="name", + type=PropertyType.string, + display_name="Variable Name", + description="snake_case identifier used downstream.", + required=True, + ), + PropertySpec( + name="type", + type=PropertyType.options, + display_name="Type", + description="Data type of the extracted value.", + required=True, + default="string", + options=[ + PropertyOption(value="string", label="String"), + PropertyOption(value="number", label="Number"), + PropertyOption(value="boolean", label="Boolean"), + ], + ), + PropertySpec( + name="prompt", + type=PropertyType.string, + display_name="Extraction Hint", + description="Per-variable hint describing what to look for.", + editor="textarea", + ), + ], + ), + # ---- Tools / documents ---- + PropertySpec( + name="tool_uuids", + type=PropertyType.tool_refs, + display_name="Tools", + description="Tools the agent can invoke during the opening turn.", + llm_hint="List of tool UUIDs from `list_tools`.", + ), + PropertySpec( + name="document_uuids", + type=PropertyType.document_refs, + display_name="Knowledge Base Documents", + description="Documents the agent can reference.", + llm_hint="List of document UUIDs from `list_documents`.", + ), + # ---- Pre-call data fetch (advanced) ---- + PropertySpec( + name="pre_call_fetch_enabled", + type=PropertyType.boolean, + display_name="Pre-Call Data Fetch", + description=( + "When true, makes a POST request to an external API before " + "the call starts and merges the JSON response into the call " + "context as template variables." + ), + default=False, + ), + PropertySpec( + name="pre_call_fetch_url", + type=PropertyType.url, + display_name="Endpoint URL", + description=( + "URL the pre-call POST request is sent to. The request body " + "includes caller and called numbers." + ), + display_options=DisplayOptions(show={"pre_call_fetch_enabled": [True]}), + placeholder="https://api.example.com/customer-lookup", + ), + PropertySpec( + name="pre_call_fetch_credential_uuid", + type=PropertyType.credential_ref, + display_name="Authentication", + description="Optional credential attached to the pre-call request.", + llm_hint="Credential UUID from `list_credentials`.", + display_options=DisplayOptions(show={"pre_call_fetch_enabled": [True]}), + ), + ], + examples=[ + NodeExample( + name="warm_greeting", + data={ + "name": "Greeting", + "prompt": "Greet warmly and ask the caller's reason for calling.", + "greeting_type": "text", + "greeting": "Hi {{first_name}}, this is Sarah from Acme.", + "allow_interrupt": True, + }, + ), + ], + graph_constraints=GraphConstraints( + min_incoming=0, + max_incoming=0, + min_outgoing=1, + ), +) diff --git a/api/services/workflow/node_specs/trigger.py b/api/services/workflow/node_specs/trigger.py new file mode 100644 index 0000000..fa237cb --- /dev/null +++ b/api/services/workflow/node_specs/trigger.py @@ -0,0 +1,61 @@ +"""Spec for the API Trigger node — exposes a public webhook URL that +external systems can hit to launch the workflow.""" + +from api.services.workflow.node_specs._base import ( + GraphConstraints, + NodeCategory, + NodeExample, + NodeSpec, + PropertySpec, + PropertyType, +) + +SPEC = NodeSpec( + name="trigger", + display_name="API Trigger", + description="Public HTTP endpoint that launches the workflow.", + llm_hint=( + "Exposes a public HTTP POST endpoint. External systems call the URL " + "(derived from the auto-generated `trigger_path`) to launch this " + "workflow. Requires an API key in the `X-API-Key` header." + ), + category=NodeCategory.trigger, + icon="Webhook", + properties=[ + PropertySpec( + name="name", + type=PropertyType.string, + display_name="Name", + description="Short identifier shown in the canvas. No runtime effect.", + required=True, + min_length=1, + default="API Trigger", + ), + PropertySpec( + name="enabled", + type=PropertyType.boolean, + display_name="Enabled", + description="When false, the trigger URL returns 404.", + default=True, + ), + PropertySpec( + name="trigger_path", + type=PropertyType.string, + display_name="Trigger Path", + description=( + "Auto-generated UUID-style path segment that uniquely " + "identifies this trigger. Do not edit manually." + ), + ), + ], + examples=[ + NodeExample( + name="default", + data={"name": "Inbound Trigger", "enabled": True}, + ), + ], + graph_constraints=GraphConstraints( + min_incoming=0, + max_incoming=0, + ), +) diff --git a/api/services/workflow/node_specs/webhook.py b/api/services/workflow/node_specs/webhook.py new file mode 100644 index 0000000..ec71d12 --- /dev/null +++ b/api/services/workflow/node_specs/webhook.py @@ -0,0 +1,135 @@ +"""Spec for the Webhook node — sends an HTTP request to an external system +after the workflow completes.""" + +from api.services.workflow.node_specs._base import ( + NodeCategory, + NodeExample, + NodeSpec, + PropertyOption, + PropertySpec, + PropertyType, +) + +SPEC = NodeSpec( + name="webhook", + display_name="Webhook", + description="Send HTTP request after the workflow completes.", + llm_hint=( + "Sends an HTTP request to an external system after the workflow " + "completes. The payload is a Jinja-templated JSON body with access " + "to `workflow_run_id`, `initial_context`, `gathered_context`, " + "`annotations`, and call metadata." + ), + category=NodeCategory.integration, + icon="Link2", + properties=[ + PropertySpec( + name="name", + type=PropertyType.string, + display_name="Name", + description="Short identifier shown in the canvas and run logs.", + required=True, + min_length=1, + default="Webhook", + ), + PropertySpec( + name="enabled", + type=PropertyType.boolean, + display_name="Enabled", + description="When false, the webhook is skipped at run time.", + default=True, + ), + PropertySpec( + name="http_method", + type=PropertyType.options, + display_name="HTTP Method", + description="HTTP verb used for the outbound request.", + default="POST", + options=[ + PropertyOption(value="GET", label="GET"), + PropertyOption(value="POST", label="POST"), + PropertyOption(value="PUT", label="PUT"), + PropertyOption(value="PATCH", label="PATCH"), + PropertyOption(value="DELETE", label="DELETE"), + ], + ), + PropertySpec( + name="endpoint_url", + type=PropertyType.url, + display_name="Endpoint URL", + description="URL the request is sent to.", + placeholder="https://api.example.com/webhook", + ), + PropertySpec( + name="credential_uuid", + type=PropertyType.credential_ref, + display_name="Authentication", + description="Optional credential applied as the Authorization header.", + llm_hint="Credential UUID from `list_credentials`.", + ), + PropertySpec( + name="custom_headers", + type=PropertyType.fixed_collection, + display_name="Custom Headers", + description="Additional HTTP headers to include with the request.", + properties=[ + PropertySpec( + name="key", + type=PropertyType.string, + display_name="Header Name", + description="HTTP header name (e.g., 'X-Source').", + required=True, + ), + PropertySpec( + name="value", + type=PropertyType.string, + display_name="Header Value", + description="Header value (supports {{template_variables}}).", + required=True, + ), + ], + ), + PropertySpec( + name="payload_template", + type=PropertyType.json, + display_name="Payload Template", + description=( + "JSON body of the request. Values are Jinja-rendered against " + "the run context — `{{workflow_run_id}}`, " + "`{{gathered_context.foo}}`, `{{annotations.qa_xxx}}`, etc." + ), + default={ + "call_id": "{{workflow_run_id}}", + "first_name": "{{initial_context.first_name}}", + "rsvp": "{{gathered_context.rsvp}}", + "duration": "{{cost_info.call_duration_seconds}}", + "recording_url": "{{recording_url}}", + "transcript_url": "{{transcript_url}}", + }, + ), + PropertySpec( + name="retry_config", + type=PropertyType.json, + display_name="Retry Configuration", + description=( + "Optional retry settings: `enabled` (bool), `max_retries` " + "(int), `retry_delay_seconds` (int)." + ), + ), + ], + examples=[ + NodeExample( + name="post_to_crm", + data={ + "name": "Notify CRM", + "enabled": True, + "http_method": "POST", + "endpoint_url": "https://crm.example.com/calls", + "payload_template": { + "run_id": "{{workflow_run_id}}", + "outcome": "{{gathered_context.call_disposition}}", + }, + }, + ), + ], +) diff --git a/api/services/workflow/pipecat_engine.py b/api/services/workflow/pipecat_engine.py index d72270d..fa3ddc7 100644 --- a/api/services/workflow/pipecat_engine.py +++ b/api/services/workflow/pipecat_engine.py @@ -243,6 +243,7 @@ class PipecatEngine: else 16000, queue_frame=self._transport_output.queue_frame, transcript=result.transcript, + persist_to_logs=True, ) else: logger.warning( @@ -252,7 +253,11 @@ class PipecatEngine: logger.info(f"Playing transition speech: {transition_speech}") self._queued_speech_mute_state = "waiting" await self.task.queue_frame( - TTSSpeakFrame(transition_speech, append_to_context=False) + TTSSpeakFrame( + transition_speech, + append_to_context=False, + persist_to_logs=True, + ) ) # Set context for the new node, so that when the function call result diff --git a/api/services/workflow/pipecat_engine_custom_tools.py b/api/services/workflow/pipecat_engine_custom_tools.py index d846e43..e9c4a75 100644 --- a/api/services/workflow/pipecat_engine_custom_tools.py +++ b/api/services/workflow/pipecat_engine_custom_tools.py @@ -100,6 +100,7 @@ class CustomToolManager: else 16000, queue_frame=self._engine._transport_output.queue_frame, transcript=result.transcript, + persist_to_logs=True, ) return True else: @@ -110,7 +111,11 @@ class CustomToolManager: custom_message = config.get("customMessage", "") if custom_message: await self._engine.task.queue_frame( - TTSSpeakFrame(custom_message, append_to_context=append_to_context) + TTSSpeakFrame( + custom_message, + append_to_context=append_to_context, + persist_to_logs=True, + ) ) return True @@ -311,6 +316,7 @@ class CustomToolManager: else 16000, queue_frame=self._engine._transport_output.queue_frame, transcript=result.transcript, + persist_to_logs=True, ) elif custom_message: logger.info( @@ -318,7 +324,11 @@ class CustomToolManager: ) self._engine._queued_speech_mute_state = "waiting" await self._engine.task.queue_frame( - TTSSpeakFrame(custom_message, append_to_context=False) + TTSSpeakFrame( + custom_message, + append_to_context=False, + persist_to_logs=True, + ) ) result = await execute_http_tool( diff --git a/api/services/workflow/qa/analysis.py b/api/services/workflow/qa/analysis.py index fc8ec21..b0a171e 100644 --- a/api/services/workflow/qa/analysis.py +++ b/api/services/workflow/qa/analysis.py @@ -8,6 +8,7 @@ from loguru import logger from api.db.models import WorkflowRunModel from api.services.gen_ai.json_parser import parse_llm_json from api.services.pipecat.service_factory import create_llm_service_from_provider +from api.services.workflow.dto import QANodeData from api.services.workflow.qa.conversation import ( build_conversation_structure, format_transcript, @@ -77,7 +78,7 @@ async def _generate_conversation_summary( async def run_per_node_qa_analysis( - qa_node_data: dict[str, Any], + qa_data: QANodeData, workflow_run: WorkflowRunModel, workflow_run_id: int, workflow_definition: dict, @@ -106,18 +107,16 @@ async def run_per_node_qa_analysis( logger.info( f"Events lack node_id for run {workflow_run_id}, falling back to whole-call QA" ) - return await _run_whole_call_qa_analysis( - qa_node_data, workflow_run, workflow_run_id - ) + return await _run_whole_call_qa_analysis(qa_data, workflow_run, workflow_run_id) - system_prompt = qa_node_data.get("qa_system_prompt", "") + system_prompt = qa_data.qa_system_prompt or "" if not system_prompt: logger.warning("No system prompt defined for QA Node") return {"error": "no_system_prompt", "node_results": {}} # Resolve LLM config provider, model, api_key, service_kwargs = await resolve_llm_config( - qa_node_data, workflow_run + qa_data, workflow_run ) if not api_key: logger.warning( @@ -127,7 +126,7 @@ async def run_per_node_qa_analysis( # Ensure node summaries node_summaries = await ensure_node_summaries( - workflow_definition, definition_id, workflow_run, qa_node_data + workflow_definition, definition_id, workflow_run, qa_data ) # Set up Langfuse tracing @@ -228,7 +227,7 @@ async def run_per_node_qa_analysis( async def _run_whole_call_qa_analysis( - qa_node_data: dict[str, Any], + qa_data: QANodeData, workflow_run: WorkflowRunModel, workflow_run_id: int, ) -> dict[str, Any]: @@ -254,13 +253,13 @@ async def _run_whole_call_qa_analysis( metrics = compute_call_metrics(rtf_events, call_duration) # Resolve LLM config - system_prompt = qa_node_data.get("qa_system_prompt", "") + system_prompt = qa_data.qa_system_prompt or "" if not system_prompt: logger.warning("No system prompt defined for QA Node") return {"error": "no_system_prompt", "node_results": {}} provider, model, api_key, service_kwargs = await resolve_llm_config( - qa_node_data, workflow_run + qa_data, workflow_run ) if not api_key: diff --git a/api/services/workflow/qa/llm_config.py b/api/services/workflow/qa/llm_config.py index e1fefe2..9c1159a 100644 --- a/api/services/workflow/qa/llm_config.py +++ b/api/services/workflow/qa/llm_config.py @@ -4,10 +4,11 @@ import random from api.db import db_client from api.db.models import WorkflowRunModel +from api.services.workflow.dto import QANodeData async def resolve_llm_config( - qa_node_data: dict, workflow_run: WorkflowRunModel + qa_data: QANodeData, workflow_run: WorkflowRunModel ) -> tuple[str, str, str, dict]: """Resolve the LLM provider, model, API key, and extra kwargs for QA analysis. @@ -18,24 +19,23 @@ async def resolve_llm_config( (provider, model, api_key, service_kwargs) tuple — service_kwargs can be passed directly to create_llm_service_from_provider as keyword arguments. """ - if not qa_node_data.get("qa_use_workflow_llm", True): - provider = qa_node_data.get("qa_provider", "openai") + if not qa_data.qa_use_workflow_llm: + provider = qa_data.qa_provider or "openai" kwargs = {} if provider == "azure": - kwargs["endpoint"] = qa_node_data.get("qa_endpoint", "") + kwargs["endpoint"] = qa_data.qa_endpoint or "" return ( provider, - qa_node_data.get("qa_model"), - qa_node_data.get("qa_api_key"), + qa_data.qa_model, + qa_data.qa_api_key, kwargs, ) # Fall back to user's configured LLM provider, model, api_key, kwargs = await resolve_user_llm_config(workflow_run) - qa_model = qa_node_data.get("qa_model", "default") - if qa_model and qa_model != "default": - model = qa_model + if qa_data.qa_model and qa_data.qa_model != "default": + model = qa_data.qa_model return provider, model, api_key, kwargs diff --git a/api/services/workflow/qa/node_summary.py b/api/services/workflow/qa/node_summary.py index c7bfabb..5896f4c 100644 --- a/api/services/workflow/qa/node_summary.py +++ b/api/services/workflow/qa/node_summary.py @@ -7,7 +7,7 @@ from loguru import logger from api.db import db_client from api.db.models import WorkflowRunModel from api.services.pipecat.service_factory import create_llm_service_from_provider -from api.services.workflow.dto import NodeType +from api.services.workflow.dto import NodeType, QANodeData from api.services.workflow.qa.llm_config import resolve_llm_config from api.services.workflow.qa.tracing import create_node_summary_trace from pipecat.processors.aggregators.llm_context import LLMContext @@ -48,7 +48,7 @@ async def ensure_node_summaries( workflow_definition: dict, definition_id: int | None, workflow_run: WorkflowRunModel, - qa_node_data: dict, + qa_data: QANodeData, ) -> dict[str, Any]: """Ensure every agentNode/startCall node has a summary in the definition. @@ -69,7 +69,7 @@ async def ensure_node_summaries( return existing_summaries provider, model, api_key, service_kwargs = await resolve_llm_config( - qa_node_data, workflow_run + qa_data, workflow_run ) if not api_key: logger.warning("No API key for node summary generation, skipping") diff --git a/api/services/workflow/tools/knowledge_base.py b/api/services/workflow/tools/knowledge_base.py index 3590797..6821583 100644 --- a/api/services/workflow/tools/knowledge_base.py +++ b/api/services/workflow/tools/knowledge_base.py @@ -242,7 +242,6 @@ async def _perform_retrieval( embedding_service = OpenAIEmbeddingService( db_client=db_client, - max_tokens=128, api_key=embeddings_api_key, model_id=embeddings_model or "text-embedding-3-small", base_url=embeddings_base_url, diff --git a/api/services/workflow/workflow.py b/api/services/workflow/workflow.py index e85ae68..d15e95a 100644 --- a/api/services/workflow/workflow.py +++ b/api/services/workflow/workflow.py @@ -2,7 +2,7 @@ import re from collections import Counter from typing import Dict, List, Set -from api.services.workflow.dto import EdgeDataDTO, NodeDataDTO, NodeType, ReactFlowDTO +from api.services.workflow.dto import EdgeDataDTO, NodeType, ReactFlowDTO from api.services.workflow.errors import ItemKind, WorkflowError # Regex for matching {{ variable }} template placeholders. @@ -61,32 +61,38 @@ class Edge: class Node: - def __init__(self, id: str, node_type: NodeType, data: NodeDataDTO): + def __init__(self, id: str, node_type: NodeType, data): self.id, self.node_type, self.data = id, node_type, data self.out: Dict[str, "Node"] = {} # forward nodes self.out_edges: List[Edge] = [] # forward edges with properties + # name/is_start/is_end live on every per-type data class (base). self.name = data.name - self.prompt = data.prompt - self.is_static = data.is_static self.is_start = data.is_start self.is_end = data.is_end - self.allow_interrupt = data.allow_interrupt - self.extraction_enabled = data.extraction_enabled - self.extraction_prompt = data.extraction_prompt - self.extraction_variables = data.extraction_variables - self.add_global_prompt = data.add_global_prompt - self.greeting = data.greeting - self.greeting_type = data.greeting_type - self.greeting_recording_id = data.greeting_recording_id - self.detect_voicemail = data.detect_voicemail - self.delayed_start = data.delayed_start - self.delayed_start_duration = data.delayed_start_duration - self.tool_uuids = data.tool_uuids - self.document_uuids = data.document_uuids - self.pre_call_fetch_enabled = data.pre_call_fetch_enabled - self.pre_call_fetch_url = data.pre_call_fetch_url - self.pre_call_fetch_credential_uuid = data.pre_call_fetch_credential_uuid + + # Type-specific fields — read with getattr so this works for every + # node variant in the discriminated union. + self.prompt = getattr(data, "prompt", None) + self.is_static = getattr(data, "is_static", False) + self.allow_interrupt = getattr(data, "allow_interrupt", False) + self.extraction_enabled = getattr(data, "extraction_enabled", False) + self.extraction_prompt = getattr(data, "extraction_prompt", None) + self.extraction_variables = getattr(data, "extraction_variables", None) + self.add_global_prompt = getattr(data, "add_global_prompt", True) + self.greeting = getattr(data, "greeting", None) + self.greeting_type = getattr(data, "greeting_type", None) + self.greeting_recording_id = getattr(data, "greeting_recording_id", None) + self.detect_voicemail = getattr(data, "detect_voicemail", False) + self.delayed_start = getattr(data, "delayed_start", False) + self.delayed_start_duration = getattr(data, "delayed_start_duration", None) + self.tool_uuids = getattr(data, "tool_uuids", None) + self.document_uuids = getattr(data, "document_uuids", None) + self.pre_call_fetch_enabled = getattr(data, "pre_call_fetch_enabled", False) + self.pre_call_fetch_url = getattr(data, "pre_call_fetch_url", None) + self.pre_call_fetch_credential_uuid = getattr( + data, "pre_call_fetch_credential_uuid", None + ) self.data = data @@ -98,9 +104,11 @@ class WorkflowGraph: """ def __init__(self, dto: ReactFlowDTO): - # build adjacency list + # build adjacency list. n.type comes off the discriminated-union + # variant as a literal string; coerce to NodeType for downstream + # comparisons. self.nodes: Dict[str, Node] = { - n.id: Node(n.id, n.type, n.data) for n in dto.nodes + n.id: Node(n.id, NodeType(n.type), n.data) for n in dto.nodes } # Store all edges diff --git a/api/tasks/knowledge_base_processing.py b/api/tasks/knowledge_base_processing.py index 0846cab..1c891e2 100644 --- a/api/tasks/knowledge_base_processing.py +++ b/api/tasks/knowledge_base_processing.py @@ -1,22 +1,22 @@ -"""ARQ background task for processing knowledge base documents.""" +"""ARQ background task for processing knowledge base documents. + +Document conversion and chunking live in the Model Proxy Service (MPS); +this task downloads the file from S3, calls MPS, then handles the embedding +and DB writes locally. +""" -import json import os import tempfile -from docling.chunking import HybridChunker -from docling.document_converter import DocumentConverter -from docling_core.transforms.chunker.tokenizer.huggingface import HuggingFaceTokenizer from loguru import logger -from transformers import AutoTokenizer from api.db import db_client from api.db.models import KnowledgeBaseChunkModel from api.services.gen_ai import OpenAIEmbeddingService +from api.services.mps_service_key_client import mps_service_key_client from api.services.storage import storage_fs -# For tokenization/chunking -TOKENIZER_MODEL = "sentence-transformers/all-MiniLM-L6-v2" +MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 async def process_knowledge_base_document( @@ -24,93 +24,84 @@ async def process_knowledge_base_document( document_id: int, s3_key: str, organization_id: int, + created_by_provider_id: str, max_tokens: int = 128, retrieval_mode: str = "chunked", ): - """Process a knowledge base document: download, chunk, embed, and store. + """Process a knowledge base document via MPS: download, call MPS, embed, store. Args: ctx: ARQ context document_id: Database ID of the document s3_key: S3 key where the file is stored organization_id: Organization ID + created_by_provider_id: Uploading user's provider ID (for OSS-mode auth to MPS) max_tokens: Maximum number of tokens per chunk (default: 128) retrieval_mode: "chunked" for vector search or "full_document" for full text """ logger.info( - f"Starting knowledge base document processing for document_id={document_id}, " - f"s3_key={s3_key}, organization_id={organization_id}" + f"Processing knowledge base document: document_id={document_id}, " + f"s3_key={s3_key}, org={organization_id}, mode={retrieval_mode}" ) temp_file_path = None try: - # Update status to processing await db_client.update_document_status(document_id, "processing") - # Extract file extension from S3 key filename = s3_key.split("/")[-1] - file_extension = ( - os.path.splitext(filename)[1] or ".bin" - ) # Default to .bin if no extension + file_extension = os.path.splitext(filename)[1] or ".bin" - # Create temp file for download with correct extension temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=file_extension) temp_file_path = temp_file.name temp_file.close() - # Download file from S3 logger.info(f"Downloading file from S3: {s3_key}") download_success = await storage_fs.adownload_file(s3_key, temp_file_path) - if not download_success: raise Exception(f"Failed to download file from S3: {s3_key}") - if not os.path.exists(temp_file_path): raise FileNotFoundError(f"Downloaded file not found: {temp_file_path}") file_size = os.path.getsize(temp_file_path) logger.info(f"Downloaded file size: {file_size} bytes") - # Validate file size (max 5MB) - max_file_size = 5 * 1024 * 1024 - if file_size > max_file_size: - error_message = f"File size ({file_size / (1024 * 1024):.1f}MB) exceeds the maximum allowed size of 5MB." + if file_size > MAX_FILE_SIZE_BYTES: + error_message = ( + f"File size ({file_size / (1024 * 1024):.1f}MB) exceeds the " + f"maximum allowed size of {MAX_FILE_SIZE_BYTES // (1024 * 1024)}MB." + ) logger.warning(f"Document {document_id}: {error_message}") await db_client.update_document_status( document_id, "failed", error_message=error_message ) return - # Compute file hash and get mime type file_hash = db_client.compute_file_hash(temp_file_path) mime_type = db_client.get_mime_type(temp_file_path) - filename = s3_key.split("/")[-1] - # Get document record document = await db_client.get_document_by_id(document_id) if not document: raise Exception(f"Document {document_id} not found") - # Check if a document with this hash already exists (reject duplicates) + # Reject duplicates (same hash already ingested for this org). existing_doc = await db_client.get_document_by_hash(file_hash, organization_id) if existing_doc and existing_doc.id != document_id: error_message = ( f"This file is a duplicate of '{existing_doc.filename}'. " - f"Please delete the duplicate files and consolidate them into a single unique file before uploading." + f"Please delete the duplicate files and consolidate them into a " + f"single unique file before uploading." ) logger.warning( - f"Duplicate document detected: {document_id} is duplicate of {existing_doc.id} " - f"({existing_doc.filename})" + f"Duplicate document detected: {document_id} is duplicate of " + f"{existing_doc.id} ({existing_doc.filename})" ) - # Update file metadata await db_client.update_document_metadata( document_id, file_size_bytes=file_size, file_hash=file_hash, mime_type=mime_type, ) - # Mark as failed with duplicate error message await db_client.update_document_status( document_id, "failed", @@ -122,7 +113,6 @@ async def process_knowledge_base_document( ) return - # Update document with file metadata await db_client.update_document_metadata( document_id, file_size_bytes=file_size, @@ -130,52 +120,35 @@ async def process_knowledge_base_document( mime_type=mime_type, ) - # Full document mode: extract text and store it, skip chunking/embedding + logger.info(f"Delegating document processing to MPS (mode={retrieval_mode})") + mps_response = await mps_service_key_client.process_document( + file_path=temp_file_path, + filename=filename, + content_type=mime_type or "application/octet-stream", + retrieval_mode=retrieval_mode, + max_tokens=max_tokens, + organization_id=organization_id, + created_by=created_by_provider_id, + ) + + docling_metadata = mps_response.get("docling_metadata", {}) + if retrieval_mode == "full_document": - logger.info(f"Document {document_id}: full_document mode, extracting text") - - plain_text_extensions = {".txt", ".json"} - if file_extension.lower() in plain_text_extensions: - with open(temp_file_path, "r", encoding="utf-8") as f: - full_text = f.read() - if file_extension.lower() == ".json": - try: - parsed = json.loads(full_text) - full_text = json.dumps(parsed, indent=2, ensure_ascii=False) - except json.JSONDecodeError: - pass - docling_metadata = {"document_type": "PlainText"} - else: - converter = DocumentConverter() - conversion_result = converter.convert(temp_file_path) - doc = conversion_result.document - full_text = doc.export_to_text() - docling_metadata = { - "num_pages": len(doc.pages) if hasattr(doc, "pages") else None, - "document_type": type(doc).__name__, - } - - # Store full text on the document record + full_text = mps_response.get("full_text") or "" await db_client.update_document_full_text(document_id, full_text) - await db_client.update_document_status( document_id, "completed", total_chunks=0, docling_metadata=docling_metadata, ) - logger.info( f"Successfully processed full_document {document_id}. " f"Text length: {len(full_text)} chars" ) return - # Initialize the OpenAI embedding service - logger.info( - f"Initializing OpenAI embedding service with max_tokens={max_tokens}" - ) - # Try to get user's embeddings configuration + # Chunked mode: fetch user embedding config, embed via OpenAI, persist chunks. embeddings_api_key = None embeddings_model = None embeddings_base_url = None @@ -187,7 +160,6 @@ async def process_knowledge_base_document( embeddings_base_url = getattr(user_config.embeddings, "base_url", None) logger.info(f"Using user embeddings config: model={embeddings_model}") - # Check if API key is configured if not embeddings_api_key: error_message = ( "OpenAI API key not configured. Please set your API key in " @@ -199,190 +171,57 @@ async def process_knowledge_base_document( ) return - service = OpenAIEmbeddingService( + embedding_service = OpenAIEmbeddingService( db_client=db_client, - max_tokens=max_tokens, api_key=embeddings_api_key, model_id=embeddings_model or "text-embedding-3-small", base_url=embeddings_base_url, ) - # Step 1: Initialize tokenizer for chunking - logger.info( - f"Loading tokenizer: {TOKENIZER_MODEL} with max_tokens={max_tokens}" - ) - hf_tokenizer = AutoTokenizer.from_pretrained(TOKENIZER_MODEL) - tokenizer = HuggingFaceTokenizer( - tokenizer=hf_tokenizer, - max_tokens=max_tokens, - ) + mps_chunks = mps_response.get("chunks", []) + if not mps_chunks: + logger.warning(f"Document {document_id}: MPS returned zero chunks") - chunk_texts = [] chunk_records = [] - token_counts = [] - - # Check if file is a plain text format that docling doesn't support - plain_text_extensions = {".txt", ".json"} - if file_extension.lower() in plain_text_extensions: - # Read text content directly - logger.info(f"Reading {file_extension} file directly (bypassing docling)") - with open(temp_file_path, "r", encoding="utf-8") as f: - raw_content = f.read() - - # For JSON files, pretty-print for better readability - if file_extension.lower() == ".json": - try: - parsed = json.loads(raw_content) - raw_content = json.dumps(parsed, indent=2, ensure_ascii=False) - except json.JSONDecodeError: - logger.warning( - "JSON file is not valid JSON, treating as plain text" - ) - - docling_metadata = { - "num_pages": None, - "document_type": "PlainText", - } - - # Token-based chunking for plain text - tokens = hf_tokenizer.encode(raw_content, add_special_tokens=False) - total_tokens = len(tokens) - logger.info( - f"Total tokens in file: {total_tokens}, chunking with max_tokens={max_tokens}" + chunk_texts = [] + for chunk in mps_chunks: + contextualized = chunk.get("contextualized_text") or chunk["chunk_text"] + chunk_records.append( + KnowledgeBaseChunkModel( + document_id=document_id, + organization_id=organization_id, + chunk_text=chunk["chunk_text"], + contextualized_text=contextualized, + chunk_index=chunk["chunk_index"], + chunk_metadata=chunk.get("chunk_metadata") or {}, + embedding_model=embedding_service.get_model_id(), + embedding_dimension=embedding_service.get_embedding_dimension(), + token_count=chunk.get("token_count", 0), + ) ) + chunk_texts.append(contextualized) - start = 0 - chunk_index = 0 - while start < total_tokens: - end = min(start + max_tokens, total_tokens) - chunk_token_ids = tokens[start:end] - chunk_text = hf_tokenizer.decode( - chunk_token_ids, skip_special_tokens=True - ) - - token_count = len(chunk_token_ids) - token_counts.append(token_count) - - chunk_record = KnowledgeBaseChunkModel( - document_id=document_id, - organization_id=organization_id, - chunk_text=chunk_text, - contextualized_text=chunk_text, - chunk_index=chunk_index, - chunk_metadata={}, - embedding_model=service.get_model_id(), - embedding_dimension=service.get_embedding_dimension(), - token_count=token_count, - ) - - chunk_records.append(chunk_record) - chunk_texts.append(chunk_text) - chunk_index += 1 - start = end - - total_chunks = len(chunk_records) - logger.info(f"Generated {total_chunks} chunks from plain text") - - else: - # Use docling for structured formats (PDF, DOCX, etc.) - logger.info("Converting document with docling") - converter = DocumentConverter() - conversion_result = converter.convert(temp_file_path) - doc = conversion_result.document - - docling_metadata = { - "num_pages": len(doc.pages) if hasattr(doc, "pages") else None, - "document_type": type(doc).__name__, - } - - # Initialize chunker - logger.info(f"Initializing HybridChunker with max_tokens={max_tokens}") - chunker = HybridChunker(tokenizer=tokenizer) - - # Chunk the document - logger.info(f"Chunking document with max_tokens={max_tokens}") - chunks = list(chunker.chunk(dl_doc=doc)) - total_chunks = len(chunks) - logger.info(f"Generated {total_chunks} chunks") - - # Process each chunk - for i, chunk in enumerate(chunks): - chunk_text = chunk.text - contextualized_text = chunker.contextualize(chunk=chunk) - - text_to_tokenize = ( - contextualized_text if contextualized_text else chunk_text - ) - token_count = len( - tokenizer.tokenizer.encode( - text_to_tokenize, add_special_tokens=False - ) - ) - token_counts.append(token_count) - - chunk_metadata = {} - if hasattr(chunk, "meta") and chunk.meta: - chunk_metadata = { - "doc_items": ( - [str(item) for item in chunk.meta.doc_items] - if hasattr(chunk.meta, "doc_items") - else [] - ), - "headings": ( - chunk.meta.headings - if hasattr(chunk.meta, "headings") - else [] - ), - } - - chunk_record = KnowledgeBaseChunkModel( - document_id=document_id, - organization_id=organization_id, - chunk_text=chunk_text, - contextualized_text=contextualized_text, - chunk_index=i, - chunk_metadata=chunk_metadata, - embedding_model=service.get_model_id(), - embedding_dimension=service.get_embedding_dimension(), - token_count=token_count, - ) - - chunk_records.append(chunk_record) - chunk_texts.append(text_to_tokenize) - - # Log chunk statistics - if token_counts: - avg_tokens = sum(token_counts) / len(token_counts) - min_tokens = min(token_counts) - max_tokens_actual = max(token_counts) - logger.info("Chunk token statistics:") - logger.info(f" - Average: {avg_tokens:.1f} tokens") - logger.info(f" - Min: {min_tokens} tokens") - logger.info(f" - Max: {max_tokens_actual} tokens") - - # Step 6: Generate embeddings using OpenAI - logger.info(f"Generating embeddings using {service.get_model_id()}") - embeddings = await service.embed_texts(chunk_texts) - - # Step 7: Attach embeddings to chunk records + logger.info( + f"Generating embeddings for {len(chunk_texts)} chunks " + f"using {embedding_service.get_model_id()}" + ) + embeddings = await embedding_service.embed_texts(chunk_texts) for chunk_record, embedding in zip(chunk_records, embeddings): chunk_record.embedding = embedding - # Step 8: Save chunks in database logger.info("Storing chunks in database") await db_client.create_chunks_batch(chunk_records) - # Step 9: Update document status to completed await db_client.update_document_status( document_id, "completed", - total_chunks=total_chunks, + total_chunks=len(chunk_records), docling_metadata=docling_metadata, ) logger.info( f"Successfully processed knowledge base document {document_id}. " - f"Total chunks: {total_chunks}" + f"Total chunks: {len(chunk_records)}" ) except Exception as e: @@ -390,14 +229,12 @@ async def process_knowledge_base_document( f"Error processing knowledge base document {document_id}: {e}", exc_info=True, ) - # Update document status to failed await db_client.update_document_status( document_id, "failed", error_message=str(e) ) raise finally: - # Clean up temp file if temp_file_path and os.path.exists(temp_file_path): try: os.remove(temp_file_path) diff --git a/api/tasks/run_integrations.py b/api/tasks/run_integrations.py index 4a035b5..da87413 100644 --- a/api/tasks/run_integrations.py +++ b/api/tasks/run_integrations.py @@ -5,12 +5,19 @@ from typing import Any, Dict, Optional import httpx from loguru import logger +from pydantic import ValidationError from api.constants import BACKEND_API_ENDPOINT from api.db import db_client from api.db.models import WorkflowRunModel from api.enums import OrganizationConfigurationKey from api.services.pipecat.tracing_config import register_org_langfuse_credentials +from api.services.workflow.dto import ( + QANodeData, + QARFNode, + WebhookNodeData, + WebhookRFNode, +) from api.services.workflow.qa import run_per_node_qa_analysis from api.utils.credential_auth import build_auth_header from api.utils.template_renderer import render_template @@ -19,34 +26,34 @@ from pipecat.utils.run_context import set_current_org_id, set_current_run_id def _should_skip_qa( - node_data: dict, + qa_data: QANodeData, workflow_run: WorkflowRunModel, ) -> str | None: """Check whether QA analysis should be skipped for this call. Returns a reason string if the call should be skipped, or None if it should proceed. """ - # Check minimum call duration - min_duration = node_data.get("qa_min_call_duration", 15) usage_info = workflow_run.usage_info or {} call_duration = usage_info.get("call_duration_seconds") - if call_duration is not None and call_duration < min_duration: - return f"call duration ({call_duration:.1f}s) below minimum ({min_duration}s)" + if call_duration is not None and call_duration < qa_data.qa_min_call_duration: + return ( + f"call duration ({call_duration:.1f}s) below minimum " + f"({qa_data.qa_min_call_duration}s)" + ) - # Check voicemail calls - qa_voicemail_calls = node_data.get("qa_voicemail_calls", False) - if not qa_voicemail_calls: + if not qa_data.qa_voicemail_calls: gathered_context = workflow_run.gathered_context or {} call_disposition = gathered_context.get("call_disposition", "") if call_disposition == EndTaskReason.VOICEMAIL_DETECTED.value: return "voicemail call and QA voicemail calls is disabled" - # Check sample rate - sample_rate = node_data.get("qa_sample_rate", 100) - if sample_rate < 100: + if qa_data.qa_sample_rate < 100: roll = random.randint(1, 100) - if roll > sample_rate: - return f"excluded by sampling ({sample_rate}% sample rate, rolled {roll})" + if roll > qa_data.qa_sample_rate: + return ( + f"excluded by sampling ({qa_data.qa_sample_rate}% sample rate, " + f"rolled {roll})" + ) return None @@ -66,15 +73,22 @@ async def _run_qa_nodes( results: Dict[str, Any] = {} for node in qa_nodes: - node_data = node.get("data", {}) node_id = node.get("id", "unknown") - node_name = node_data.get("name", "QA Analysis") + try: + qa_node = QARFNode.model_validate(node) + except ValidationError as e: + logger.warning(f"QA node #{node_id} failed validation, skipping: {e}") + results[f"qa_{node_id}"] = {"error": "validation_failed"} + continue - if not node_data.get("qa_enabled", True): + qa_data = qa_node.data + node_name = qa_data.name + + if not qa_data.qa_enabled: logger.debug(f"QA node '{node_name}' is disabled, skipping") continue - skip_reason = _should_skip_qa(node_data, workflow_run) + skip_reason = _should_skip_qa(qa_data, workflow_run) if skip_reason: logger.info(f"Skipping QA node '{node_name}' (#{node_id}): {skip_reason}") results[f"qa_{node_id}"] = {"skipped": True, "reason": skip_reason} @@ -83,7 +97,7 @@ async def _run_qa_nodes( try: logger.info(f"Running QA analysis for node '{node_name}' (#{node_id})") result = await run_per_node_qa_analysis( - node_data, + qa_data, workflow_run, workflow_run_id, workflow_definition, @@ -260,7 +274,16 @@ async def run_integrations_post_workflow_run(_ctx, workflow_run_id: int): # Step 8: Execute each webhook node for node in webhook_nodes: - webhook_data = node.get("data", {}) + node_id = node.get("id", "unknown") + try: + webhook_node = WebhookRFNode.model_validate(node) + except ValidationError as e: + logger.warning( + f"Webhook node #{node_id} failed validation, skipping: {e}" + ) + continue + + webhook_data = webhook_node.data try: await _execute_webhook_node( webhook_data=webhook_data, @@ -268,10 +291,7 @@ async def run_integrations_post_workflow_run(_ctx, workflow_run_id: int): organization_id=organization_id, ) except Exception as e: - # Log error but continue with other webhooks - logger.warning( - f"Failed to execute webhook '{webhook_data.get('name', 'unknown')}': {e}" - ) + logger.warning(f"Failed to execute webhook '{webhook_data.name}': {e}") except Exception as e: logger.error(f"Error running integrations: {e}", exc_info=True) @@ -323,7 +343,7 @@ def _build_render_context( async def _execute_webhook_node( - webhook_data: Dict[str, Any], + webhook_data: WebhookNodeData, render_context: Dict[str, Any], organization_id: int, ) -> bool: @@ -331,31 +351,27 @@ async def _execute_webhook_node( Execute a single webhook node. Args: - webhook_data: The webhook node's data dict from workflow definition + webhook_data: The validated webhook node data render_context: Context for template rendering organization_id: For credential lookup Returns: True if successful, False otherwise """ - webhook_name = webhook_data.get("name", "Unnamed Webhook") + webhook_name = webhook_data.name - # 1. Check if enabled - if not webhook_data.get("enabled", True): + if not webhook_data.enabled: logger.debug(f"Webhook '{webhook_name}' is disabled, skipping") return True - # 2. Validate endpoint URL - url = webhook_data.get("endpoint_url") + url = webhook_data.endpoint_url if not url: logger.warning(f"Webhook '{webhook_name}' has no endpoint URL") return False - # 3. Build headers headers = {"Content-Type": "application/json"} - # 4. Add auth header if credential configured - credential_uuid = webhook_data.get("credential_uuid") + credential_uuid = webhook_data.credential_uuid if credential_uuid: credential = await db_client.get_credential_by_uuid( credential_uuid, organization_id @@ -369,18 +385,13 @@ async def _execute_webhook_node( f"Credential {credential_uuid} not found for webhook '{webhook_name}'" ) - # 5. Add custom headers - custom_headers = webhook_data.get("custom_headers", []) - for h in custom_headers: - if h.get("key") and h.get("value"): - headers[h["key"]] = h["value"] + for h in webhook_data.custom_headers or []: + if h.key and h.value: + headers[h.key] = h.value - # 6. Render payload template - payload_template = webhook_data.get("payload_template", {}) - payload = render_template(payload_template, render_context) + payload = render_template(webhook_data.payload_template or {}, render_context) - # 7. Make HTTP request - method = webhook_data.get("http_method", "POST").upper() + method = (webhook_data.http_method or "POST").upper() logger.info(f"Executing webhook '{webhook_name}': {method}") diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 1ed93b1..0056ff2 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -14,14 +14,17 @@ from unittest.mock import Mock import pytest from api.services.workflow.dto import ( + AgentNodeData, + AgentRFNode, EdgeDataDTO, + EndCallNodeData, + EndCallRFNode, ExtractionVariableDTO, - NodeDataDTO, - NodeType, Position, ReactFlowDTO, RFEdgeDTO, - RFNodeDTO, + StartCallNodeData, + StartCallRFNode, VariableType, ) from api.services.workflow.workflow import WorkflowGraph @@ -252,11 +255,10 @@ def simple_workflow() -> WorkflowGraph: """ dto = ReactFlowDTO( nodes=[ - RFNodeDTO( + StartCallRFNode( id="start", - type=NodeType.startNode, position=Position(x=0, y=0), - data=NodeDataDTO( + data=StartCallNodeData( name="Start Call", prompt=START_CALL_SYSTEM_PROMPT, is_start=True, @@ -273,11 +275,10 @@ def simple_workflow() -> WorkflowGraph: ], ), ), - RFNodeDTO( + EndCallRFNode( id="end", - type=NodeType.endNode, position=Position(x=0, y=200), - data=NodeDataDTO( + data=EndCallNodeData( name="End Call", prompt=END_CALL_SYSTEM_PROMPT, is_end=True, @@ -317,11 +318,10 @@ def three_node_workflow() -> WorkflowGraph: """ dto = ReactFlowDTO( nodes=[ - RFNodeDTO( + StartCallRFNode( id="start", - type=NodeType.startNode, position=Position(x=0, y=0), - data=NodeDataDTO( + data=StartCallNodeData( name="Start Call", prompt=START_CALL_SYSTEM_PROMPT, is_start=True, @@ -338,11 +338,10 @@ def three_node_workflow() -> WorkflowGraph: ], ), ), - RFNodeDTO( + AgentRFNode( id="agent", - type=NodeType.agentNode, position=Position(x=0, y=200), - data=NodeDataDTO( + data=AgentNodeData( name="Collect Info", prompt=AGENT_SYSTEM_PROMPT, allow_interrupt=False, @@ -358,11 +357,10 @@ def three_node_workflow() -> WorkflowGraph: ], ), ), - RFNodeDTO( + EndCallRFNode( id="end", - type=NodeType.endNode, position=Position(x=0, y=400), - data=NodeDataDTO( + data=EndCallNodeData( name="End Call", prompt=END_CALL_SYSTEM_PROMPT, is_end=True, @@ -411,11 +409,10 @@ def three_node_workflow_extraction_start_only() -> WorkflowGraph: """ dto = ReactFlowDTO( nodes=[ - RFNodeDTO( + StartCallRFNode( id="start", - type=NodeType.startNode, position=Position(x=0, y=0), - data=NodeDataDTO( + data=StartCallNodeData( name="Start Call", prompt=START_CALL_SYSTEM_PROMPT, is_start=True, @@ -432,11 +429,10 @@ def three_node_workflow_extraction_start_only() -> WorkflowGraph: ], ), ), - RFNodeDTO( + AgentRFNode( id="agent", - type=NodeType.agentNode, position=Position(x=0, y=200), - data=NodeDataDTO( + data=AgentNodeData( name="Collect Info", prompt=AGENT_SYSTEM_PROMPT, allow_interrupt=False, @@ -444,11 +440,10 @@ def three_node_workflow_extraction_start_only() -> WorkflowGraph: extraction_enabled=False, # Explicitly disabled for testing ), ), - RFNodeDTO( + EndCallRFNode( id="end", - type=NodeType.endNode, position=Position(x=0, y=400), - data=NodeDataDTO( + data=EndCallNodeData( name="End Call", prompt=END_CALL_SYSTEM_PROMPT, is_end=True, @@ -493,11 +488,10 @@ def three_node_workflow_no_variable_extraction() -> WorkflowGraph: """ dto = ReactFlowDTO( nodes=[ - RFNodeDTO( + StartCallRFNode( id="start", - type=NodeType.startNode, position=Position(x=0, y=0), - data=NodeDataDTO( + data=StartCallNodeData( name="Start Call", prompt=START_CALL_SYSTEM_PROMPT, is_start=True, @@ -506,11 +500,10 @@ def three_node_workflow_no_variable_extraction() -> WorkflowGraph: extraction_enabled=False, ), ), - RFNodeDTO( + AgentRFNode( id="agent", - type=NodeType.agentNode, position=Position(x=0, y=200), - data=NodeDataDTO( + data=AgentNodeData( name="Collect Info", prompt=AGENT_SYSTEM_PROMPT, allow_interrupt=False, @@ -518,11 +511,10 @@ def three_node_workflow_no_variable_extraction() -> WorkflowGraph: extraction_enabled=False, # Explicitly disabled for testing ), ), - RFNodeDTO( + EndCallRFNode( id="end", - type=NodeType.endNode, position=Position(x=0, y=400), - data=NodeDataDTO( + data=EndCallNodeData( name="End Call", prompt=END_CALL_SYSTEM_PROMPT, is_end=True, diff --git a/api/tests/test_display_options_evaluator.py b/api/tests/test_display_options_evaluator.py new file mode 100644 index 0000000..8a25587 --- /dev/null +++ b/api/tests/test_display_options_evaluator.py @@ -0,0 +1,39 @@ +"""Golden-test parity for the display_options evaluator. + +Both the Python `evaluate_display_options` and the TypeScript +`evaluateDisplayOptions` (in `ui/src/components/flow/renderer/displayOptions.ts`) +must agree on every fixture in `display_options_fixtures.json`. The TS +side is verified by `ui/scripts/test-display-options.mjs`. +""" + +import json +from pathlib import Path + +import pytest + +from api.services.workflow.node_specs import evaluate_display_options + +FIXTURES_PATH = ( + Path(__file__).parent.parent + / "services" + / "workflow" + / "node_specs" + / "display_options_fixtures.json" +) + + +def load_cases(): + with open(FIXTURES_PATH) as f: + return json.load(f)["cases"] + + +@pytest.mark.parametrize("case", load_cases(), ids=lambda c: c["name"]) +def test_python_evaluator_matches_fixture(case): + rules = case["rules"] + values = case["values"] + expected = case["expected"] + actual = evaluate_display_options(rules, values) + assert actual is expected, ( + f"{case['name']}: expected {expected}, got {actual} " + f"for rules={rules!r} values={values!r}" + ) diff --git a/api/tests/test_dograh_sdk.py b/api/tests/test_dograh_sdk.py new file mode 100644 index 0000000..c2598ed --- /dev/null +++ b/api/tests/test_dograh_sdk.py @@ -0,0 +1,235 @@ +"""Tests for the Python runtime SDK (`dograh_sdk`). + +Uses a stub client backed by the in-process spec registry rather than +exercising the HTTP layer — the HTTP client is a thin wrapper that's +easier to test manually against a live server. + +Covers: +- Workflow builder round-trips through ReactFlowDTO validation +- Validation errors fail at the `add()` call site +- from_json preserves node IDs and subsequent add() doesn't collide +- Edge labels / conditions are required +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from dograh_sdk import Workflow +from dograh_sdk._generated_models import NodeSpec +from dograh_sdk.errors import ValidationError + +from api.services.workflow.dto import ReactFlowDTO +from api.services.workflow.node_specs import all_specs, get_spec + + +class _StubClient: + """Stand-in for DograhClient backed by the in-process spec registry. + Matches the real client's contract: `get_node_type(name)` returns a + `NodeSpec` Pydantic model.""" + + def get_node_type(self, name: str) -> NodeSpec: + spec = get_spec(name) + if spec is None: + raise ValueError(f"Unknown spec: {name}") + return NodeSpec.model_validate(spec.model_dump(mode="json")) + + +@pytest.fixture +def client() -> _StubClient: + return _StubClient() + + +# ─── Builder + to_json round-trip ──────────────────────────────────────── + + +def test_builds_minimal_workflow_and_roundtrips_through_dto(client: _StubClient): + wf = Workflow(client=client, name="minimal") + start = wf.add( + type="startCall", + name="greeting", + prompt="Say hi to the caller.", + ) + end = wf.add( + type="endCall", + name="close", + prompt="Thank the caller and hang up.", + ) + wf.edge(start, end, label="done", condition="When the greeting is complete") + + payload = wf.to_json() + # Wire format must validate through the backend Pydantic union — if + # it doesn't, the SDK has silently drifted from the spec schema. + dto = ReactFlowDTO.model_validate(payload) + assert len(dto.nodes) == 2 + assert {n.type for n in dto.nodes} == {"startCall", "endCall"} + assert len(dto.edges) == 1 + + +def test_defaults_applied_from_spec(client: _StubClient): + """Spec defaults (e.g., `allow_interrupt=False` on startCall) fill in + when the user doesn't pass them.""" + wf = Workflow(client=client, name="defaults") + start = wf.add(type="startCall", name="greeting", prompt="hello") + payload = wf.to_json() + data = payload["nodes"][0]["data"] + assert data["allow_interrupt"] is False # spec default + assert data["add_global_prompt"] is True # spec default + _ = start # used implicitly; silence unused + + +def test_webhook_complex_fields_validate(client: _StubClient): + """Webhook's json + fixed_collection (custom_headers) round-trip.""" + wf = Workflow(client=client, name="wh") + wh = wf.add( + type="webhook", + name="notify", + enabled=True, + http_method="POST", + endpoint_url="https://api.example.com/hook", + custom_headers=[{"key": "X-Source", "value": "dograh"}], + payload_template={"run": "{{workflow_run_id}}"}, + ) + payload = wf.to_json() + # Webhook has no incoming/outgoing graph requirements — render as a + # standalone node in the graph for the DTO round-trip. + ReactFlowDTO.model_validate(payload) + _ = wh + + +# ─── Validation errors at call site ────────────────────────────────────── + + +def test_unknown_field_raises_at_add(client: _StubClient): + wf = Workflow(client=client, name="typo") + with pytest.raises(ValidationError, match="unknown field"): + wf.add( + type="startCall", + name="greeting", + prompt="hi", + promt="typo", # extra misspelled field + ) + + +def test_missing_required_raises_at_add(client: _StubClient): + wf = Workflow(client=client, name="missing") + with pytest.raises(ValidationError, match="required field missing"): + wf.add(type="startCall", name="greeting") # no prompt + + +def test_wrong_scalar_type_raises(client: _StubClient): + wf = Workflow(client=client, name="wrongtype") + with pytest.raises(ValidationError, match="expected boolean"): + wf.add( + type="agentNode", + name="x", + prompt="y", + allow_interrupt="yes", + ) + + +def test_invalid_options_value_raises(client: _StubClient): + wf = Workflow(client=client, name="wrongenum") + with pytest.raises(ValidationError, match="not in allowed"): + wf.add( + type="startCall", + name="greeting", + prompt="hi", + greeting_type="video", # only text|audio allowed + ) + + +def test_unknown_node_type_raises(client: _StubClient): + wf = Workflow(client=client, name="x") + with pytest.raises(ValueError, match="Unknown spec"): + wf.add(type="nonExistentType", name="x") + + +def test_validation_error_surfaces_llm_hint(client: _StubClient): + """When a property carries `llm_hint`, it appears in the error message + so LLMs can self-correct on retry. `tool_uuids` on agentNode has the + hint 'List of tool UUIDs from `list_tools`.'""" + wf = Workflow(client=client, name="hint") + with pytest.raises(ValidationError) as exc_info: + wf.add( + type="agentNode", + name="x", + prompt="y", + tool_uuids="single-uuid-not-a-list", # wrong shape: str, not list + ) + msg = str(exc_info.value) + assert "tool_uuids" in msg + assert "Hint:" in msg + assert "list_tools" in msg + + +def test_no_hint_message_when_spec_has_none(client: _StubClient): + """Properties without `llm_hint` produce a plain error (no dangling + 'Hint:' line).""" + wf = Workflow(client=client, name="no-hint") + with pytest.raises(ValidationError) as exc_info: + wf.add(type="agentNode", name="x", prompt="y", allow_interrupt="yes") + assert "Hint:" not in str(exc_info.value) + + +def test_edge_requires_label_and_condition(client: _StubClient): + wf = Workflow(client=client, name="edge") + a = wf.add(type="startCall", name="a", prompt="hi") + b = wf.add(type="endCall", name="b", prompt="bye") + with pytest.raises(ValidationError, match="label is required"): + wf.edge(a, b, label="", condition="condition") + with pytest.raises(ValidationError, match="condition is required"): + wf.edge(a, b, label="label", condition="") + + +# ─── Round-trip from_json → edit → to_json ──────────────────────────────── + + +def test_from_json_preserves_ids_and_next_id_doesnt_collide(client: _StubClient): + wf0 = Workflow(client=client, name="w0") + start = wf0.add(type="startCall", name="g", prompt="hi") + end = wf0.add(type="endCall", name="e", prompt="bye") + wf0.edge(start, end, label="done", condition="done") + + payload = wf0.to_json() + wf1 = Workflow.from_json(payload, client=client, name="w0-reload") + + # IDs are preserved + assert [n.id for n in wf1._nodes] == [start.id, end.id] + # Next add() gets a fresh ID, not colliding with the existing ones + new_ref = wf1.add(type="agentNode", name="qualify", prompt="ask stuff") + assert new_ref.id != start.id + assert new_ref.id != end.id + assert int(new_ref.id) > max(int(start.id), int(end.id)) + + +def test_from_json_validates_data(client: _StubClient): + """Loading a JSON payload with a misnamed field raises — we don't + silently accept drift.""" + bad = { + "nodes": [ + { + "id": "1", + "type": "startCall", + "position": {"x": 0, "y": 0}, + "data": {"name": "g", "prompt": "hi", "bogus": 1}, + } + ], + "edges": [], + } + with pytest.raises(ValidationError, match="unknown field"): + Workflow.from_json(bad, client=client) + + +# ─── Sanity: all registered specs are reachable by name ─────────────────── + + +def test_every_registered_spec_is_reachable_by_sdk(client: _StubClient): + wf = Workflow(client=client, name="probe") + for spec in all_specs(): + # Just fetch the spec via the client; doesn't add anything. This + # ensures the `_StubClient` wiring works for all types. + probe = client.get_node_type(spec.name) + assert probe.name == spec.name + _ = wf diff --git a/api/tests/test_dograh_sdk_typed.py b/api/tests/test_dograh_sdk_typed.py new file mode 100644 index 0000000..ce20101 --- /dev/null +++ b/api/tests/test_dograh_sdk_typed.py @@ -0,0 +1,128 @@ +"""Tests for the typed SDK (`dograh_sdk.typed`). + +Covers: +- Generated classes import cleanly and declare the correct spec name +- `Workflow.add_typed(node)` produces the same wire format as + `Workflow.add(type=..., **kwargs)` +- Typed-class construction respects required/optional field defaults +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from dograh_sdk import Workflow +from dograh_sdk._generated_models import NodeSpec +from dograh_sdk.typed import ( + AgentNode, + EndCall, + GlobalNode, + Qa, + StartCall, + Trigger, + TypedNode, + Webhook, +) + +from api.services.workflow.dto import ReactFlowDTO +from api.services.workflow.node_specs import get_spec + + +class _StubClient: + def get_node_type(self, name: str) -> NodeSpec: + return NodeSpec.model_validate(get_spec(name).model_dump(mode="json")) + + +@pytest.fixture +def client() -> _StubClient: + return _StubClient() + + +# ─── Generated classes declare the correct discriminator ────────────────── + + +@pytest.mark.parametrize( + "cls,expected_type", + [ + (StartCall, "startCall"), + (AgentNode, "agentNode"), + (EndCall, "endCall"), + (GlobalNode, "globalNode"), + (Trigger, "trigger"), + (Webhook, "webhook"), + (Qa, "qa"), + ], + ids=lambda v: v.__name__ if isinstance(v, type) else v, +) +def test_typed_class_declares_spec_name(cls: type[TypedNode], expected_type: str): + assert cls.type == expected_type + # Instances inherit the ClassVar + if cls is StartCall: + inst = cls(name="g", prompt="hi") + elif cls is AgentNode: + inst = cls(name="a", prompt="hi") + elif cls is EndCall: + inst = cls(name="e", prompt="hi") + elif cls is GlobalNode: + inst = cls(name="g", prompt="hi") + elif cls is Trigger: + inst = cls(name="t") + elif cls is Webhook: + inst = cls(name="wh") + else: # Qa + inst = cls(name="qa") + assert inst.type == expected_type + + +# ─── add_typed integrates with Workflow and round-trips through DTO ────── + + +def test_add_typed_builds_valid_workflow(client: _StubClient): + wf = Workflow(client=client, name="typed-e2e") + start = wf.add_typed(StartCall(name="greeting", prompt="Hi there!")) + end = wf.add_typed(EndCall(name="done", prompt="Bye.")) + wf.edge(start, end, label="done", condition="conversation over") + + payload = wf.to_json() + dto = ReactFlowDTO.model_validate(payload) + assert len(dto.nodes) == 2 + assert payload["nodes"][0]["type"] == "startCall" + assert payload["nodes"][1]["type"] == "endCall" + + +def test_add_typed_and_add_produce_identical_data(client: _StubClient): + """The typed path and the generic path should produce identical node + data for equivalent inputs.""" + wf_typed = Workflow(client=client) + wf_typed.add_typed(AgentNode(name="q", prompt="ask")) + + wf_generic = Workflow(client=client) + wf_generic.add(type="agentNode", name="q", prompt="ask") + + typed_data = wf_typed.to_json()["nodes"][0]["data"] + generic_data = wf_generic.to_json()["nodes"][0]["data"] + assert typed_data == generic_data + + +def test_webhook_mutable_defaults_dont_share_state(client: _StubClient): + """Dataclass default_factory ensures every Webhook() gets its own dict.""" + wf = Workflow(client=client) + a = wf.add_typed(Webhook(name="a")) + b = wf.add_typed(Webhook(name="b")) + payload = wf.to_json() + a_data = payload["nodes"][0]["data"] + b_data = payload["nodes"][1]["data"] + # Both instances must end up with payload_template populated from the + # factory; mutating one must not affect the other. + assert a_data["payload_template"] is not b_data["payload_template"] + _ = a, b + + +def test_typed_sdk_surfaces_spec_default_to_field(client: _StubClient): + """Spec defaults make it all the way through: StartCall().name defaults + to the spec's `"Start Call"` literal.""" + s = StartCall(prompt="hi") + assert s.name == "Start Call" + assert s.allow_interrupt is False # matches spec default from earlier edits + assert s.add_global_prompt is True diff --git a/api/tests/test_dto.py b/api/tests/test_dto.py index 5ded56e..aa4aabd 100644 --- a/api/tests/test_dto.py +++ b/api/tests/test_dto.py @@ -1,11 +1,98 @@ +from pathlib import Path + import pytest -from api.services.workflow.dto import ReactFlowDTO +from api.services.workflow.dto import ReactFlowDTO, sanitize_workflow_definition + +_FIXTURES_DIR = Path(__file__).parent / "definitions" @pytest.mark.asyncio async def test_dto(): - # assert no exceptions are raised - with open("tests/definitions/rf-1.json", "r") as f: + # Path resolved relative to this test file so the test works regardless + # of the cwd pytest is invoked from. + with open(_FIXTURES_DIR / "rf-1.json", "r") as f: dto = ReactFlowDTO.model_validate_json(f.read()) assert dto is not None + + +def test_sanitize_strips_ui_runtime_fields(): + definition = { + "viewport": {"x": 0, "y": 0, "zoom": 1}, + "nodes": [ + { + "id": "n1", + "type": "startCall", + "position": {"x": 0, "y": 0}, + "width": 200, # ReactFlow-computed, preserved + "selected": True, # ReactFlow runtime, preserved + "data": { + "name": "Start", + "prompt": "hi", + "greeting": "hello", + "invalid": True, # UI-only, should be stripped + "validationMessage": "oops", # UI-only, should be stripped + "mystery_field": 42, # unknown, should be stripped + }, + }, + { + "id": "n2", + "type": "agentNode", + "position": {"x": 1, "y": 1}, + "data": {"name": "A", "prompt": "p", "invalid": False}, + }, + ], + "edges": [ + { + "id": "e1", + "source": "n1", + "target": "n2", + "data": { + "label": "next", + "condition": "true", + "invalid": True, # UI-only, should be stripped + }, + } + ], + } + + out = sanitize_workflow_definition(definition) + + # Top-level keys preserved + assert out["viewport"] == {"x": 0, "y": 0, "zoom": 1} + # ReactFlow runtime fields on the node itself preserved + assert out["nodes"][0]["width"] == 200 + assert out["nodes"][0]["selected"] is True + + # node.data stripped of unknowns, known fields kept + n1_data = out["nodes"][0]["data"] + assert n1_data == {"name": "Start", "prompt": "hi", "greeting": "hello"} + assert "invalid" not in n1_data + assert "validationMessage" not in n1_data + assert "mystery_field" not in n1_data + + n2_data = out["nodes"][1]["data"] + assert n2_data == {"name": "A", "prompt": "p"} + + # edge.data stripped + assert out["edges"][0]["data"] == {"label": "next", "condition": "true"} + + +def test_sanitize_noop_on_empty_and_unknown_types(): + assert sanitize_workflow_definition(None) is None + assert sanitize_workflow_definition({}) == {} + + # Unknown node type: pass through unchanged rather than wipe data + definition = { + "nodes": [ + { + "id": "n1", + "type": "unknownType", + "position": {"x": 0, "y": 0}, + "data": {"anything": "goes"}, + } + ], + "edges": [], + } + out = sanitize_workflow_definition(definition) + assert out["nodes"][0]["data"] == {"anything": "goes"} diff --git a/api/tests/test_layout.py b/api/tests/test_layout.py new file mode 100644 index 0000000..6886506 --- /dev/null +++ b/api/tests/test_layout.py @@ -0,0 +1,124 @@ +"""Tests for position reconciliation after the LLM save round-trip.""" + +from __future__ import annotations + +from api.services.workflow.layout import reconcile_positions + + +def _node( + id: str, + type: str, + *, + name: str | None = None, + x: float = 0.0, + y: float = 0.0, +) -> dict: + data: dict = {} + if name is not None: + data["name"] = name + return {"id": id, "type": type, "position": {"x": x, "y": y}, "data": data} + + +def _edge(src: str, tgt: str) -> dict: + return { + "id": f"{src}-{tgt}", + "source": src, + "target": tgt, + "data": {"label": "x", "condition": "y"}, + } + + +def test_named_match_preserves_position(): + previous = { + "nodes": [_node("99", "startCall", name="greeting", x=100, y=200)], + "edges": [], + } + new = { + "nodes": [_node("1", "startCall", name="greeting")], + "edges": [], + } + out = reconcile_positions(new, previous) + assert out["nodes"][0]["position"] == {"x": 100, "y": 200} + + +def test_unnamed_match_by_type_ordering(): + previous = { + "nodes": [ + _node("7", "agentNode", x=-648, y=-158), + _node("8", "agentNode", x=500, y=-100), + ], + "edges": [], + } + new = { + "nodes": [ + _node("1", "agentNode"), + _node("2", "agentNode"), + ], + "edges": [], + } + out = reconcile_positions(new, previous) + assert out["nodes"][0]["position"] == {"x": -648, "y": -158} + assert out["nodes"][1]["position"] == {"x": 500, "y": -100} + + +def test_new_node_placed_relative_to_incoming_neighbor(): + previous = { + "nodes": [_node("99", "startCall", name="greeting", x=100, y=200)], + "edges": [], + } + new = { + "nodes": [ + _node("1", "startCall", name="greeting"), + _node("2", "agentNode", name="new_node"), + ], + "edges": [_edge("1", "2")], + } + out = reconcile_positions(new, previous) + # Start call keeps its previous position. + assert out["nodes"][0]["position"] == {"x": 100, "y": 200} + # New node offset from its incoming neighbor. + assert out["nodes"][1]["position"] == {"x": 500, "y": 400} + + +def test_orphan_new_node_stays_at_origin(): + new = { + "nodes": [_node("1", "agentNode", name="orphan")], + "edges": [], + } + out = reconcile_positions(new, None) + assert out["nodes"][0]["position"] == {"x": 0.0, "y": 0.0} + + +def test_named_wins_over_unnamed_ordering(): + previous = { + "nodes": [ + _node("7", "agentNode", x=-648, y=-158), # unnamed + _node("8", "agentNode", name="qualify", x=900, y=900), + ], + "edges": [], + } + new = { + "nodes": [ + _node("1", "agentNode", name="qualify"), # matches named + _node("2", "agentNode"), # falls to unnamed queue + ], + "edges": [], + } + out = reconcile_positions(new, previous) + assert out["nodes"][0]["position"] == {"x": 900, "y": 900} + assert out["nodes"][1]["position"] == {"x": -648, "y": -158} + + +def test_no_previous_keeps_origin_for_all_matched_positions(): + new = { + "nodes": [ + _node("1", "startCall", name="greeting"), + _node("2", "agentNode", name="reply"), + ], + "edges": [_edge("1", "2")], + } + out = reconcile_positions(new, None) + # No previous → first node stays at origin (no incoming), second + # node placed relative to its incoming neighbor at origin. + assert out["nodes"][0]["position"] == {"x": 0.0, "y": 0.0} + assert out["nodes"][1]["position"] == {"x": 400.0, "y": 200.0} diff --git a/api/tests/test_mcp_save_workflow.py b/api/tests/test_mcp_save_workflow.py new file mode 100644 index 0000000..3dbd703 --- /dev/null +++ b/api/tests/test_mcp_save_workflow.py @@ -0,0 +1,225 @@ +"""Integration tests for the `save_workflow` MCP tool. + +Mocks `authenticate_mcp_request` and the db_client so tests don't need +a live DB, but exercises the real TS validator subprocess end-to-end — +parse is part of the contract the LLM relies on. + +Round-trip and pure-parser tests live in `test_ts_bridge.py`; this file +focuses on the MCP tool's error-routing, version tagging, and DB-call +shape. +""" + +from __future__ import annotations + +import shutil +from dataclasses import dataclass +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException + +from api.mcp_server.tools.save_workflow import save_workflow + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None, reason="node binary not available" +) + + +# ─── Fixtures & helpers ────────────────────────────────────────────────── + + +@dataclass +class _FakeDraft: + version_number: int = 2 + status: str = "draft" + + +class _FakeWorkflowModel: + id = 1 + organization_id = 1 + name = "test" + # reconcile_positions reads whichever of these holds the previous + # stored workflow JSON; None on all three is fine for a greenfield + # test and causes reconcile_positions to fall back to the placement + # heuristic for any new node. + current_definition = None + released_definition = None + workflow_definition = None + + +@pytest.fixture +def authed_user() -> MagicMock: + user = MagicMock() + user.selected_organization_id = 1 + user.id = 1 + return user + + +@pytest.fixture +def mock_backends(authed_user: MagicMock): + save_mock = AsyncMock(return_value=_FakeDraft()) + update_mock = AsyncMock(return_value=_FakeWorkflowModel()) + with ( + patch( + "api.mcp_server.tools.save_workflow.authenticate_mcp_request", + AsyncMock(return_value=authed_user), + ), + patch( + "api.mcp_server.tools.save_workflow.db_client.get_workflow", + AsyncMock(return_value=_FakeWorkflowModel()), + ), + patch( + "api.mcp_server.tools.save_workflow.db_client.save_workflow_draft", + save_mock, + ), + patch( + "api.mcp_server.tools.save_workflow.db_client.update_workflow", + update_mock, + ), + patch( + "api.mcp_server.tools.save_workflow.db_client.get_draft_version", + AsyncMock(return_value=None), + ), + ): + yield save_mock, update_mock + + +def _valid_code(name: str = "tool-test") -> str: + return f'''import {{ Workflow }} from "@dograh/sdk"; +import {{ startCall, endCall }} from "@dograh/sdk/typed"; + +const wf = new Workflow({{ name: "{name}" }}); + +const greeting = wf.addTyped(startCall({{ name: "greeting", prompt: "Hi!" }})); +const done = wf.addTyped(endCall({{ name: "done", prompt: "Bye." }})); + +wf.edge(greeting, done, {{ label: "done", condition: "conversation complete" }}); +''' + + +# ─── Happy path ────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_happy_path_saves_draft(mock_backends): + save_mock, update_mock = mock_backends + # Match the stored name so the rename branch stays dormant here. + result = await save_workflow( + workflow_id=1, code=_valid_code(name=_FakeWorkflowModel.name) + ) + assert result["saved"] is True + assert result["workflow_id"] == 1 + assert result["version_number"] == 2 + assert result["status"] == "draft" + assert result["node_count"] == 2 + assert result["edge_count"] == 1 + assert result["renamed"] is False + assert result["name"] == _FakeWorkflowModel.name + save_mock.assert_awaited_once() + update_mock.assert_not_awaited() + payload = save_mock.call_args.kwargs["workflow_definition"] + assert len(payload["nodes"]) == 2 + assert len(payload["edges"]) == 1 + + +@pytest.mark.asyncio +async def test_rename_propagates_to_update_workflow(mock_backends): + save_mock, update_mock = mock_backends + result = await save_workflow(workflow_id=1, code=_valid_code(name="renamed")) + assert result["saved"] is True + assert result["renamed"] is True + assert result["name"] == "renamed" + update_mock.assert_awaited_once() + kwargs = update_mock.call_args.kwargs + assert kwargs["workflow_id"] == 1 + assert kwargs["name"] == "renamed" + assert kwargs["workflow_definition"] is None + assert kwargs["organization_id"] == 1 + save_mock.assert_awaited_once() + + +# ─── Parse-stage rejections ────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_parser_rejects_disallowed_top_level(mock_backends): + save_mock, update_mock = mock_backends + code = _valid_code() + "function evil() { return 1; }\n" + result = await save_workflow(workflow_id=1, code=code) + assert result["saved"] is False + assert result["error_code"] == "parse_error" + save_mock.assert_not_awaited() + update_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_parser_rejects_unknown_factory(mock_backends): + save_mock, update_mock = mock_backends + code = """import { Workflow } from "@dograh/sdk"; +const wf = new Workflow({ name: "x" }); +const n = wf.addTyped(fakeNode({ name: "x", prompt: "y" })); +""" + result = await save_workflow(workflow_id=1, code=code) + assert result["saved"] is False + assert result["error_code"] == "parse_error" + assert "Unknown node type" in result["error"] + save_mock.assert_not_awaited() + update_mock.assert_not_awaited() + + +# ─── Validation-stage rejections ───────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_unknown_field_surfaces_validation_error(mock_backends): + save_mock, update_mock = mock_backends + code = """import { Workflow } from "@dograh/sdk"; +import { startCall } from "@dograh/sdk/typed"; +const wf = new Workflow({ name: "x" }); +const n = wf.addTyped(startCall({ name: "g", prompt: "hi", promt: "typo" })); +""" + result = await save_workflow(workflow_id=1, code=code) + assert result["saved"] is False + assert result["error_code"] == "validation_error" + assert "Unknown field" in result["error"] + save_mock.assert_not_awaited() + update_mock.assert_not_awaited() + + +# ─── Graph-stage rejections ────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_graph_validation_catches_missing_start_node(mock_backends): + save_mock, update_mock = mock_backends + # Only an end node — WorkflowGraph requires exactly one start node. + code = """import { Workflow } from "@dograh/sdk"; +import { endCall } from "@dograh/sdk/typed"; +const wf = new Workflow({ name: "orphan" }); +const only = wf.addTyped(endCall({ name: "only", prompt: "bye" })); +""" + result = await save_workflow(workflow_id=1, code=code) + assert result["saved"] is False + assert result["error_code"] == "graph_validation" + save_mock.assert_not_awaited() + update_mock.assert_not_awaited() + + +# ─── Workflow not found / unauthorized ─────────────────────────────────── + + +@pytest.mark.asyncio +async def test_unknown_workflow_raises_404(authed_user: MagicMock): + with ( + patch( + "api.mcp_server.tools.save_workflow.authenticate_mcp_request", + AsyncMock(return_value=authed_user), + ), + patch( + "api.mcp_server.tools.save_workflow.db_client.get_workflow", + AsyncMock(return_value=None), + ), + ): + with pytest.raises(HTTPException) as exc_info: + await save_workflow(workflow_id=999, code=_valid_code()) + assert exc_info.value.status_code == 404 diff --git a/api/tests/test_node_specs.py b/api/tests/test_node_specs.py new file mode 100644 index 0000000..110927b --- /dev/null +++ b/api/tests/test_node_specs.py @@ -0,0 +1,196 @@ +"""Spec-quality lint. + +Catches drift between NodeSpecs and the rest of the system before it lands: +- Placeholder/empty descriptions +- Missing examples +- display_options referencing fields that don't exist +- Examples that don't validate against the per-type Pydantic DTO +- Spec name not matching a discriminator value in dto.py +""" + +from __future__ import annotations + +import re + +import pytest + +from api.services.workflow.dto import NodeType, ReactFlowDTO +from api.services.workflow.node_specs import ( + NodeSpec, + PropertySpec, + PropertyType, + all_specs, +) + +PLACEHOLDER_DESCRIPTION_PATTERN = re.compile( + r"^\s*(todo|fixme|tbd|xxx|\.\.\.|placeholder|description|n/?a|\?)\s*\.?\s*$", + re.IGNORECASE, +) + + +def _walk_properties(props: list[PropertySpec], path: str = ""): + """Yield (full_path, property) for every property and nested sub-property.""" + for prop in props: + full_path = f"{path}.{prop.name}" if path else prop.name + yield full_path, prop + if prop.properties: + yield from _walk_properties(prop.properties, full_path) + + +# ───────────────────────────────────────────────────────────────────────── +# Lint +# ───────────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name) +def test_node_spec_has_non_placeholder_description(spec: NodeSpec): + assert spec.description.strip(), f"{spec.name}: empty description" + assert not PLACEHOLDER_DESCRIPTION_PATTERN.match(spec.description), ( + f"{spec.name}: description looks like a placeholder: {spec.description!r}" + ) + assert len(spec.description) >= 20, ( + f"{spec.name}: description too short to be useful for an LLM " + f"({len(spec.description)} chars)" + ) + + +@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name) +def test_node_spec_has_at_least_one_example(spec: NodeSpec): + assert spec.examples, ( + f"{spec.name}: must have at least one NodeExample so LLMs have a " + f"realistic shape to pattern-match." + ) + + +@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name) +def test_property_descriptions_non_placeholder(spec: NodeSpec): + for path, prop in _walk_properties(spec.properties): + assert prop.description.strip(), f"{spec.name}.{path}: empty description" + assert not PLACEHOLDER_DESCRIPTION_PATTERN.match(prop.description), ( + f"{spec.name}.{path}: description looks like a placeholder: " + f"{prop.description!r}" + ) + + +@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name) +def test_display_options_reference_real_fields(spec: NodeSpec): + """A property's display_options must only reference sibling property + names. Nested properties are scoped to their parent's siblings.""" + + def _check(scope_props: list[PropertySpec], scope_path: str = ""): + names_in_scope = {p.name for p in scope_props} + for prop in scope_props: + current_path = f"{scope_path}.{prop.name}" if scope_path else prop.name + if prop.display_options: + refs = set((prop.display_options.show or {}).keys()) | set( + (prop.display_options.hide or {}).keys() + ) + missing = refs - names_in_scope + assert not missing, ( + f"{spec.name}.{current_path}: display_options references " + f"unknown sibling fields: {sorted(missing)}" + ) + if prop.properties: + _check(prop.properties, current_path) + + _check(spec.properties) + + +@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name) +def test_options_properties_have_options(spec: NodeSpec): + for path, prop in _walk_properties(spec.properties): + if prop.type in (PropertyType.options, PropertyType.multi_options): + assert prop.options, ( + f"{spec.name}.{path}: type={prop.type.value} requires at " + f"least one PropertyOption." + ) + + +@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name) +def test_fixed_collection_has_sub_properties(spec: NodeSpec): + for path, prop in _walk_properties(spec.properties): + if prop.type == PropertyType.fixed_collection: + assert prop.properties, ( + f"{spec.name}.{path}: fixed_collection requires nested " + f"`properties` describing each row." + ) + + +@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name) +def test_spec_name_matches_dto_discriminator(spec: NodeSpec): + valid_names = {t.value for t in NodeType} + assert spec.name in valid_names, ( + f"NodeSpec {spec.name!r} doesn't match any NodeType discriminator. " + f"Valid: {sorted(valid_names)}" + ) + + +@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name) +def test_examples_validate_against_dto(spec: NodeSpec): + """Each NodeExample.data must pass per-type DTO validation. This stops + examples from drifting away from the actual wire schema.""" + for ex in spec.examples: + wire_node = { + "id": "example", + "type": spec.name, + "position": {"x": 0, "y": 0}, + "data": ex.data, + } + # Build a minimal valid graph: example node plus a synthetic peer if + # graph_constraints require an incoming or outgoing edge. + nodes = [wire_node] + edges: list[dict] = [] + constraints = spec.graph_constraints + + if constraints and (constraints.min_outgoing or 0) > 0: + nodes.append( + { + "id": "downstream", + "type": "endCall", + "position": {"x": 0, "y": 0}, + "data": {"name": "End", "prompt": "End", "is_end": True}, + } + ) + edges.append( + { + "id": "e_out", + "source": "example", + "target": "downstream", + "data": {"label": "next", "condition": "next"}, + } + ) + + if constraints and (constraints.min_incoming or 0) > 0: + nodes.append( + { + "id": "upstream", + "type": "startCall", + "position": {"x": 0, "y": 0}, + "data": { + "name": "Start", + "prompt": "Hello", + "is_start": True, + }, + } + ) + edges.append( + { + "id": "e_in", + "source": "upstream", + "target": "example", + "data": {"label": "in", "condition": "in"}, + } + ) + + # Validate. If this raises, the example is broken. + ReactFlowDTO.model_validate({"nodes": nodes, "edges": edges}) + + +def test_all_dto_types_have_specs(): + """Every NodeType discriminator value must have a registered NodeSpec — + catches the case where someone adds a new node type to dto.py but + forgets to author a spec.""" + spec_names = {s.name for s in all_specs()} + type_values = {t.value for t in NodeType} + missing = type_values - spec_names + assert not missing, f"NodeType discriminators without specs: {sorted(missing)}" diff --git a/api/tests/test_pipecat_engine_end_call.py b/api/tests/test_pipecat_engine_end_call.py index 7ef7ffe..1384150 100644 --- a/api/tests/test_pipecat_engine_end_call.py +++ b/api/tests/test_pipecat_engine_end_call.py @@ -27,12 +27,13 @@ import pytest from api.enums import ToolCategory from api.services.workflow.dto import ( EdgeDataDTO, - NodeDataDTO, - NodeType, + EndCallNodeData, + EndCallRFNode, Position, ReactFlowDTO, RFEdgeDTO, - RFNodeDTO, + StartCallNodeData, + StartCallRFNode, ) from api.services.workflow.pipecat_engine import PipecatEngine from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager @@ -1013,11 +1014,10 @@ class TestEndCallExtractionBehavior: # Create a workflow where start node has NO extraction dto = ReactFlowDTO( nodes=[ - RFNodeDTO( + StartCallRFNode( id="start", - type=NodeType.startNode, position=Position(x=0, y=0), - data=NodeDataDTO( + data=StartCallNodeData( name="Start Call", prompt=START_CALL_SYSTEM_PROMPT, is_start=True, @@ -1026,11 +1026,10 @@ class TestEndCallExtractionBehavior: extraction_enabled=False, # No extraction ), ), - RFNodeDTO( + EndCallRFNode( id="end", - type=NodeType.endNode, position=Position(x=0, y=200), - data=NodeDataDTO( + data=EndCallNodeData( name="End Call", prompt=END_CALL_SYSTEM_PROMPT, is_end=True, diff --git a/api/tests/test_sdk_sync.py b/api/tests/test_sdk_sync.py new file mode 100644 index 0000000..eb5f2ee --- /dev/null +++ b/api/tests/test_sdk_sync.py @@ -0,0 +1,99 @@ +"""Drift guard: committed SDK typed files must match what codegen +produces from the current `node_specs/` registry. + +Fails loudly if a spec was edited without running +`./scripts/generate_sdk.sh`. CI also runs the full script and asserts +an empty `git diff` as the authoritative cross-language check; this +test is the fast local feedback loop inside pytest. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +# Ensure the Python SDK package is importable without requiring a +# `pip install -e sdk/python`. The codegen lives there because it ships +# with the SDK wheel, but tests need to reach it directly. +REPO_ROOT = Path(__file__).resolve().parents[2] +SDK_PY_SRC = REPO_ROOT / "sdk" / "python" / "src" +if str(SDK_PY_SRC) not in sys.path: + sys.path.insert(0, str(SDK_PY_SRC)) + +from dograh_sdk.codegen import generate_all # noqa: E402 + +from api.services.workflow.node_specs import SPEC_VERSION, all_specs # noqa: E402 + +PY_OUT = REPO_ROOT / "sdk" / "python" / "src" / "dograh_sdk" / "typed" +TS_OUT = REPO_ROOT / "sdk" / "typescript" / "src" / "typed" +TS_CODEGEN = REPO_ROOT / "sdk" / "typescript" / "scripts" / "codegen.mts" +REGEN_HINT = "Run ./scripts/generate_sdk.sh to regenerate." + + +def _specs_payload() -> dict: + return { + "spec_version": SPEC_VERSION, + "node_types": [s.model_dump(mode="json") for s in all_specs()], + } + + +def _compare_trees(expected_dir: Path, actual_dir: Path, *, skip: set[str]) -> None: + def tree(d: Path) -> dict[str, str]: + return { + p.name: p.read_text() + for p in d.iterdir() + if p.is_file() and p.name not in skip + } + + expected = tree(expected_dir) + actual = tree(actual_dir) + + if expected.keys() != actual.keys(): + pytest.fail( + f"File set differs in {expected_dir.name}/.\n" + f" committed: {sorted(expected)}\n" + f" generated: {sorted(actual)}\n" + f"{REGEN_HINT}" + ) + for name in sorted(expected): + if expected[name] != actual[name]: + pytest.fail( + f"{expected_dir.name}/{name} is out of sync with node_specs. " + f"{REGEN_HINT}" + ) + + +def test_python_sdk_typed_in_sync(tmp_path: Path) -> None: + specs = _specs_payload()["node_types"] + generate_all(specs, tmp_path) + # _base.py is hand-written and lives alongside generated files. + _compare_trees(PY_OUT, tmp_path, skip={"_base.py", "__pycache__"}) + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node binary not available") +def test_typescript_sdk_typed_in_sync(tmp_path: Path) -> None: + specs_file = tmp_path / "specs.json" + specs_file.write_text(json.dumps(_specs_payload())) + out = tmp_path / "ts_out" + + result = subprocess.run( + [ + "node", + str(TS_CODEGEN), + "--input", + str(specs_file), + "--out", + str(out), + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"TS codegen failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + _compare_trees(TS_OUT, out, skip=set()) diff --git a/api/tests/test_text_and_audio_playback.py b/api/tests/test_text_and_audio_playback.py index 3a8b1a6..6330fa4 100644 --- a/api/tests/test_text_and_audio_playback.py +++ b/api/tests/test_text_and_audio_playback.py @@ -15,12 +15,13 @@ import pytest from api.services.pipecat.recording_audio_cache import RecordingAudio from api.services.workflow.dto import ( EdgeDataDTO, - NodeDataDTO, - NodeType, + EndCallNodeData, + EndCallRFNode, Position, ReactFlowDTO, RFEdgeDTO, - RFNodeDTO, + StartCallNodeData, + StartCallRFNode, ) from api.services.workflow.pipecat_engine import PipecatEngine from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager @@ -64,11 +65,10 @@ def text_workflow() -> WorkflowGraph: """Start->End workflow with text greeting and text transition speech.""" dto = ReactFlowDTO( nodes=[ - RFNodeDTO( + StartCallRFNode( id="start", - type=NodeType.startNode, position=Position(x=0, y=0), - data=NodeDataDTO( + data=StartCallNodeData( name="Start Call", prompt=START_PROMPT, is_start=True, @@ -79,11 +79,10 @@ def text_workflow() -> WorkflowGraph: extraction_enabled=False, ), ), - RFNodeDTO( + EndCallRFNode( id="end", - type=NodeType.endNode, position=Position(x=0, y=200), - data=NodeDataDTO( + data=EndCallNodeData( name="End Call", prompt=END_PROMPT, is_end=True, @@ -115,11 +114,10 @@ def audio_workflow() -> WorkflowGraph: """Start->End workflow with audio greeting and audio transition speech.""" dto = ReactFlowDTO( nodes=[ - RFNodeDTO( + StartCallRFNode( id="start", - type=NodeType.startNode, position=Position(x=0, y=0), - data=NodeDataDTO( + data=StartCallNodeData( name="Start Call", prompt=START_PROMPT, is_start=True, @@ -130,11 +128,10 @@ def audio_workflow() -> WorkflowGraph: extraction_enabled=False, ), ), - RFNodeDTO( + EndCallRFNode( id="end", - type=NodeType.endNode, position=Position(x=0, y=200), - data=NodeDataDTO( + data=EndCallNodeData( name="End Call", prompt=END_PROMPT, is_end=True, @@ -293,11 +290,10 @@ class TestStartGreeting: """No greeting configured should return None.""" dto = ReactFlowDTO( nodes=[ - RFNodeDTO( + StartCallRFNode( id="start", - type=NodeType.startNode, position=Position(x=0, y=0), - data=NodeDataDTO( + data=StartCallNodeData( name="Start", prompt="Prompt", is_start=True, @@ -305,11 +301,10 @@ class TestStartGreeting: extraction_enabled=False, ), ), - RFNodeDTO( + EndCallRFNode( id="end", - type=NodeType.endNode, position=Position(x=0, y=200), - data=NodeDataDTO( + data=EndCallNodeData( name="End", prompt="End", is_end=True, @@ -338,11 +333,10 @@ class TestStartGreeting: """Text greeting with {{variable}} placeholders should be rendered.""" dto = ReactFlowDTO( nodes=[ - RFNodeDTO( + StartCallRFNode( id="start", - type=NodeType.startNode, position=Position(x=0, y=0), - data=NodeDataDTO( + data=StartCallNodeData( name="Start", prompt="Prompt", is_start=True, @@ -352,11 +346,10 @@ class TestStartGreeting: extraction_enabled=False, ), ), - RFNodeDTO( + EndCallRFNode( id="end", - type=NodeType.endNode, position=Position(x=0, y=200), - data=NodeDataDTO( + data=EndCallNodeData( name="End", prompt="End", is_end=True, diff --git a/api/tests/test_ts_bridge.py b/api/tests/test_ts_bridge.py new file mode 100644 index 0000000..723fe4d --- /dev/null +++ b/api/tests/test_ts_bridge.py @@ -0,0 +1,275 @@ +"""End-to-end tests for the Node TS validator bridge. + +Exercises the real `node` subprocess — slow-ish but the whole point is +that code → JSON and JSON → code round-trip losslessly. +""" + +from __future__ import annotations + +import shutil + +import pytest + +from api.mcp_server.ts_bridge import TsBridgeError, generate_code, parse_code + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None, reason="node binary not available" +) + + +def _minimal_workflow() -> dict: + """Start → End, one edge. Stored shape matches ReactFlowDTO.""" + return { + "nodes": [ + { + "id": "1", + "type": "startCall", + "position": {"x": 0, "y": 0}, + "data": { + "name": "Greeting", + "prompt": "Greet warmly.", + "greeting_type": "text", + "greeting": "Hi {{first_name}}!", + "allow_interrupt": True, + }, + }, + { + "id": "2", + "type": "endCall", + "position": {"x": 200, "y": 0}, + "data": {"name": "Done", "prompt": "Say goodbye."}, + }, + ], + "edges": [ + { + "id": "1-2", + "source": "1", + "target": "2", + "data": {"label": "done", "condition": "conversation complete"}, + }, + ], + "viewport": {"x": 0, "y": 0, "zoom": 1}, + } + + +def _normalize(wf: dict) -> dict: + """Strip cosmetics before comparing a round-tripped workflow. + + Node IDs are regenerated deterministically by the parser + (1, 2, 3, ...) so the inputs already match if constructed that way. + Position is preserved. Edge ids follow `source-target`. + """ + return { + "nodes": [ + { + "id": n["id"], + "type": n["type"], + "position": n["position"], + "data": n["data"], + } + for n in wf["nodes"] + ], + "edges": [ + { + "id": e["id"], + "source": e["source"], + "target": e["target"], + "data": e["data"], + } + for e in wf["edges"] + ], + } + + +# ─── generate_code ─────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_generate_emits_imports_and_factories(): + code = await generate_code(_minimal_workflow(), workflow_name="test") + assert 'import { Workflow } from "@dograh/sdk";' in code + assert "startCall" in code + assert "endCall" in code + assert "wf.addTyped(startCall(" in code + assert "wf.edge(" in code + + +@pytest.mark.asyncio +async def test_generate_strips_spec_defaults(): + wf = _minimal_workflow() + code = await generate_code(wf) + # `add_global_prompt=True` is a spec default for startCall; emitted + # code should omit it. Keeps the LLM-facing projection tight. + assert "add_global_prompt" not in code + + +@pytest.mark.asyncio +async def test_generate_omits_position(): + """Positions are hidden from the LLM — auto-layout post-processing + (future) reassigns them on save. Keeping them out of the edit + surface avoids the LLM producing cramped/overlapping layouts.""" + wf = _minimal_workflow() + code = await generate_code(wf) + assert "position" not in code + + +@pytest.mark.asyncio +async def test_generate_strips_legacy_ui_state_fields(): + """Stored workflows from before spec validation carry UI-state fields + (`invalid`, `selected`, `is_start`, etc.). `get_workflow_code` hides + those from the LLM so edits don't round-trip the noise.""" + wf = { + "nodes": [ + { + "id": "1", + "type": "startCall", + "position": {"x": 0, "y": 0}, + "data": { + "name": "g", + "prompt": "hi", + "invalid": False, + "validationMessage": None, + "is_start": True, + "selected": True, + "dragging": False, + }, + }, + ], + "edges": [], + "viewport": {"x": 0, "y": 0, "zoom": 1}, + } + code = await generate_code(wf) + for dropped in ("invalid", "validationMessage", "is_start", "selected", "dragging"): + assert dropped not in code, f"{dropped} should be stripped" + assert 'prompt: "hi"' in code + + +@pytest.mark.asyncio +async def test_generate_strips_unknown_edge_fields(): + wf = _minimal_workflow() + wf["edges"][0]["data"]["invalid"] = False + wf["edges"][0]["data"]["validationMessage"] = None + code = await generate_code(wf) + assert "invalid" not in code + assert "validationMessage" not in code + + +# ─── parse_code ────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_parse_accepts_minimal_code(): + code = """import { Workflow } from "@dograh/sdk"; +import { startCall, endCall } from "@dograh/sdk/typed"; + +const wf = new Workflow({ name: "min" }); +const a = wf.addTyped(startCall({ name: "g", prompt: "hi" })); +const b = wf.addTyped(endCall({ name: "d", prompt: "bye" })); +wf.edge(a, b, { label: "done", condition: "wrapped" }); +""" + result = await parse_code(code) + assert result["ok"] is True + wf = result["workflow"] + assert len(wf["nodes"]) == 2 + assert len(wf["edges"]) == 1 + assert wf["nodes"][0]["type"] == "startCall" + assert wf["edges"][0]["source"] == wf["nodes"][0]["id"] + + +@pytest.mark.asyncio +async def test_parse_rejects_function_declaration(): + code = """import { Workflow } from "@dograh/sdk"; +const wf = new Workflow({ name: "x" }); +function evil() { return 1; } +""" + result = await parse_code(code) + assert result["ok"] is False + assert result["stage"] == "parse" + assert any("FunctionDeclaration" in e["message"] for e in result["errors"]) + + +@pytest.mark.asyncio +async def test_parse_rejects_unknown_field(): + code = """import { Workflow } from "@dograh/sdk"; +import { startCall } from "@dograh/sdk/typed"; +const wf = new Workflow({ name: "x" }); +const a = wf.addTyped(startCall({ name: "g", prompt: "hi", promt: "typo" })); +""" + result = await parse_code(code) + assert result["ok"] is False + assert result["stage"] == "validate" + assert any("Unknown field" in e["message"] for e in result["errors"]) + + +@pytest.mark.asyncio +async def test_parse_rejects_unknown_variable_in_edge(): + code = """import { Workflow } from "@dograh/sdk"; +import { startCall, endCall } from "@dograh/sdk/typed"; +const wf = new Workflow({ name: "x" }); +const a = wf.addTyped(startCall({ name: "g", prompt: "hi" })); +wf.edge(a, missing, { label: "done", condition: "c" }); +""" + result = await parse_code(code) + assert result["ok"] is False + assert result["stage"] == "parse" + assert any("Unknown node variable" in e["message"] for e in result["errors"]) + + +@pytest.mark.asyncio +async def test_parse_requires_label_and_condition_on_edge(): + code = """import { Workflow } from "@dograh/sdk"; +import { startCall, endCall } from "@dograh/sdk/typed"; +const wf = new Workflow({ name: "x" }); +const a = wf.addTyped(startCall({ name: "g", prompt: "hi" })); +const b = wf.addTyped(endCall({ name: "d", prompt: "bye" })); +wf.edge(a, b, { label: "", condition: "c" }); +""" + result = await parse_code(code) + assert result["ok"] is False + assert result["stage"] == "parse" + + +# ─── Round-trip ────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_round_trip_minimal(): + wf = _minimal_workflow() + code = await generate_code(wf, workflow_name="rt") + result = await parse_code(code) + assert result["ok"] is True, result + # Positions are intentionally not preserved — they'll be reassigned + # by a downstream auto-layout pass. Parser defaults to {0, 0}. + for in_node, out_node in zip(wf["nodes"], result["workflow"]["nodes"]): + assert out_node["type"] == in_node["type"] + assert out_node["position"] == {"x": 0, "y": 0} + for k, v in in_node["data"].items(): + assert out_node["data"][k] == v, ( + f"{k}: {out_node['data'].get(k)!r} != {v!r}" + ) + assert _normalize({"nodes": [], "edges": result["workflow"]["edges"]})["edges"] == [ + { + "id": "1-2", + "source": "1", + "target": "2", + "data": {"label": "done", "condition": "conversation complete"}, + } + ] + + +@pytest.mark.asyncio +async def test_generate_fails_on_unknown_type(): + bad = { + "nodes": [ + { + "id": "1", + "type": "doesNotExist", + "position": {"x": 0, "y": 0}, + "data": {}, + } + ], + "edges": [], + "viewport": {"x": 0, "y": 0, "zoom": 1}, + } + with pytest.raises(TsBridgeError, match="Unknown node type"): + await generate_code(bad) diff --git a/docs/docs.json b/docs/docs.json index 4a5ec40..95fa29a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -107,6 +107,7 @@ }, { "group": "Integrations", + "tag": "NEW", "pages": [ "integrations/mcp" ] @@ -125,6 +126,15 @@ "developer/environment-variables" ] }, + { + "group": "SDKs", + "tag": "NEW", + "pages": [ + "sdks/introduction", + "sdks/build-an-agent", + "sdks/outbound-calls" + ] + }, { "group": "Deployment", "pages": [ @@ -243,7 +253,7 @@ } }, "banner": { - "content": "🎉 **New: Gemini Live 3.1 Support** — Try the latest Google Gemini Live 3.1 on Dograh platform. [Learn more →](/configurations/inference-providers#gemini-3-1-live)", + "content": "🎉 **New: MCP Server & SDKs Released** — Drive Dograh from Claude, Cursor, or your own Python/TypeScript code. [MCP →](/integrations/mcp) · [SDKs →](/sdks/introduction)", "dismissible": true }, "search": { diff --git a/docs/integrations/mcp.mdx b/docs/integrations/mcp.mdx index 61d7b57..a0e763a 100644 --- a/docs/integrations/mcp.mdx +++ b/docs/integrations/mcp.mdx @@ -16,6 +16,16 @@ Dograh exposes an [MCP (Model Context Protocol)](https://modelcontextprotocol.io The endpoint is also shown in **Platform Settings → MCP Server** inside the Dograh UI. + +If you deployed Dograh to a remote server using [`setup_remote.sh`](/deployment/docker#option-2%3A-remote-server-deployment), your endpoint is served with a self-signed SSL certificate. Claude Code will refuse MCP connections to it unless you start the CLI with TLS verification disabled: + +```bash +NODE_TLS_REJECT_UNAUTHORIZED=0 claude +``` + +This is only required for self-signed certificates. If you have set up a [custom domain](/deployment/custom-domain) with Let's Encrypt certificates via Certbot, no extra flag is needed. + + ## Claude Code Register Dograh as an MCP server with the Claude Code CLI: @@ -56,14 +66,33 @@ Restart Claude Desktop after saving. The Dograh tools should appear in the tool Any MCP client that supports Streamable HTTP transport can connect with the same URL and header. Paste the configuration above into your client's MCP settings file and replace `YOUR_API_KEY`. -## Available tools +## Example prompts -Once connected, your AI assistant can: +Once the MCP server is connected, you can drive Dograh from your coding agent in plain English. A few prompts to try: -- `list_workflows` — list agents in your workspace -- `get_workflow` — fetch an agent's definition by ID -- `search_dograh_docs` — search Dograh documentation -- `fetch_dograh_doc` — retrieve a specific doc page +**Explore your workspace** + +- "List my agents in Dograh." +- "Show me the definition of the agent called *Lead Qualifier*." +- "Which credentials and tools are configured in my Dograh workspace?" +- "List the recordings from my most recent agent." + +**Edit an agent** + +- "In my *Lead Qualifier* agent, add a new agent node after the greeting that asks the caller for their budget, then routes to the existing qualification node." +- "Add an end-call node to *Support Bot* that triggers when the user says they are done, with a polite goodbye prompt." +- "Rename the *intro* node in *Lead Qualifier* to *greeting* and update any edges that reference it." +- "Change the LLM model on all agent nodes in *Support Bot* to `gpt-4o-mini`." + +**Learn the platform** + +- "Search the Dograh docs for how to configure a TURN server." +- "What node types does Dograh support, and what fields does a `knowledge_base` node take?" +- "How do I deploy Dograh on a custom domain with HTTPS?" + + +Agent edits are saved as a new **draft** version — your published agent keeps serving calls until you explicitly publish the draft from the Dograh UI. + The API key controls which workspace the assistant sees. Treat it like any other credential — do not commit it to source control or paste it into shared chats. diff --git a/docs/sdks/build-an-agent.mdx b/docs/sdks/build-an-agent.mdx new file mode 100644 index 0000000..ce76ee3 --- /dev/null +++ b/docs/sdks/build-an-agent.mdx @@ -0,0 +1,111 @@ +--- +title: "Build an agent" +description: "Assemble a Dograh voice agent programmatically with the SDK and save it as a draft" +--- + +The SDK mirrors the node-and-edge model of the [Voice Agent Builder](/voice-agent/introduction). You create a `Workflow`, add nodes (`startCall`, `agentNode`, `endCall`, …) with `add()`, connect them with `edge()`, and persist the result via `save_workflow`. + +## Prerequisites + +- A Dograh [API key](/configurations/api-keys) exported as `DOGRAH_API_KEY` +- An existing agent ID to save drafts against (create one in the Dograh UI or via [`POST /api/v1/workflow/create`](/api-reference/agents/create-from-template)) + +## Build and save + +The example below builds a three-node loan-qualification agent and saves it as a **new draft version** on an existing agent. Your published agent keeps serving calls until you explicitly publish the draft. + + +```python Python +from dograh_sdk import DograhClient, Workflow + +with DograhClient(api_key="YOUR_API_KEY") as client: + wf = Workflow(client=client, name="loan_qualification") + + greeting = wf.add( + type="startCall", + name="greeting", + prompt="You are Sarah from Acme Loans. Greet the caller warmly.", + ) + qualify = wf.add( + type="agentNode", + name="qualify", + prompt="Ask about loan amount, purpose, and monthly income.", + ) + done = wf.add( + type="endCall", + name="done", + prompt="Thank them and end the call politely.", + ) + + wf.edge(greeting, qualify, label="interested", condition="Caller wants to continue.") + wf.edge(qualify, done, label="done", condition="All qualification questions answered.") + + client.save_workflow(workflow_id=123, workflow=wf) +``` +```typescript TypeScript +import { DograhClient, Workflow } from "@dograh/sdk"; + +const client = new DograhClient({ apiKey: "YOUR_API_KEY" }); +const wf = new Workflow({ client, name: "loan_qualification" }); + +const greeting = await wf.add({ + type: "startCall", + name: "greeting", + prompt: "You are Sarah from Acme Loans. Greet the caller warmly.", +}); +const qualify = await wf.add({ + type: "agentNode", + name: "qualify", + prompt: "Ask about loan amount, purpose, and monthly income.", +}); +const done = await wf.add({ + type: "endCall", + name: "done", + prompt: "Thank them and end the call politely.", +}); + +wf.edge(greeting, qualify, { label: "interested", condition: "Caller wants to continue." }); +wf.edge(qualify, done, { label: "done", condition: "All qualification questions answered." }); + +await client.saveWorkflow(123, wf); +``` + + +## Edit an existing agent + +Load an agent into an editable `Workflow`, mutate it, then save: + + +```python Python +wf = client.load_workflow(workflow_id=123) +wf.name = "loan_qualification_v2" +client.save_workflow(workflow_id=123, workflow=wf) +``` +```typescript TypeScript +const wf = await client.loadWorkflow(123); +wf.name = "loan_qualification_v2"; +await client.saveWorkflow(123, wf); +``` + + +## Discover node types + +Each node's `type` string and required fields come from the backend's node-spec catalog. Fetch it at runtime to validate what you can build: + + +```python Python +types = client.list_node_types() +for spec in types.node_types: + print(spec.name, [p.name for p in spec.properties]) +``` +```typescript TypeScript +const types = await client.listNodeTypes(); +for (const spec of types.node_types) { + console.log(spec.name, spec.properties.map(p => p.name)); +} +``` + + + +For a full description of each node type and its fields, see the [Nodes](/voice-agent/start-call) section of the Voice Agent Builder docs. + diff --git a/docs/sdks/introduction.mdx b/docs/sdks/introduction.mdx new file mode 100644 index 0000000..bd79600 --- /dev/null +++ b/docs/sdks/introduction.mdx @@ -0,0 +1,75 @@ +--- +title: "Introduction" +description: "Build and operate Dograh voice AI agents programmatically from Python or TypeScript" +--- + +Dograh ships official SDKs for **Python** and **TypeScript** that wrap the Dograh REST API and the workflow builder. Use them to create or edit agents, place outbound calls, and inspect runs from your own code. + +- **Python** — [`dograh-sdk`](https://pypi.org/project/dograh-sdk/) on PyPI +- **TypeScript** — [`@dograh/sdk`](https://www.npmjs.com/package/@dograh/sdk) on npm + +Both packages are generated from the same backend OpenAPI spec, so the method surface is equivalent — only the naming convention differs (`snake_case` in Python, `camelCase` in TypeScript). + +## Install + + +```bash Python +pip install dograh-sdk +``` +```bash TypeScript +npm install @dograh/sdk +``` + + +## Authenticate + +Generate an API key at [`/api-keys`](https://app.dograh.com/api-keys) (or `http://localhost:3010/api-keys` for self-hosted). See [API Keys](/configurations/api-keys) for details. + +Both SDKs read the API key from the `DOGRAH_API_KEY` environment variable by default, and the base URL from `DOGRAH_API_URL` (defaults to `http://localhost:8000`). You can also pass them explicitly. + + +```python Python +from dograh_sdk import DograhClient + +client = DograhClient( + base_url="https://app.dograh.com", + api_key="YOUR_API_KEY", +) +``` +```typescript TypeScript +import { DograhClient } from "@dograh/sdk"; + +const client = new DograhClient({ + baseUrl: "https://app.dograh.com", + apiKey: "YOUR_API_KEY", +}); +``` + + + +For self-hosted deployments, swap `baseUrl` for your backend URL (e.g. `http://localhost:8000`). + + +## Quick tour + +List the agents in your workspace: + + +```python Python +workflows = client.list_workflows() +for wf in workflows: + print(wf.id, wf.name) +``` +```typescript TypeScript +const workflows = await client.listWorkflows(); +for (const wf of workflows) { + console.log(wf.id, wf.name); +} +``` + + +## Next steps + +- [Build an agent](/sdks/build-an-agent) — assemble nodes and edges, save as a draft +- [Place an outbound call](/sdks/outbound-calls) — trigger a call from an agent to a phone number +- [MCP Server](/integrations/mcp) — let Claude and other coding agents drive the SDK for you diff --git a/docs/sdks/outbound-calls.mdx b/docs/sdks/outbound-calls.mdx new file mode 100644 index 0000000..7a919aa --- /dev/null +++ b/docs/sdks/outbound-calls.mdx @@ -0,0 +1,49 @@ +--- +title: "Place an outbound call" +description: "Trigger a Dograh voice agent to call a phone number from the SDK" +--- + +Use the SDK to place a test outbound call from a specific agent to a phone number. This is the same endpoint used by the **Test Call** button in the Dograh UI. + +## Prerequisites + +- A Dograh [API key](/configurations/api-keys) exported as `DOGRAH_API_KEY` +- A published agent (you need the agent ID) +- A configured telephony provider — see [Telephony](/integrations/telephony/overview) for Twilio, Vonage, and other setups + +## Place the call + + +```python Python +from dograh_sdk import DograhClient +from dograh_sdk._generated_models import InitiateCallRequest + +with DograhClient(api_key="YOUR_API_KEY") as client: + client.test_phone_call( + body=InitiateCallRequest( + workflow_id=123, + phone_number="+14155551234", + ) + ) +``` +```typescript TypeScript +import { DograhClient } from "@dograh/sdk"; + +const client = new DograhClient({ apiKey: "YOUR_API_KEY" }); + +await client.testPhoneCall({ + body: { + workflow_id: 123, + phone_number: "+14155551234", + }, +}); +``` + + +## Inspect the run + +Every call creates a **run** you can inspect afterwards. See [Calls & runs](/core-concepts/calls-and-runs) for what's tracked, or use the [Runs API](/api-reference/agents/runs/list) to list and fetch runs programmatically. + +## Bulk campaigns + +For placing many calls at once (say, from a CSV), use [Campaigns](/core-concepts/campaigns) rather than looping over `test_phone_call` — campaigns handle pacing, retries, and progress tracking. diff --git a/docs/voice-agent/pre-call-data-fetch.mdx b/docs/voice-agent/pre-call-data-fetch.mdx index 2784984..53d1d9a 100644 --- a/docs/voice-agent/pre-call-data-fetch.mdx +++ b/docs/voice-agent/pre-call-data-fetch.mdx @@ -1,7 +1,6 @@ --- title: "Pre-Call Data Fetch" description: "Fetch customer data from your CRM or ERP before the call starts, so your voice agent can greet callers by name and reference their account details." -tag: "NEW" --- Pre-Call Data Fetch allows you to enrich the call context with external data before the voice agent starts speaking. When enabled on the **Start Call** node, Dograh sends an HTTP request to your API as soon as a call is initiated. While the response is loading, the caller hears a ring-back tone. Once the data arrives, it is merged into the call's [initial context](/core-concepts/context-and-variables#initial_context) and becomes available as template variables in your prompts and greetings. diff --git a/docs/voice-agent/pre-recorded-audio.mdx b/docs/voice-agent/pre-recorded-audio.mdx index d0fe0aa..5958521 100644 --- a/docs/voice-agent/pre-recorded-audio.mdx +++ b/docs/voice-agent/pre-recorded-audio.mdx @@ -1,7 +1,6 @@ --- title: "Pre-recorded Audio" description: "Build hybrid voice agents that combine pre-recorded audio with dynamic text generation for lower latency, reduced TTS costs, and natural-sounding conversations." -tag: "NEW" --- Custom recordings allow you to build **hybrid voice agents** that use your own pre-recorded audio for key parts of the conversation, while falling back to LLM-generated speech (via a cloned voice) for dynamic responses. This gives you the best of both worlds — the emotional depth of real human speech and the flexibility of AI-generated dialogue. diff --git a/pipecat b/pipecat index edefaad..a6869df 160000 --- a/pipecat +++ b/pipecat @@ -1 +1 @@ -Subproject commit edefaad42b97e52a3ad5eef8d15115a5c6ba3b11 +Subproject commit a6869df4bc7de8bd14f0533f7112f7d6a24891d9 diff --git a/scripts/generate_sdk.sh b/scripts/generate_sdk.sh new file mode 100755 index 0000000..c95a4a6 --- /dev/null +++ b/scripts/generate_sdk.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Regenerate every file the SDKs derive from authoritative backend state: +# +# 1. Typed node dataclasses / TS interfaces (from node_specs registry) +# 2. Filtered OpenAPI spec (routes tagged via @sdk_expose) +# 3. Pydantic request/response models + TS interfaces (datamodel-codegen +# / openapi-typescript) +# 4. Client method mixins (_generated_client.py / _generated_client.ts) +# +# Run from anywhere — the script resolves the repo root relative to itself. +# Requires: +# - `python` in the `dograh` conda env, `api/.env` sourced; the `api` +# package must be importable. `datamodel-code-generator` installed +# (`pip install datamodel-code-generator`). +# - `node` (>= 22.6 for native .mts support) and npm. openapi-typescript +# is a devDependency of sdk/typescript; `npm install` in that dir is +# done for you if node_modules is missing. +# +# Invoked manually after editing any NodeSpec or after adding/removing an +# `@sdk_expose` decorator. CI runs this and asserts the git diff is empty. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +if [ -f "$REPO_ROOT/api/.env" ]; then + set -a + # shellcheck disable=SC1091 + source "$REPO_ROOT/api/.env" + set +a +fi + +SPECS_JSON="$(mktemp -t dograh-specs-XXXXXX.json)" +OPENAPI_JSON="$(mktemp -t dograh-openapi-XXXXXX.json)" +trap 'rm -f "$SPECS_JSON" "$OPENAPI_JSON"' EXIT + +# ── 1. Node-spec typed dataclasses ──────────────────────────────────── + +echo "→ Dumping node specs from in-process registry..." +python -m api.services.workflow.node_specs > "$SPECS_JSON" + +echo "→ Generating Python typed dataclasses..." +PYTHONPATH="$REPO_ROOT/sdk/python/src" python -m dograh_sdk.codegen \ + --input "$SPECS_JSON" \ + --out "sdk/python/src/dograh_sdk/typed" + +echo "→ Generating TypeScript typed interfaces..." +node "sdk/typescript/scripts/codegen.mts" \ + --input "$SPECS_JSON" \ + --out "sdk/typescript/src/typed" + +# ── 2. SDK-scoped OpenAPI spec ──────────────────────────────────────── + +echo "→ Dumping filtered OpenAPI (sdk_expose routes only)..." +python - < # e.g. 0.1.2" >&2 + exit 1 +fi +if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.\-][A-Za-z0-9.]+)?$ ]]; then + echo "error: '$VERSION' does not look like semver (e.g. 0.1.2 or 0.2.0-rc.1)" >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +confirm() { + local reply + read -r -p "$1 [y/N] " reply + [[ "$reply" =~ ^[Yy]$ ]] +} + +echo "→ Regenerating typed SDK sources from node_specs..." +./scripts/generate_sdk.sh + +if ! git diff --quiet -- sdk/python/src/dograh_sdk/typed sdk/typescript/src/typed; then + echo + echo "⚠ node_specs regeneration changed typed files. Review the diff" + echo " above and commit before releasing — otherwise the tag will" + echo " point at a tree that disagrees with what ships to the registry." + if ! confirm "Continue anyway?"; then + exit 1 + fi +fi + +echo "→ Bumping versions to $VERSION..." +VERSION="$VERSION" python - <<'PY' +import os +import pathlib +import re + +version = os.environ["VERSION"] + +py = pathlib.Path("sdk/python/pyproject.toml") +py.write_text( + re.sub(r'^version = "[^"]+"', f'version = "{version}"', py.read_text(), count=1, flags=re.M) +) + +ts = pathlib.Path("sdk/typescript/package.json") +ts.write_text( + re.sub(r'"version": "[^"]+"', f'"version": "{version}"', ts.read_text(), count=1) +) + +print(f" pyproject.toml → {version}") +print(f" package.json → {version}") +PY + +echo "→ Building Python wheel + sdist..." +( + cd sdk/python + rm -rf dist build + python -m build >/dev/null + twine check dist/* +) + +echo "→ Building TypeScript + running tests..." +( + cd sdk/typescript + rm -rf dist + npm ci --silent + npm run build + npm test +) + +echo +echo "============================================================" +echo " Built dograh-sdk==$VERSION and @dograh/sdk@$VERSION" +echo " Nothing has been published yet." +echo "============================================================" +echo + +if confirm "Upload dograh-sdk==$VERSION to TestPyPI first (recommended)?"; then + (cd sdk/python && twine upload --repository testpypi dist/*) + echo " → https://test.pypi.org/project/dograh-sdk/$VERSION/" + echo +fi + +if confirm "Upload dograh-sdk==$VERSION to PyPI?"; then + (cd sdk/python && twine upload dist/*) + echo " → https://pypi.org/project/dograh-sdk/$VERSION/" + echo +fi + +if confirm "Publish @dograh/sdk@$VERSION to npm? (will prompt for 2FA OTP)"; then + (cd sdk/typescript && npm publish --access public) + echo " → https://www.npmjs.com/package/@dograh/sdk/v/$VERSION" + echo +fi + +if confirm "Create annotated git tag sdks-v$VERSION at HEAD?"; then + git tag -a "sdks-v$VERSION" -m "dograh-sdk + @dograh/sdk $VERSION" + echo " → created tag (not pushed). Push with:" + echo " git push origin sdks-v$VERSION" +fi + +echo "✓ Done." diff --git a/sdk/codegen/__init__.py b/sdk/codegen/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sdk/codegen/client_codegen.py b/sdk/codegen/client_codegen.py new file mode 100644 index 0000000..05ca1ea --- /dev/null +++ b/sdk/codegen/client_codegen.py @@ -0,0 +1,355 @@ +"""Generate SDK client mixins (Python + TypeScript) from a filtered OpenAPI dump. + +Input: a spec produced by calling FastAPI's `get_openapi(routes=...)` with +only the routes tagged via `sdk_expose(...)`. Because it's already filtered, +this script does *no* filtering — it just walks the operations and emits +typed method stubs. + +Request/response types come from sibling model files already produced by +`datamodel-codegen` (Python) and `openapi-typescript --root-types +--root-types-no-schema-prefix` (TypeScript). We only import the names +here; this script doesn't generate types itself. + +Output: + --py-out sdk/python/src/dograh_sdk/_generated_client.py + --ts-out sdk/typescript/src/_generated_client.ts +""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +_API_PREFIX = "/api/v1" + +# openapi scalar → (python, typescript) +_TYPE_MAP = { + "integer": ("int", "number"), + "number": ("float", "number"), + "string": ("str", "string"), + "boolean": ("bool", "boolean"), +} + + +def _map_scalar(schema: dict[str, Any]) -> tuple[str, str]: + t = schema.get("type") + if t in _TYPE_MAP: + return _TYPE_MAP[t] + # optional string often shown as anyOf:[{type:string}, {type:null}] + for branch in schema.get("anyOf") or []: + if branch.get("type") in _TYPE_MAP and branch.get("type") != "null": + return _TYPE_MAP[branch["type"]] + return ("Any", "unknown") + + +def _ref_name(schema: dict[str, Any]) -> str | None: + ref = schema.get("$ref") + if isinstance(ref, str) and ref.startswith("#/components/schemas/"): + return ref.rsplit("/", 1)[-1] + return None + + +@dataclass +class ResponseType: + """What comes back from an operation. `class_name` is a model class from + `_generated_models`; `is_list` wraps it as a list.""" + class_name: str | None = None + is_list: bool = False + + @property + def py(self) -> str: + if self.class_name is None: + return "Any" + return f"list[{self.class_name}]" if self.is_list else self.class_name + + @property + def ts(self) -> str: + if self.class_name is None: + return "unknown" + return f"{self.class_name}[]" if self.is_list else self.class_name + + +@dataclass +class Param: + name: str + py_type: str + ts_type: str + required: bool + + +@dataclass +class Operation: + method: str + verb: str + path: str + description: str + path_params: list[Param] = field(default_factory=list) + query_params: list[Param] = field(default_factory=list) + request_class: str | None = None # None → no body + response: ResponseType = field(default_factory=ResponseType) + + +def _collect(spec: dict[str, Any]) -> list[Operation]: + ops: list[Operation] = [] + used_models: set[str] = set() + + for path, methods in spec.get("paths", {}).items(): + for verb, op in methods.items(): + if not isinstance(op, dict) or "x-sdk-method" not in op: + continue + + description = (op.get("x-sdk-description") or op.get("summary") or "").strip() + sdk_path = path[len(_API_PREFIX):] if path.startswith(_API_PREFIX) else path + + path_params: list[Param] = [] + query_params: list[Param] = [] + for p in op.get("parameters") or []: + py_t, ts_t = _map_scalar(p.get("schema") or {}) + param = Param( + name=p["name"], + py_type=py_t, + ts_type=ts_t, + required=bool(p.get("required")), + ) + if p.get("in") == "path": + path_params.append(param) + elif p.get("in") == "query": + query_params.append(param) + + request_class: str | None = None + rb = op.get("requestBody") or {} + rb_schema = ( + (rb.get("content") or {}).get("application/json", {}).get("schema") or {} + ) + if rb_schema: + request_class = _ref_name(rb_schema) + + response = ResponseType() + r200 = ( + op.get("responses", {}) + .get("200", {}) + .get("content", {}) + .get("application/json", {}) + .get("schema") + or {} + ) + if r200: + name = _ref_name(r200) + if name: + response = ResponseType(class_name=name) + elif r200.get("type") == "array": + item = r200.get("items") or {} + name = _ref_name(item) + if name: + response = ResponseType(class_name=name, is_list=True) + + for cls in (request_class, response.class_name): + if cls: + used_models.add(cls) + + ops.append(Operation( + method=op["x-sdk-method"], + verb=verb.lower(), + path=sdk_path, + description=description, + path_params=path_params, + query_params=query_params, + request_class=request_class, + response=response, + )) + + ops.sort(key=lambda o: o.method) + return ops, sorted(used_models) + + +# ── Python emitter ───────────────────────────────────────────────────── + + +def _py_method(op: Operation) -> str: + positional = [f"{p.name}: {p.py_type}" for p in op.path_params] + kw_only: list[str] = [] + if op.request_class: + kw_only.append(f"body: {op.request_class}") + for p in op.query_params: + kw_only.append(f"{p.name}: {p.py_type} | None = None") + + sig = ", ".join(["self", *positional] + (["*"] + kw_only if kw_only else [])) + + lines: list[str] = [] + lines.append(f" def {op.method}({sig}) -> {op.response.py}:") + lines.append(f' """{op.description or op.verb.upper() + " " + op.path}"""') + + path_expr = f'f"{op.path}"' if op.path_params else f'"{op.path}"' + + call_kwargs: list[str] = [] + if op.query_params: + lines.append(" params: dict[str, Any] = {}") + for p in op.query_params: + lines.append(f" if {p.name} is not None:") + lines.append(f' params["{p.name}"] = {p.name}') + call_kwargs.append("params=params") + if op.request_class: + call_kwargs.append('json=body.model_dump(mode="json", exclude_none=True)') + + extra = (", " + ", ".join(call_kwargs)) if call_kwargs else "" + raw_call = f'self._request("{op.verb.upper()}", {path_expr}{extra})' + + if op.response.class_name is None: + lines.append(f" return {raw_call}") + elif op.response.is_list: + lines.append(f" data = {raw_call}") + lines.append(f" return [{op.response.class_name}.model_validate(x) for x in data]") + else: + lines.append(f" data = {raw_call}") + lines.append(f" return {op.response.class_name}.model_validate(data)") + + lines.append("") + return "\n".join(lines) + + +_PY_HEADER = '''\ +"""GENERATED — do not edit. Source: filtered OpenAPI from `api.app`. + +Regenerate with `./scripts/generate_sdk.sh`. + +`DograhClient` mixes in this class to get HTTP methods for every route +decorated with `sdk_expose(...)` on the backend. Request/response types +come from `_generated_models` (datamodel-codegen output). +""" + +from __future__ import annotations + +from typing import Any + +from dograh_sdk._generated_models import ( +{imports} +) + + +class _GeneratedClient: + # `DograhClient.__init__` installs `self._request` (see client.py). + +''' + + +def emit_python(ops: list[Operation], models: list[str]) -> str: + imports = "\n".join(f" {m}," for m in models) + body = "\n".join(_py_method(op) for op in ops) + return _PY_HEADER.format(imports=imports) + body + + +# ── TypeScript emitter ───────────────────────────────────────────────── + + +def _snake_to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p.title() for p in parts[1:]) + + +def _ts_method(op: Operation) -> str: + name = _snake_to_camel(op.method) + positional = [f"{_snake_to_camel(p.name)}: {p.ts_type}" for p in op.path_params] + + opts_props: list[str] = [] + if op.request_class: + opts_props.append(f"body: {op.request_class}") + for p in op.query_params: + opts_props.append(f"{_snake_to_camel(p.name)}?: {p.ts_type}") + + args = list(positional) + if opts_props: + required_in_opts = op.request_class is not None + opts_sig = "{ " + "; ".join(opts_props) + " }" + # If body is required, opts is required too (no `= {}` default) + args.append(f"opts: {opts_sig}" if required_in_opts else f"opts: {opts_sig} = {{}}") + + sig = ", ".join(args) + ret = op.response.ts + + lines: list[str] = [] + lines.append(f" /** {op.description or op.verb.upper() + ' ' + op.path} */") + lines.append(f" async {name}({sig}): Promise<{ret}> {{") + + path_expr = op.path + for p in op.path_params: + path_expr = path_expr.replace("{" + p.name + "}", "${" + _snake_to_camel(p.name) + "}") + tmpl = f"`{path_expr}`" if op.path_params else f'"{op.path}"' + + call_opts: list[str] = [] + if op.query_params: + entries: list[str] = [] + for p in op.query_params: + camel = _snake_to_camel(p.name) + entries.append(f' ...(opts.{camel} !== undefined ? {{ "{p.name}": opts.{camel} }} : {{}}),') + lines.append(" const params: Record = {") + lines.extend(entries) + lines.append(" };") + call_opts.append("params") + if op.request_class: + call_opts.append("json: opts.body") + + extra = (", { " + ", ".join(call_opts) + " }") if call_opts else "" + generic = f"<{ret}>" if ret != "unknown" else "" + lines.append(f' return this.request{generic}("{op.verb.upper()}", {tmpl}{extra});') + lines.append(" }") + lines.append("") + return "\n".join(lines) + + +_TS_HEADER = """\ +// GENERATED — do not edit. Source: filtered OpenAPI from `api.app`. +// +// Regenerate with `./scripts/generate_sdk.sh`. +// +// `DograhClient` extends this base to get HTTP methods for every route +// decorated with `sdk_expose(...)`. Request/response types come from +// `_generated_models` (openapi-typescript output, --root-types). + +import type {{ +{imports} +}} from "./_generated_models.js"; + +export abstract class _GeneratedClient {{ + protected abstract request( + method: string, + path: string, + opts?: {{ json?: unknown; params?: Record }}, + ): Promise; + +""" + + +def emit_typescript(ops: list[Operation], models: list[str]) -> str: + imports = "\n".join(f" {m}," for m in models) + body = "\n".join(_ts_method(op) for op in ops) + return _TS_HEADER.format(imports=imports) + body + "}\n" + + +# ── CLI ──────────────────────────────────────────────────────────────── + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--input", required=True, help="Path to filtered openapi.json") + ap.add_argument("--py-out", required=True) + ap.add_argument("--ts-out", required=True) + args = ap.parse_args() + + spec = json.loads(Path(args.input).read_text()) + ops, models = _collect(spec) + if not ops: + raise SystemExit("No x-sdk-method operations — nothing to emit.") + + Path(args.py_out).write_text(emit_python(ops, models)) + Path(args.ts_out).write_text(emit_typescript(ops, models)) + print(f" → {len(ops)} operations, {len(models)} models referenced") + print(f" → {args.py_out}") + print(f" → {args.ts_out}") + + +if __name__ == "__main__": + main() diff --git a/sdk/python/.gitignore b/sdk/python/.gitignore new file mode 100644 index 0000000..3bb8821 --- /dev/null +++ b/sdk/python/.gitignore @@ -0,0 +1,3 @@ +dist/ +build/ +*.egg-info/ diff --git a/sdk/python/LICENSE b/sdk/python/LICENSE new file mode 100644 index 0000000..ca08b45 --- /dev/null +++ b/sdk/python/LICENSE @@ -0,0 +1,24 @@ +BSD 2-Clause License + +Copyright (c) 2025, Zansat Technologies Private Limited + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/sdk/python/README.md b/sdk/python/README.md new file mode 100644 index 0000000..9728d9d --- /dev/null +++ b/sdk/python/README.md @@ -0,0 +1,77 @@ +# dograh-sdk + +Typed builder for Dograh voice-AI workflows. Fetches the node-spec catalog from +the Dograh backend at session start, validates every call against it at the +call site, and produces `ReactFlowDTO`-compatible JSON. + +## Install + +```bash +pip install dograh-sdk +``` + +For local development against a checked-out monorepo: + +```bash +pip install -e sdk/python/ +``` + +## Usage + +```python +from dograh_sdk import DograhClient, Workflow + +with DograhClient(base_url="http://localhost:8000", api_key="...") as client: + wf = Workflow(client=client, name="loan_qualification") + + start = wf.add( + type="startCall", + name="greeting", + prompt="You are Sarah from Acme Loans. Greet the caller warmly.", + greeting_type="text", + greeting="Hi {{first_name}}, this is Sarah.", + ) + qualify = wf.add( + type="agentNode", + name="qualify", + prompt="Ask about loan amount and timeline.", + ) + done = wf.add(type="endCall", name="done", prompt="Thank the caller.") + + wf.edge(start, qualify, label="interested", condition="Caller expressed interest.") + wf.edge(qualify, done, label="done", condition="Qualification complete.") + + client.save_workflow(workflow_id=123, workflow=wf) +``` + +## What gets validated at the call site + +The SDK fetches the spec for each node type via `get_node_type` and raises +`ValidationError` immediately when: + +- an unknown field is passed (catches typos) +- a required field is missing or empty +- a scalar type is wrong (e.g., string for a boolean) +- an `options` value isn't in the allowed list + +When a spec carries an `llm_hint`, the hint is appended to the error message so +an LLM agent can self-correct on retry: + +``` +tool_uuids: expected tool_refs, got str + Hint: List of tool UUIDs from `list_tools`. +``` + +Server-side Pydantic validators run on save and surface anything the SDK lets +through (compound invariants, cross-field rules). + +## Environment + +```bash +DOGRAH_API_URL=http://localhost:8000 # default +DOGRAH_API_KEY=sk-... # sent as X-API-Key +``` + +## License + +BSD 2-Clause — see `LICENSE`. diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml new file mode 100644 index 0000000..0968b1a --- /dev/null +++ b/sdk/python/pyproject.toml @@ -0,0 +1,49 @@ +[project] +name = "dograh-sdk" +version = "0.1.2" +description = "Typed builder for Dograh voice-AI workflows" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "BSD-2-Clause" } +authors = [ + { name = "Zansat Technologies Private Limited", email = "contact@dograh.com" }, +] +keywords = ["dograh", "voice-ai", "workflow", "sdk", "llm", "agent"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries", +] +dependencies = [ + "httpx>=0.27", + "pydantic>=2.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", +] + +[project.urls] +Homepage = "https://dograh.com" +Documentation = "https://docs.dograh.com" +Repository = "https://github.com/dograh-hq/dograh" + +[project.scripts] +dograh-sdk-codegen = "dograh_sdk.codegen:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/dograh_sdk"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/sdk/python/src/dograh_sdk/__init__.py b/sdk/python/src/dograh_sdk/__init__.py new file mode 100644 index 0000000..8c12895 --- /dev/null +++ b/sdk/python/src/dograh_sdk/__init__.py @@ -0,0 +1,35 @@ +"""Dograh SDK — typed builder for voice-AI workflows. + +Runtime SDK: fetches the spec catalog from the Dograh backend at session +start and validates every `Workflow.add()` call against it. LLMs don't +need to import per-node-type classes — the `type` argument is a string +keyed against the fetched spec catalog. + + from dograh_sdk import DograhClient, Workflow + + with DograhClient(base_url="http://localhost:8000", api_key=...) as client: + wf = Workflow(client=client, name="loan_qualification") + start = wf.add(type="startCall", name="greeting", prompt="...") + qualify = wf.add(type="agentNode", name="qualify", prompt="...") + wf.edge(start, qualify, label="interested", condition="...") + client.save_workflow(workflow_id=123, workflow=wf) + +For typed IDE autocomplete, generate per-node dataclasses via the SDK +codegen (Phase 6) — the runtime and typed SDKs share this same core. +""" + +from .client import DograhClient +from .errors import ApiError, DograhSdkError, SpecMismatchError, ValidationError +from .typed._base import TypedNode +from .workflow import NodeRef, Workflow + +__all__ = [ + "ApiError", + "DograhClient", + "DograhSdkError", + "NodeRef", + "SpecMismatchError", + "TypedNode", + "ValidationError", + "Workflow", +] diff --git a/sdk/python/src/dograh_sdk/_generated_client.py b/sdk/python/src/dograh_sdk/_generated_client.py new file mode 100644 index 0000000..82c2031 --- /dev/null +++ b/sdk/python/src/dograh_sdk/_generated_client.py @@ -0,0 +1,102 @@ +"""GENERATED — do not edit. Source: filtered OpenAPI from `api.app`. + +Regenerate with `./scripts/generate_sdk.sh`. + +`DograhClient` mixes in this class to get HTTP methods for every route +decorated with `sdk_expose(...)` on the backend. Request/response types +come from `_generated_models` (datamodel-codegen output). +""" + +from __future__ import annotations + +from typing import Any + +from dograh_sdk._generated_models import ( + CredentialResponse, + DocumentListResponseSchema, + InitiateCallRequest, + NodeSpec, + NodeTypesResponse, + RecordingListResponseSchema, + ToolResponse, + UpdateWorkflowRequest, + WorkflowListResponse, + WorkflowResponse, +) + + +class _GeneratedClient: + # `DograhClient.__init__` installs `self._request` (see client.py). + + def get_node_type(self, name: str) -> NodeSpec: + """Fetch a single node spec by name.""" + data = self._request("GET", f"/node-types/{name}") + return NodeSpec.model_validate(data) + + def get_workflow(self, workflow_id: int) -> WorkflowResponse: + """Get a single workflow by ID (returns draft if one exists, else published).""" + data = self._request("GET", f"/workflow/fetch/{workflow_id}") + return WorkflowResponse.model_validate(data) + + def list_credentials(self) -> list[CredentialResponse]: + """List webhook credentials available to the authenticated organization.""" + data = self._request("GET", "/credentials/") + return [CredentialResponse.model_validate(x) for x in data] + + def list_documents(self, *, status: str | None = None, limit: int | None = None, offset: int | None = None) -> DocumentListResponseSchema: + """List knowledge base documents available to the authenticated organization.""" + params: dict[str, Any] = {} + if status is not None: + params["status"] = status + if limit is not None: + params["limit"] = limit + if offset is not None: + params["offset"] = offset + data = self._request("GET", "/knowledge-base/documents", params=params) + return DocumentListResponseSchema.model_validate(data) + + def list_node_types(self) -> NodeTypesResponse: + """List every registered node type with its spec. Pinned to spec_version.""" + data = self._request("GET", "/node-types") + return NodeTypesResponse.model_validate(data) + + def list_recordings(self, *, workflow_id: int | None = None, tts_provider: str | None = None, tts_model: str | None = None, tts_voice_id: str | None = None) -> RecordingListResponseSchema: + """List workflow recordings available to the authenticated organization.""" + params: dict[str, Any] = {} + if workflow_id is not None: + params["workflow_id"] = workflow_id + if tts_provider is not None: + params["tts_provider"] = tts_provider + if tts_model is not None: + params["tts_model"] = tts_model + if tts_voice_id is not None: + params["tts_voice_id"] = tts_voice_id + data = self._request("GET", "/workflow-recordings/", params=params) + return RecordingListResponseSchema.model_validate(data) + + def list_tools(self, *, status: str | None = None, category: str | None = None) -> list[ToolResponse]: + """List tools available to the authenticated organization.""" + params: dict[str, Any] = {} + if status is not None: + params["status"] = status + if category is not None: + params["category"] = category + data = self._request("GET", "/tools/", params=params) + return [ToolResponse.model_validate(x) for x in data] + + def list_workflows(self, *, status: str | None = None) -> list[WorkflowListResponse]: + """List all workflows in the authenticated organization.""" + params: dict[str, Any] = {} + if status is not None: + params["status"] = status + data = self._request("GET", "/workflow/fetch", params=params) + return [WorkflowListResponse.model_validate(x) for x in data] + + def test_phone_call(self, *, body: InitiateCallRequest) -> Any: + """Place a test call from a workflow to a phone number.""" + return self._request("POST", "/telephony/initiate-call", json=body.model_dump(mode="json", exclude_none=True)) + + def update_workflow(self, workflow_id: int, *, body: UpdateWorkflowRequest) -> WorkflowResponse: + """Update a workflow's name and/or definition. Saves as a new draft.""" + data = self._request("PUT", f"/workflow/{workflow_id}", json=body.model_dump(mode="json", exclude_none=True)) + return WorkflowResponse.model_validate(data) diff --git a/sdk/python/src/dograh_sdk/_generated_models.py b/sdk/python/src/dograh_sdk/_generated_models.py new file mode 100644 index 0000000..c675ec9 --- /dev/null +++ b/sdk/python/src/dograh_sdk/_generated_models.py @@ -0,0 +1,358 @@ +# generated by datamodel-codegen: +# filename: dograh-openapi-XXXXXX.json.oPRfLAwVZP +# timestamp: 2026-04-21T02:15:12+00:00 + +from __future__ import annotations + +from enum import Enum +from typing import Annotated, Any + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field + + +class CallDispositionCodes(BaseModel): + disposition_codes: Annotated[list[str] | None, Field(title='Disposition Codes')] = ( + [] + ) + + +class CreatedByResponse(BaseModel): + """ + Response schema for the user who created a tool. + """ + + id: Annotated[int, Field(title='Id')] + provider_id: Annotated[str, Field(title='Provider Id')] + + +class CredentialResponse(BaseModel): + """ + Response schema for a webhook credential (never includes sensitive data). + """ + + uuid: Annotated[str, Field(title='Uuid')] + name: Annotated[str, Field(title='Name')] + description: Annotated[str | None, Field(title='Description')] + credential_type: Annotated[str, Field(title='Credential Type')] + created_at: Annotated[AwareDatetime, Field(title='Created At')] + updated_at: Annotated[AwareDatetime | None, Field(title='Updated At')] + + +class DisplayOptions(BaseModel): + """ + Conditional visibility rules. + + `show` keys are AND-combined: this property is visible only when EVERY + referenced field's value matches one of the listed values. + + `hide` keys are OR-combined: this property is hidden when ANY referenced + field's value matches one of the listed values. + + Example: + DisplayOptions(show={"extraction_enabled": [True]}) + DisplayOptions(show={"greeting_type": ["audio"]}) + """ + + model_config = ConfigDict( + extra='forbid', + ) + show: Annotated[dict[str, list[Any]] | None, Field(title='Show')] = None + hide: Annotated[dict[str, list[Any]] | None, Field(title='Hide')] = None + + +class DocumentResponseSchema(BaseModel): + """ + Response schema for document metadata. + """ + + id: Annotated[int, Field(title='Id')] + document_uuid: Annotated[str, Field(title='Document Uuid')] + filename: Annotated[str, Field(title='Filename')] + file_size_bytes: Annotated[int, Field(title='File Size Bytes')] + file_hash: Annotated[str, Field(title='File Hash')] + mime_type: Annotated[str, Field(title='Mime Type')] + processing_status: Annotated[str, Field(title='Processing Status')] + processing_error: Annotated[str | None, Field(title='Processing Error')] = None + total_chunks: Annotated[int, Field(title='Total Chunks')] + retrieval_mode: Annotated[str | None, Field(title='Retrieval Mode')] = 'chunked' + custom_metadata: Annotated[dict[str, Any], Field(title='Custom Metadata')] + docling_metadata: Annotated[dict[str, Any], Field(title='Docling Metadata')] + source_url: Annotated[str | None, Field(title='Source Url')] = None + created_at: Annotated[AwareDatetime, Field(title='Created At')] + updated_at: Annotated[AwareDatetime, Field(title='Updated At')] + organization_id: Annotated[int, Field(title='Organization Id')] + created_by: Annotated[int, Field(title='Created By')] + is_active: Annotated[bool, Field(title='Is Active')] + + +class GraphConstraints(BaseModel): + """ + Per-node-type graph rules. WorkflowGraph enforces these at validation. + """ + + model_config = ConfigDict( + extra='forbid', + ) + min_incoming: Annotated[int | None, Field(title='Min Incoming')] = None + max_incoming: Annotated[int | None, Field(title='Max Incoming')] = None + min_outgoing: Annotated[int | None, Field(title='Min Outgoing')] = None + max_outgoing: Annotated[int | None, Field(title='Max Outgoing')] = None + + +class InitiateCallRequest(BaseModel): + workflow_id: Annotated[int, Field(title='Workflow Id')] + workflow_run_id: Annotated[int | None, Field(title='Workflow Run Id')] = None + phone_number: Annotated[str | None, Field(title='Phone Number')] = None + + +class NodeCategory(Enum): + """ + Drives grouping in the AddNodePanel UI. + """ + + call_node = 'call_node' + global_node = 'global_node' + trigger = 'trigger' + integration = 'integration' + + +class NodeExample(BaseModel): + """ + A worked example LLMs can pattern-match. Keep small and realistic. + """ + + model_config = ConfigDict( + extra='forbid', + ) + name: Annotated[str, Field(title='Name')] + description: Annotated[str | None, Field(title='Description')] = None + data: Annotated[dict[str, Any], Field(title='Data')] + + +class PropertyOption(BaseModel): + """ + An option in an `options` or `multi_options` dropdown. + """ + + model_config = ConfigDict( + extra='forbid', + ) + value: Annotated[str | int | bool | float, Field(title='Value')] + label: Annotated[str, Field(title='Label')] + description: Annotated[str | None, Field(title='Description')] = None + + +class PropertyType(Enum): + """ + Bounded vocabulary of property types the renderer dispatches on. + + Adding a value here requires a matching arm in the frontend + `` switch and (where relevant) the SDK codegen template. + """ + + string = 'string' + number = 'number' + boolean = 'boolean' + options = 'options' + multi_options = 'multi_options' + fixed_collection = 'fixed_collection' + json = 'json' + tool_refs = 'tool_refs' + document_refs = 'document_refs' + recording_ref = 'recording_ref' + credential_ref = 'credential_ref' + mention_textarea = 'mention_textarea' + url = 'url' + + +class RecordingResponseSchema(BaseModel): + """ + Response schema for a single recording. + """ + + id: Annotated[int, Field(title='Id')] + recording_id: Annotated[str, Field(title='Recording Id')] + workflow_id: Annotated[int | None, Field(title='Workflow Id')] = None + organization_id: Annotated[int, Field(title='Organization Id')] + tts_provider: Annotated[str | None, Field(title='Tts Provider')] = None + tts_model: Annotated[str | None, Field(title='Tts Model')] = None + tts_voice_id: Annotated[str | None, Field(title='Tts Voice Id')] = None + transcript: Annotated[str, Field(title='Transcript')] + storage_key: Annotated[str, Field(title='Storage Key')] + storage_backend: Annotated[str, Field(title='Storage Backend')] + metadata: Annotated[dict[str, Any], Field(title='Metadata')] + created_by: Annotated[int, Field(title='Created By')] + created_at: Annotated[AwareDatetime, Field(title='Created At')] + is_active: Annotated[bool, Field(title='Is Active')] + + +class ToolResponse(BaseModel): + """ + Response schema for a tool. + """ + + id: Annotated[int, Field(title='Id')] + tool_uuid: Annotated[str, Field(title='Tool Uuid')] + name: Annotated[str, Field(title='Name')] + description: Annotated[str | None, Field(title='Description')] + category: Annotated[str, Field(title='Category')] + icon: Annotated[str | None, Field(title='Icon')] + icon_color: Annotated[str | None, Field(title='Icon Color')] + status: Annotated[str, Field(title='Status')] + definition: Annotated[dict[str, Any], Field(title='Definition')] + created_at: Annotated[AwareDatetime, Field(title='Created At')] + updated_at: Annotated[AwareDatetime | None, Field(title='Updated At')] + created_by: CreatedByResponse | None = None + + +class UpdateWorkflowRequest(BaseModel): + name: Annotated[str | None, Field(title='Name')] = None + workflow_definition: Annotated[ + dict[str, Any] | None, Field(title='Workflow Definition') + ] = None + template_context_variables: Annotated[ + dict[str, Any] | None, Field(title='Template Context Variables') + ] = None + workflow_configurations: Annotated[ + dict[str, Any] | None, Field(title='Workflow Configurations') + ] = None + + +class ValidationError(BaseModel): + loc: Annotated[list[str | int], Field(title='Location')] + msg: Annotated[str, Field(title='Message')] + type: Annotated[str, Field(title='Error Type')] + input: Annotated[Any | None, Field(title='Input')] = None + ctx: Annotated[dict[str, Any] | None, Field(title='Context')] = None + + +class WorkflowListResponse(BaseModel): + """ + Lightweight response for workflow listings (excludes large fields). + """ + + id: Annotated[int, Field(title='Id')] + name: Annotated[str, Field(title='Name')] + status: Annotated[str, Field(title='Status')] + created_at: Annotated[AwareDatetime, Field(title='Created At')] + total_runs: Annotated[int, Field(title='Total Runs')] + + +class WorkflowResponse(BaseModel): + id: Annotated[int, Field(title='Id')] + name: Annotated[str, Field(title='Name')] + status: Annotated[str, Field(title='Status')] + created_at: Annotated[AwareDatetime, Field(title='Created At')] + workflow_definition: Annotated[dict[str, Any], Field(title='Workflow Definition')] + current_definition_id: Annotated[int | None, Field(title='Current Definition Id')] + template_context_variables: Annotated[ + dict[str, Any] | None, Field(title='Template Context Variables') + ] = None + call_disposition_codes: CallDispositionCodes | None = None + total_runs: Annotated[int | None, Field(title='Total Runs')] = None + workflow_configurations: Annotated[ + dict[str, Any] | None, Field(title='Workflow Configurations') + ] = None + version_number: Annotated[int | None, Field(title='Version Number')] = None + version_status: Annotated[str | None, Field(title='Version Status')] = None + + +class DocumentListResponseSchema(BaseModel): + """ + Response schema for list of documents. + """ + + documents: Annotated[list[DocumentResponseSchema], Field(title='Documents')] + total: Annotated[int, Field(title='Total')] + limit: Annotated[int, Field(title='Limit')] + offset: Annotated[int, Field(title='Offset')] + + +class HTTPValidationError(BaseModel): + detail: Annotated[list[ValidationError] | None, Field(title='Detail')] = None + + +class PropertySpec(BaseModel): + """ + Single field on a node. + + `description` is HUMAN-FACING — shown under the field in the edit + dialog. Keep it concise and explain what the field does. + + `llm_hint` is LLM-FACING — appears only in the `get_node_type` MCP + response and in SDK schema output. Use it for catalog tool references + (e.g., "Use `list_recordings`"), array shape, expected value idioms, + or anything that would be noise in the UI. Optional; omit when the + `description` already suffices for both audiences. + """ + + model_config = ConfigDict( + extra='forbid', + ) + name: Annotated[str, Field(title='Name')] + type: PropertyType + display_name: Annotated[str, Field(title='Display Name')] + description: Annotated[str, Field(min_length=1, title='Description')] + """ + Human-facing explanation shown in the UI. + """ + llm_hint: Annotated[str | None, Field(title='Llm Hint')] = None + """ + LLM-only guidance; omitted from the UI. + """ + default: Annotated[Any | None, Field(title='Default')] = None + required: Annotated[bool | None, Field(title='Required')] = False + placeholder: Annotated[str | None, Field(title='Placeholder')] = None + display_options: DisplayOptions | None = None + options: Annotated[list[PropertyOption] | None, Field(title='Options')] = None + properties: Annotated[list[PropertySpec] | None, Field(title='Properties')] = None + min_value: Annotated[float | None, Field(title='Min Value')] = None + max_value: Annotated[float | None, Field(title='Max Value')] = None + min_length: Annotated[int | None, Field(title='Min Length')] = None + max_length: Annotated[int | None, Field(title='Max Length')] = None + pattern: Annotated[str | None, Field(title='Pattern')] = None + editor: Annotated[str | None, Field(title='Editor')] = None + extra: Annotated[dict[str, Any] | None, Field(title='Extra')] = None + + +class RecordingListResponseSchema(BaseModel): + """ + Response schema for list of recordings. + """ + + recordings: Annotated[list[RecordingResponseSchema], Field(title='Recordings')] + total: Annotated[int, Field(title='Total')] + + +class NodeSpec(BaseModel): + """ + Single source of truth for a node type. + """ + + model_config = ConfigDict( + extra='forbid', + ) + name: Annotated[str, Field(title='Name')] + display_name: Annotated[str, Field(title='Display Name')] + description: Annotated[str, Field(min_length=1, title='Description')] + """ + Human-facing explanation shown in AddNodePanel. + """ + llm_hint: Annotated[str | None, Field(title='Llm Hint')] = None + """ + LLM-only guidance; omitted from the UI. + """ + category: NodeCategory + icon: Annotated[str, Field(title='Icon')] + version: Annotated[str | None, Field(title='Version')] = '1.0.0' + properties: Annotated[list[PropertySpec], Field(title='Properties')] + examples: Annotated[list[NodeExample] | None, Field(title='Examples')] = None + graph_constraints: GraphConstraints | None = None + + +class NodeTypesResponse(BaseModel): + spec_version: Annotated[str, Field(title='Spec Version')] + node_types: Annotated[list[NodeSpec], Field(title='Node Types')] + + +PropertySpec.model_rebuild() diff --git a/sdk/python/src/dograh_sdk/_validation.py b/sdk/python/src/dograh_sdk/_validation.py new file mode 100644 index 0000000..ec7bf05 --- /dev/null +++ b/sdk/python/src/dograh_sdk/_validation.py @@ -0,0 +1,166 @@ +"""Client-side validation of node data against a fetched spec. + +Intentionally lightweight: we catch the hallucinations that matter (typo'd +field names, missing required fields, obvious scalar-type mismatches) and +leave rigorous coercion to the backend's Pydantic validators, which run at +save time. The tradeoff: the SDK fails fast on mistakes an LLM makes, and +the backend remains the single authority on wire-format correctness. +""" + +from __future__ import annotations + +from typing import Any + +from .errors import ValidationError + +# Map PropertyType → primitive Python type(s) we check at call site. +# `None` means "skip scalar-type check" (compound types, refs, JSON, etc.). +_SCALAR_TYPES: dict[str, tuple[type, ...] | None] = { + "string": (str,), + "number": (int, float), + "boolean": (bool,), + "options": None, # value-in-options handled separately + "multi_options": None, + "fixed_collection": (list,), + "json": None, # any JSON-serializable + "tool_refs": (list,), + "document_refs": (list,), + "recording_ref": (str,), + "credential_ref": (str,), + "mention_textarea": (str,), + "url": (str,), +} + + +def _with_hint(prop: dict[str, Any], message: str) -> str: + """Append `prop.llm_hint` to an error message when set. + + Surfacing the hint inside validation errors lets an LLM author + self-correct on retry — it sees the catalog reference or value-shape + guidance inline with the failure. + """ + hint = prop.get("llm_hint") + if hint: + return f"{message}\n Hint: {hint}" + return message + + +def _check_scalar(prop: dict[str, Any], value: Any) -> None: + # None is always allowed (missing value handled by required check). + if value is None: + return + allowed = _SCALAR_TYPES.get(prop["type"]) + if allowed is None: + return + # Booleans ARE ints in Python, so exclude accidentally-matching bools. + if bool in allowed and not (int in allowed or float in allowed): + if not isinstance(value, bool): + raise ValidationError( + _with_hint( + prop, + f"{prop['name']}: expected boolean, got {type(value).__name__}", + ) + ) + return + if not isinstance(value, allowed): + raise ValidationError( + _with_hint( + prop, + f"{prop['name']}: expected {prop['type']}, " + f"got {type(value).__name__}", + ) + ) + + +def _check_options(prop: dict[str, Any], value: Any) -> None: + if value is None: + return + allowed = {o["value"] for o in prop.get("options") or []} + if not allowed: + return + if prop["type"] == "multi_options": + if not isinstance(value, list): + raise ValidationError( + _with_hint( + prop, + f"{prop['name']}: expected list, got {type(value).__name__}", + ) + ) + bad = [v for v in value if v not in allowed] + if bad: + raise ValidationError( + _with_hint( + prop, + f"{prop['name']}: values {bad} not in allowed {sorted(allowed)}", + ) + ) + else: # 'options' + if value not in allowed: + raise ValidationError( + _with_hint( + prop, + f"{prop['name']}: {value!r} not in allowed {sorted(allowed)}", + ) + ) + + +def validate_node_data( + spec: dict[str, Any], + kwargs: dict[str, Any], +) -> dict[str, Any]: + """Validate LLM-supplied kwargs against the node spec. Returns the data + dict to embed in the wire format, with defaults applied. + + Raises: + ValidationError if kwargs contain unknown fields, omit required + fields, or carry obvious type mismatches. + """ + declared = {p["name"]: p for p in spec["properties"]} + + # Unknown field names — the most common LLM hallucination. + unknown = set(kwargs) - set(declared) + if unknown: + raise ValidationError( + f"{spec['name']}: unknown field(s) {sorted(unknown)}. " + f"Allowed: {sorted(declared)}" + ) + + # Per-property validation + data: dict[str, Any] = {} + for name, prop in declared.items(): + if name in kwargs: + value = kwargs[name] + elif prop.get("default") is not None: + value = prop["default"] + else: + value = None + + # Scalar / collection shape + if prop["type"] in ("options", "multi_options"): + _check_options(prop, value) + else: + _check_scalar(prop, value) + + # Nested fixed_collection rows — validate each row as a sub-spec. + if prop["type"] == "fixed_collection" and isinstance(value, list): + sub_spec = {"name": f"{spec['name']}.{name}", "properties": prop.get("properties") or []} + data[name] = [validate_node_data(sub_spec, row) for row in value] + continue + + if value is not None: + data[name] = value + + # Required check — must be set AND non-empty for strings. + for name, prop in declared.items(): + if not prop.get("required"): + continue + val = data.get(name) + if val is None or (isinstance(val, str) and val.strip() == ""): + raise ValidationError( + _with_hint( + prop, + f"{spec['name']}: required field missing: {name}", + ) + ) + + return data diff --git a/sdk/python/src/dograh_sdk/client.py b/sdk/python/src/dograh_sdk/client.py new file mode 100644 index 0000000..5a04ecb --- /dev/null +++ b/sdk/python/src/dograh_sdk/client.py @@ -0,0 +1,151 @@ +"""HTTP client for the Dograh REST API. + +Most endpoint methods come from `_GeneratedClient` (auto-generated from +the FastAPI OpenAPI spec — see `scripts/generate_sdk.sh`). This class +adds the session/auth/cache surface around that mixin plus a couple of +ergonomic wrappers (`load_workflow`, `save_workflow`) that compose a +generated call with local `Workflow` hydration. + +The SDK surface on the backend is controlled by decorating routes with +`@sdk_expose(method="...")`; anything else is invisible here. +""" + +from __future__ import annotations + +import os +from typing import Any + +import httpx + +from ._generated_client import _GeneratedClient +from ._generated_models import ( + NodeSpec, + NodeTypesResponse, + UpdateWorkflowRequest, + WorkflowResponse, +) +from .errors import ApiError, SpecMismatchError +from .workflow import Workflow + + +class DograhClient(_GeneratedClient): + """Sync HTTP client. Suitable for scripts, pytest, and the LLM SDK + exec sandbox. + + Auth precedence: + 1. `api_key` kwarg + 2. `DOGRAH_API_KEY` env var + 3. unauthenticated (most endpoints will 401) + """ + + def __init__( + self, + *, + base_url: str | None = None, + api_key: str | None = None, + timeout: float = 30.0, + ): + resolved_url = base_url or os.environ.get( + "DOGRAH_API_URL", "http://localhost:8000" + ) + self.base_url = resolved_url.rstrip("/") + self.api_key = api_key or os.environ.get("DOGRAH_API_KEY") + + headers = {"Accept": "application/json"} + if self.api_key: + headers["X-API-Key"] = self.api_key + + self._http = httpx.Client( + base_url=f"{self.base_url}/api/v1", + headers=headers, + timeout=timeout, + ) + + # Populated by the first call to `list_node_types` / `get_node_type` + # — avoids repeated round-trips when building a workflow. + self._spec_cache: dict[str, NodeSpec] = {} + self._spec_version: str | None = None + + def close(self) -> None: + self._http.close() + + def __enter__(self) -> DograhClient: + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + @property + def spec_version(self) -> str | None: + """Contract version reported by the server, or None until the + first `list_node_types` / `get_node_type` call.""" + return self._spec_version + + # ── spec discovery overrides (generated methods + caching) ──────── + + def list_node_types(self) -> NodeTypesResponse: + resp = super().list_node_types() + self._spec_version = resp.spec_version + for spec in resp.node_types: + self._spec_cache[spec.name] = spec + return resp + + def get_node_type(self, name: str) -> NodeSpec: + cached = self._spec_cache.get(name) + if cached is not None: + return cached + try: + spec = super().get_node_type(name) + except ApiError as e: + if e.status_code == 404: + raise SpecMismatchError(f"Unknown node type: {name!r}") from e + raise + self._spec_cache[name] = spec + return spec + + # ── ergonomic workflow wrappers ─────────────────────────────────── + + def load_workflow(self, workflow_id: int) -> Workflow: + """Fetch a workflow and hydrate it into an editable `Workflow` builder.""" + resp = self.get_workflow(workflow_id) + if not resp.workflow_definition: + raise ApiError( + 200, + f"Workflow {workflow_id} has no definition to load", + body=resp.model_dump(mode="json"), + ) + return Workflow.from_json( + resp.workflow_definition, client=self, name=resp.name + ) + + def save_workflow(self, workflow_id: int, workflow: Workflow) -> WorkflowResponse: + """Persist a `Workflow` builder back to the server as a new draft.""" + return self.update_workflow( + workflow_id, + body=UpdateWorkflowRequest( + name=workflow.name, + workflow_definition=workflow.to_json(), + ), + ) + + # ── low-level ────────────────────────────────────────────────── + + def _request(self, method: str, path: str, **kwargs: Any) -> Any: + resp = self._http.request(method, path, **kwargs) + if resp.status_code >= 400: + try: + body = resp.json() + if isinstance(body, dict): + message = body.get("detail") or body.get("message") or resp.text + else: + message = resp.text + except ValueError: + body = resp.text + message = resp.text + raise ApiError(resp.status_code, message, body=body) + if resp.status_code == 204 or not resp.content: + return None + try: + return resp.json() + except ValueError: + return resp.text diff --git a/sdk/python/src/dograh_sdk/codegen.py b/sdk/python/src/dograh_sdk/codegen.py new file mode 100644 index 0000000..9d5de7d --- /dev/null +++ b/sdk/python/src/dograh_sdk/codegen.py @@ -0,0 +1,332 @@ +"""Typed SDK code generator. + +Reads NodeSpecs (from the live backend, a JSON file, or the in-process +registry) and emits a dataclass per node type into an output directory. +The generated files live under `dograh_sdk.typed` and are committed to +the repository so `pip install dograh-sdk` ships typed classes without +requiring a regen step. + +Run manually: + + python -m dograh_sdk.codegen --api http://localhost:8000 \\ + --out sdk/python/src/dograh_sdk/typed + + python -m dograh_sdk.codegen --input specs.json \\ + --out ./my_typed +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import textwrap +from pathlib import Path +from typing import Any + +# ── property type → Python type annotation ──────────────────────────────── + +_SCALAR_PY_TYPES = { + "string": "str", + "number": "float", + "boolean": "bool", + "json": "dict[str, Any]", + "mention_textarea": "str", + "url": "str", + "recording_ref": "str", + "credential_ref": "str", + "tool_refs": "list[str]", + "document_refs": "list[str]", +} + + +def _snake_to_camel(name: str) -> str: + """`start_call` → `StartCall` (class-name case).""" + return "".join(part.capitalize() or "_" for part in name.split("_")) + + +def _spec_class_name(spec_name: str) -> str: + # startCall → StartCall; agentNode → AgentNode; qa → Qa + if not spec_name: + return "Node" + return spec_name[0].upper() + spec_name[1:] + + +def _safe_py_repr(value: Any) -> str: + """Render a JSON-serializable value as a Python literal.""" + return repr(value) + + +def _py_type_for(prop: dict[str, Any], owner_class_name: str) -> tuple[str, str]: + """Return (type_annotation, default_source) for one property. + + Defaults are expressed as source code — e.g., `"Start Call"`, `False`, + `field(default_factory=list)`, etc. An empty string means "no default" + (the field is required for the dataclass). + """ + t = prop["type"] + required = bool(prop.get("required")) + has_spec_default = prop.get("default") is not None + + # Compound types first + if t == "options": + options = prop.get("options") or [] + literals = ", ".join(repr(o["value"]) for o in options) + annotation = f"Literal[{literals}]" if literals else "str" + elif t == "multi_options": + options = prop.get("options") or [] + literals = ", ".join(repr(o["value"]) for o in options) + inner = f"Literal[{literals}]" if literals else "str" + annotation = f"list[{inner}]" + elif t == "fixed_collection": + row_class = f"{owner_class_name}_{_spec_class_name(prop['name'])}Row" + annotation = f"list[{row_class}]" + else: + annotation = _SCALAR_PY_TYPES.get(t, "Any") + + # Required fields without a spec default get no dataclass default + # (the user must set them). Optional fields default to None if the + # spec doesn't declare anything, or to the spec's default literal. + if has_spec_default: + spec_default = prop["default"] + if isinstance(spec_default, (dict, list, set)): + # Mutable defaults require default_factory — can't appear + # inline on a dataclass field. + default_src = f"field(default_factory=lambda: {spec_default!r})" + else: + default_src = _safe_py_repr(spec_default) + elif required: + default_src = "" # no default — caller must pass a value + elif t == "multi_options" or t == "fixed_collection" or t in ( + "tool_refs", + "document_refs", + ): + default_src = "field(default_factory=list)" + else: + default_src = "None" + annotation = f"Optional[{annotation}]" + + return annotation, default_src + + +def _format_docstring(text: str, indent: int = 4) -> str: + """Wrap a description into a triple-quoted docstring.""" + pad = " " * indent + wrapped = textwrap.fill( + text.strip(), + width=76, + initial_indent=pad, + subsequent_indent=pad, + ) + return f'{pad}"""\n{wrapped}\n{pad}"""' + + +# ── source rendering ───────────────────────────────────────────────────── + +_FILE_HEADER = '''"""GENERATED — do not edit by hand. + +Regenerate with `python -m dograh_sdk.codegen` against the target +Dograh backend. Source of truth: each node's NodeSpec in the backend's +`api/services/workflow/node_specs/` directory. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, ClassVar, Literal, Optional + +from dograh_sdk.typed._base import TypedNode +''' + + +def _render_nested_row_dataclass( + owner_class_name: str, + parent_prop: dict[str, Any], +) -> str: + row_class = f"{owner_class_name}_{_spec_class_name(parent_prop['name'])}Row" + props = parent_prop.get("properties") or [] + lines = [f"@dataclass(kw_only=True)", f"class {row_class}:"] + + desc = parent_prop.get("description") or "Row in " + parent_prop["name"] + lines.append(_format_docstring(desc)) + lines.append("") + + if not props: + lines.append(" pass") + return "\n".join(lines) + + for sub in props: + annotation, default_src = _py_type_for(sub, row_class) + if default_src: + lines.append(f" {sub['name']}: {annotation} = {default_src}") + else: + lines.append(f" {sub['name']}: {annotation}") + if sub.get("description"): + lines.append(_format_docstring(sub["description"])) + return "\n".join(lines) + + +def _render_spec_class(spec: dict[str, Any]) -> str: + class_name = _spec_class_name(spec["name"]) + lines: list[str] = [] + + # Emit nested row dataclasses first so the main class can reference them. + nested_rendered: list[str] = [] + for prop in spec.get("properties", []): + if prop["type"] == "fixed_collection": + nested_rendered.append( + _render_nested_row_dataclass(class_name, prop) + ) + lines.extend(nested_rendered) + if nested_rendered: + lines.append("") + + lines.append("@dataclass(kw_only=True)") + lines.append(f"class {class_name}(TypedNode):") + + # Class docstring: description + optional llm_hint + description = spec.get("description") or "" + llm_hint = spec.get("llm_hint") + doc_text = description + if llm_hint: + doc_text = f"{description}\n\nLLM hint: {llm_hint}" + lines.append(_format_docstring(doc_text)) + lines.append("") + + # Spec-name discriminator + lines.append(f' type: ClassVar[str] = {spec["name"]!r}') + lines.append("") + + # Split fields into "has default" and "required-no-default" so we can + # emit required ones first (dataclass rule, even though we use + # kw_only=True — still cleaner output). + with_defaults: list[tuple[dict, str, str]] = [] + without_defaults: list[tuple[dict, str]] = [] + + for prop in spec.get("properties", []): + annotation, default_src = _py_type_for(prop, class_name) + if default_src: + with_defaults.append((prop, annotation, default_src)) + else: + without_defaults.append((prop, annotation)) + + for prop, annotation in without_defaults: + lines.append(f" {prop['name']}: {annotation}") + if prop.get("description"): + lines.append(_format_docstring(prop["description"])) + lines.append("") + + for prop, annotation, default_src in with_defaults: + lines.append(f" {prop['name']}: {annotation} = {default_src}") + if prop.get("description"): + lines.append(_format_docstring(prop["description"])) + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +def _render_init_module(spec_names: list[str]) -> str: + lines = [ + '"""GENERATED — do not edit by hand.', + "", + "Re-exports every typed node class so users can write", + "`from dograh_sdk.typed import StartCall, AgentNode`.", + '"""', + "", + ] + exports: list[str] = [] + for spec_name in sorted(spec_names): + module_name = re.sub(r"(? str: + """startCall → start_call.py; agentNode → agent_node.py.""" + return re.sub(r"(? None: + """Emit typed dataclasses for every spec into `out_dir`. + + Preserves `out_dir/_base.py` if present (it's hand-written). Writes + one `.py` file per spec and regenerates `__init__.py`. + """ + out_dir.mkdir(parents=True, exist_ok=True) + + for spec in specs: + module_name = _module_name_for(spec["name"]) + source = _FILE_HEADER + "\n\n" + _render_spec_class(spec) + "\n" + (out_dir / f"{module_name}.py").write_text(source) + + (out_dir / "__init__.py").write_text( + _render_init_module([s["name"] for s in specs]) + ) + + +def _load_specs_from_json(path: Path) -> list[dict[str, Any]]: + raw = json.loads(path.read_text()) + if isinstance(raw, dict) and "node_types" in raw: + return raw["node_types"] + if isinstance(raw, list): + return raw + raise SystemExit(f"{path}: expected list or {{node_types: [...]}}") + + +def _load_specs_from_api(base_url: str) -> list[dict[str, Any]]: + import httpx + + resp = httpx.get( + f"{base_url.rstrip('/')}/api/v1/node-types", + timeout=30.0, + ) + resp.raise_for_status() + body = resp.json() + return body.get("node_types", []) + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser( + prog="python -m dograh_sdk.codegen", + description="Generate typed SDK dataclasses from the Dograh node-spec catalog.", + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--api", help="Dograh backend base URL") + source.add_argument("--input", help="Local JSON file with specs") + parser.add_argument( + "--out", required=True, help="Output directory for generated modules" + ) + args = parser.parse_args(argv) + + if args.api: + specs = _load_specs_from_api(args.api) + else: + specs = _load_specs_from_json(Path(args.input)) + + out_dir = Path(args.out) + generate_all(specs, out_dir) + + print( + f"Generated {len(specs)} typed node modules " + f"({', '.join(s['name'] for s in specs)}) into {out_dir}" + ) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/sdk/python/src/dograh_sdk/errors.py b/sdk/python/src/dograh_sdk/errors.py new file mode 100644 index 0000000..c3cd5a3 --- /dev/null +++ b/sdk/python/src/dograh_sdk/errors.py @@ -0,0 +1,32 @@ +"""SDK-level exceptions. + +All errors raised from `dograh_sdk` are subclasses of `DograhSdkError` so +calling code can catch them as one category. +""" + + +class DograhSdkError(Exception): + """Base class for all SDK errors.""" + + +class ValidationError(DograhSdkError): + """Raised when node data fails client-side validation (unknown field, + missing required field, obvious type mismatch). + + Server-side Pydantic validation runs on save and may raise further + errors via `ApiError` — this class covers the fast-fail cases caught + at the `Workflow.add()` call site. + """ + + +class ApiError(DograhSdkError): + """Raised when the Dograh backend returns a non-2xx response.""" + + def __init__(self, status_code: int, message: str, body: object = None): + super().__init__(f"[{status_code}] {message}") + self.status_code = status_code + self.body = body + + +class SpecMismatchError(DograhSdkError): + """Raised when a referenced node type isn't registered on the server.""" diff --git a/sdk/python/src/dograh_sdk/typed/__init__.py b/sdk/python/src/dograh_sdk/typed/__init__.py new file mode 100644 index 0000000..b5dcea3 --- /dev/null +++ b/sdk/python/src/dograh_sdk/typed/__init__.py @@ -0,0 +1,25 @@ +"""GENERATED — do not edit by hand. + +Re-exports every typed node class so users can write +`from dograh_sdk.typed import StartCall, AgentNode`. +""" + +from dograh_sdk.typed.agent_node import AgentNode +from dograh_sdk.typed.end_call import EndCall +from dograh_sdk.typed.global_node import GlobalNode +from dograh_sdk.typed.qa import Qa +from dograh_sdk.typed.start_call import StartCall +from dograh_sdk.typed.trigger import Trigger +from dograh_sdk.typed.webhook import Webhook +from dograh_sdk.typed._base import TypedNode + +__all__ = [ + "AgentNode", + "EndCall", + "GlobalNode", + "Qa", + "StartCall", + "Trigger", + "TypedNode", + "Webhook", +] diff --git a/sdk/python/src/dograh_sdk/typed/_base.py b/sdk/python/src/dograh_sdk/typed/_base.py new file mode 100644 index 0000000..7c2e038 --- /dev/null +++ b/sdk/python/src/dograh_sdk/typed/_base.py @@ -0,0 +1,49 @@ +"""Base class for generated per-node-type dataclasses. + +The typed SDK (`dograh_sdk.typed`) contains one generated dataclass per +node spec. Each subclass declares its spec name as a class-level `type` +and carries fields mirroring the spec's properties — giving IDEs full +autocomplete, docstrings on hover, and mypy/pyright coverage. + +At runtime the typed objects feed into `Workflow.add_typed(node)`, which +unpacks them into the same kwargs the generic `add()` already accepts. +Wire format and validation rules are unchanged — typed SDK is an +ergonomic layer, not a second validator. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any, ClassVar + + +@dataclass(kw_only=True) +class TypedNode: + """Common base for every generated typed node class. + + Subclasses override `type` with their spec name (e.g. `"startCall"`). + Subclasses should be declared with `@dataclass(kw_only=True)` so + required fields can appear after optional ones without triggering + Python's default-ordering rule. + """ + + # Overridden per subclass via dataclass inheritance + ClassVar shadowing. + type: ClassVar[str] = "" + + def to_dict(self) -> dict[str, Any]: + """Dataclass fields as a plain dict, suitable for feeding directly + into `Workflow.add(type=..., **kwargs)`. + + `type` is a ClassVar and is NOT included — the caller passes it + separately. + + Fields with "unset" sentinels (`None`, empty list) are filtered + out so the output matches what `Workflow.add(**kwargs)` would + produce when the user omits them. Downstream validation applies + spec defaults for absent keys. + """ + raw = asdict(self) + return { + k: v for k, v in raw.items() + if v is not None and v != [] + } diff --git a/sdk/python/src/dograh_sdk/typed/agent_node.py b/sdk/python/src/dograh_sdk/typed/agent_node.py new file mode 100644 index 0000000..3b8c21e --- /dev/null +++ b/sdk/python/src/dograh_sdk/typed/agent_node.py @@ -0,0 +1,97 @@ +"""GENERATED — do not edit by hand. + +Regenerate with `python -m dograh_sdk.codegen` against the target +Dograh backend. Source of truth: each node's NodeSpec in the backend's +`api/services/workflow/node_specs/` directory. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, ClassVar, Literal, Optional + +from dograh_sdk.typed._base import TypedNode + + +@dataclass(kw_only=True) +class AgentNode_Extraction_variablesRow: + """ + Each entry declares one variable to capture from the conversation, with + its name, type, and per-variable hint. + """ + + name: str + """ + snake_case identifier used downstream. + """ + type: Literal['string', 'number', 'boolean'] = 'string' + """ + Data type of the extracted value. + """ + prompt: Optional[str] = None + """ + Per-variable hint describing what to look for. + """ + +@dataclass(kw_only=True) +class AgentNode(TypedNode): + """ + Conversational step — the LLM runs one focused exchange. LLM hint: Mid- + call step executed by the LLM. Most workflows are a chain of agent nodes + connected by edges that describe transition conditions. Each agent node + can invoke tools and reference documents. + """ + + type: ClassVar[str] = 'agentNode' + + prompt: str + """ + Agent system prompt for this step. Supports {{template_variables}} from + extraction or pre-call fetch. + """ + + name: str = 'Agent' + """ + Short identifier for this step (e.g., 'Qualify Budget'). Appears in call + logs and edge transition tools. + """ + + allow_interrupt: bool = True + """ + When true, the user can interrupt the agent mid-utterance. Set false for + non-interruptible disclosures. + """ + + add_global_prompt: bool = True + """ + When true and a Global node exists, prepends the global prompt to this + node's prompt at runtime. + """ + + extraction_enabled: bool = False + """ + When true, runs an LLM extraction pass on transition out of this node to + capture variables from the conversation. + """ + + extraction_prompt: Optional[str] = None + """ + Overall instructions guiding variable extraction. + """ + + extraction_variables: list[AgentNode_Extraction_variablesRow] = field(default_factory=list) + """ + Each entry declares one variable to capture from the conversation, with + its name, type, and per-variable hint. + """ + + tool_uuids: list[str] = field(default_factory=list) + """ + Tools the agent can invoke during this step. + """ + + document_uuids: list[str] = field(default_factory=list) + """ + Documents the agent can reference during this step. + """ + diff --git a/sdk/python/src/dograh_sdk/typed/end_call.py b/sdk/python/src/dograh_sdk/typed/end_call.py new file mode 100644 index 0000000..737205c --- /dev/null +++ b/sdk/python/src/dograh_sdk/typed/end_call.py @@ -0,0 +1,82 @@ +"""GENERATED — do not edit by hand. + +Regenerate with `python -m dograh_sdk.codegen` against the target +Dograh backend. Source of truth: each node's NodeSpec in the backend's +`api/services/workflow/node_specs/` directory. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, ClassVar, Literal, Optional + +from dograh_sdk.typed._base import TypedNode + + +@dataclass(kw_only=True) +class EndCall_Extraction_variablesRow: + """ + Each entry declares one variable to capture from the conversation, with + its name, data type, and a per-variable extraction hint. + """ + + name: str + """ + snake_case identifier used downstream. + """ + type: Literal['string', 'number', 'boolean'] = 'string' + """ + The data type of the extracted value. + """ + prompt: Optional[str] = None + """ + Per-variable hint describing what to look for in the conversation. + """ + +@dataclass(kw_only=True) +class EndCall(TypedNode): + """ + Closes the conversation and hangs up. LLM hint: Terminal node that + politely closes the conversation. Variable extraction can run before + hangup. A workflow can have multiple endCall nodes reached via different + edge conditions. + """ + + type: ClassVar[str] = 'endCall' + + prompt: str + """ + Agent system prompt for the closing exchange. Supports + {{template_variables}} from extraction or pre-call fetch. + """ + + name: str = 'End Call' + """ + Short identifier shown in call logs. Should describe the ending context + (e.g., 'Successful close', 'Polite decline'). + """ + + add_global_prompt: bool = False + """ + When true and a Global node exists, prepends the global prompt to this + node's prompt at runtime. + """ + + extraction_enabled: bool = False + """ + When true, runs an LLM extraction pass before hangup to capture + variables from the conversation. + """ + + extraction_prompt: Optional[str] = None + """ + Overall instructions guiding how variables should be extracted from the + conversation. + """ + + extraction_variables: list[EndCall_Extraction_variablesRow] = field(default_factory=list) + """ + Each entry declares one variable to capture from the conversation, with + its name, data type, and a per-variable extraction hint. + """ + diff --git a/sdk/python/src/dograh_sdk/typed/global_node.py b/sdk/python/src/dograh_sdk/typed/global_node.py new file mode 100644 index 0000000..6453230 --- /dev/null +++ b/sdk/python/src/dograh_sdk/typed/global_node.py @@ -0,0 +1,38 @@ +"""GENERATED — do not edit by hand. + +Regenerate with `python -m dograh_sdk.codegen` against the target +Dograh backend. Source of truth: each node's NodeSpec in the backend's +`api/services/workflow/node_specs/` directory. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, ClassVar, Literal, Optional + +from dograh_sdk.typed._base import TypedNode + + +@dataclass(kw_only=True) +class GlobalNode(TypedNode): + """ + Persona/tone appended to every agent node's prompt. LLM hint: System- + level prompt appended to every prompted node whose `add_global_prompt` + is true. Use it for persona, tone, and shared rules that apply across + the entire conversation. At most one global node per workflow. + """ + + type: ClassVar[str] = 'globalNode' + + name: str = 'Global Node' + """ + Short identifier shown in the canvas and call logs. Has no runtime + effect. + """ + + prompt: str = "You are a helpful assistant whose mode of interaction with the user is voice. So don't use any special characters which can not be pronounced. Use short sentences and simple language." + """ + Text appended to every prompted node's system prompt when that node has + `add_global_prompt=true`. Supports {{template_variables}}. + """ + diff --git a/sdk/python/src/dograh_sdk/typed/qa.py b/sdk/python/src/dograh_sdk/typed/qa.py new file mode 100644 index 0000000..339e2af --- /dev/null +++ b/sdk/python/src/dograh_sdk/typed/qa.py @@ -0,0 +1,87 @@ +"""GENERATED — do not edit by hand. + +Regenerate with `python -m dograh_sdk.codegen` against the target +Dograh backend. Source of truth: each node's NodeSpec in the backend's +`api/services/workflow/node_specs/` directory. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, ClassVar, Literal, Optional + +from dograh_sdk.typed._base import TypedNode + + +@dataclass(kw_only=True) +class Qa(TypedNode): + """ + Run LLM quality analysis on the call transcript. LLM hint: Runs an LLM + quality review on the call transcript after completion. Per-node + analysis splits the conversation by node and evaluates each segment + against the configured system prompt. Sampling, minimum duration, and + voicemail filters are supported. + """ + + type: ClassVar[str] = 'qa' + + name: str = 'QA Analysis' + """ + Short identifier for this QA configuration. + """ + + qa_enabled: bool = True + """ + When false, the QA run is skipped. + """ + + qa_system_prompt: str = 'You are a QA analyst evaluating a specific segment of a voice AI conversation.\n\n## Node Purpose\n{{node_summary}}\n\n## Previous Conversation Context (For start of conversation, previous conversation summary can be empty.)\n{{previous_conversation_summary}}\n\n## Tags to evaluate\n\nExamine the conversation carefully and identify which of the following tags apply:\n\n- UNCLEAR_CONVERSATION - The conversation is not coherent or clear, messages don\'t connect logically\n- ASSISTANT_IN_LOOP - The assistant asks the same question multiple times or gets stuck repeating itself\n- ASSISTANT_REPLY_IMPROPER - The assistant did not reply properly to the user\'s question/query or seems confused by what the user said\n- USER_FRUSTRATED - The user seems angry, frustrated, or is complaining about something in the call\n- USER_NOT_UNDERSTANDING - The user explicitly says they don\'t understand or repeatedly asks for clarification\n- HEARING_ISSUES - Either party can\'t hear the other ("hello?", "are you there?", "can you hear me?")\n- DEAD_AIR - Unusually long silences in the conversation (use the timestamps to judge)\n- USER_REQUESTING_FEATURE - The user asks for something the assistant can\'t fulfill\n- ASSISTANT_LACKS_EMPATHY - The assistant ignores the user\'s personal situation or emotional state and continues pitching or pushing the agenda.\n- USER_DETECTS_AI - The user suspects or identifies that they are talking to an AI/robot/bot rather than a real human.\n\n## Call metrics (pre-computed)\n\nUse these alongside the transcript for your analysis:\n{{metrics}}\n\n## Output format\n\nReturn ONLY a valid JSON object (no markdown):\n{\n "tags": [\n {\n "tag": "TAG_NAME",\n "reason": "Short reason with evidence from the transcript"\n }\n ],\n "overall_sentiment": "positive|neutral|negative",\n "call_quality_score": <1-10>,\n "summary": "1-2 sentence summary of this segment"\n}\n\nIf no tags apply, return an empty tags list. Always provide sentiment, score, and summary.' + """ + Instructions to the QA reviewer LLM. Supports placeholders: + `{node_summary}`, `{previous_conversation_summary}`, `{transcript}`, + `{metrics}`. + """ + + qa_min_call_duration: float = 15 + """ + Calls shorter than this are skipped. + """ + + qa_voicemail_calls: bool = False + """ + When false, calls flagged as voicemail are skipped. + """ + + qa_sample_rate: float = 100 + """ + Percent of eligible calls QA'd. 100 means every call; lower values use + random sampling. + """ + + qa_use_workflow_llm: bool = True + """ + When true, the QA pass uses the same LLM the workflow runs with. Set + false to specify a separate provider/model. + """ + + qa_provider: Optional[Literal['openai', 'azure', 'openrouter', 'anthropic']] = None + """ + LLM provider used for the QA pass. + """ + + qa_model: str = 'default' + """ + Model identifier (e.g., 'gpt-4o', 'claude-sonnet-4-6'). Provider- + specific. + """ + + qa_api_key: Optional[str] = None + """ + API key for the chosen provider. + """ + + qa_endpoint: Optional[str] = None + """ + Required for the Azure provider. + """ + diff --git a/sdk/python/src/dograh_sdk/typed/start_call.py b/sdk/python/src/dograh_sdk/typed/start_call.py new file mode 100644 index 0000000..b9e2562 --- /dev/null +++ b/sdk/python/src/dograh_sdk/typed/start_call.py @@ -0,0 +1,142 @@ +"""GENERATED — do not edit by hand. + +Regenerate with `python -m dograh_sdk.codegen` against the target +Dograh backend. Source of truth: each node's NodeSpec in the backend's +`api/services/workflow/node_specs/` directory. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, ClassVar, Literal, Optional + +from dograh_sdk.typed._base import TypedNode + + +@dataclass(kw_only=True) +class StartCall_Extraction_variablesRow: + """ + Each entry declares one variable to capture, with its name, data type, + and per-variable extraction hint. + """ + + name: str + """ + snake_case identifier used downstream. + """ + type: Literal['string', 'number', 'boolean'] = 'string' + """ + Data type of the extracted value. + """ + prompt: Optional[str] = None + """ + Per-variable hint describing what to look for. + """ + +@dataclass(kw_only=True) +class StartCall(TypedNode): + """ + Entry point of the workflow — plays a greeting and opens the + conversation. LLM hint: The entry point of every workflow (exactly one + required). Plays an optional greeting, can fetch context from an + external API before the call begins, and executes the first + conversational turn. + """ + + type: ClassVar[str] = 'startCall' + + prompt: str + """ + Agent system prompt for the opening turn. Supports + {{template_variables}} from pre-call fetch and the initial context. + """ + + name: str = 'Start Call' + """ + Short identifier shown in the canvas and call logs. + """ + + greeting_type: Literal['text', 'audio'] = 'text' + """ + Whether the optional greeting is spoken via TTS from text or played from + a pre-recorded audio file. + """ + + greeting: Optional[str] = None + """ + Text spoken via TTS at the start of the call. Supports + {{template_variables}}. Leave empty to skip the greeting. + """ + + greeting_recording_id: Optional[str] = None + """ + Pre-recorded audio file played at the start of the call. + """ + + allow_interrupt: bool = False + """ + When true, the user can interrupt the agent mid-utterance. + """ + + add_global_prompt: bool = True + """ + When true and a Global node exists, prepends the global prompt to this + node's prompt at runtime. + """ + + delayed_start: bool = False + """ + When true, the agent waits before speaking after pickup. Useful for + outbound calls where the called party needs a moment to settle. + """ + + delayed_start_duration: float = 2.0 + """ + Seconds to wait before the agent speaks. 0.1–10. + """ + + extraction_enabled: bool = False + """ + When true, runs an LLM extraction pass on transition out of this node to + capture variables from the opening turn. + """ + + extraction_prompt: Optional[str] = None + """ + Overall instructions guiding variable extraction. + """ + + extraction_variables: list[StartCall_Extraction_variablesRow] = field(default_factory=list) + """ + Each entry declares one variable to capture, with its name, data type, + and per-variable extraction hint. + """ + + tool_uuids: list[str] = field(default_factory=list) + """ + Tools the agent can invoke during the opening turn. + """ + + document_uuids: list[str] = field(default_factory=list) + """ + Documents the agent can reference. + """ + + pre_call_fetch_enabled: bool = False + """ + When true, makes a POST request to an external API before the call + starts and merges the JSON response into the call context as template + variables. + """ + + pre_call_fetch_url: Optional[str] = None + """ + URL the pre-call POST request is sent to. The request body includes + caller and called numbers. + """ + + pre_call_fetch_credential_uuid: Optional[str] = None + """ + Optional credential attached to the pre-call request. + """ + diff --git a/sdk/python/src/dograh_sdk/typed/trigger.py b/sdk/python/src/dograh_sdk/typed/trigger.py new file mode 100644 index 0000000..e70ac4a --- /dev/null +++ b/sdk/python/src/dograh_sdk/typed/trigger.py @@ -0,0 +1,42 @@ +"""GENERATED — do not edit by hand. + +Regenerate with `python -m dograh_sdk.codegen` against the target +Dograh backend. Source of truth: each node's NodeSpec in the backend's +`api/services/workflow/node_specs/` directory. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, ClassVar, Literal, Optional + +from dograh_sdk.typed._base import TypedNode + + +@dataclass(kw_only=True) +class Trigger(TypedNode): + """ + Public HTTP endpoint that launches the workflow. LLM hint: Exposes a + public HTTP POST endpoint. External systems call the URL (derived from + the auto-generated `trigger_path`) to launch this workflow. Requires an + API key in the `X-API-Key` header. + """ + + type: ClassVar[str] = 'trigger' + + name: str = 'API Trigger' + """ + Short identifier shown in the canvas. No runtime effect. + """ + + enabled: bool = True + """ + When false, the trigger URL returns 404. + """ + + trigger_path: Optional[str] = None + """ + Auto-generated UUID-style path segment that uniquely identifies this + trigger. Do not edit manually. + """ + diff --git a/sdk/python/src/dograh_sdk/typed/webhook.py b/sdk/python/src/dograh_sdk/typed/webhook.py new file mode 100644 index 0000000..fe35bf6 --- /dev/null +++ b/sdk/python/src/dograh_sdk/typed/webhook.py @@ -0,0 +1,84 @@ +"""GENERATED — do not edit by hand. + +Regenerate with `python -m dograh_sdk.codegen` against the target +Dograh backend. Source of truth: each node's NodeSpec in the backend's +`api/services/workflow/node_specs/` directory. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, ClassVar, Literal, Optional + +from dograh_sdk.typed._base import TypedNode + + +@dataclass(kw_only=True) +class Webhook_Custom_headersRow: + """ + Additional HTTP headers to include with the request. + """ + + key: str + """ + HTTP header name (e.g., 'X-Source'). + """ + value: str + """ + Header value (supports {{template_variables}}). + """ + +@dataclass(kw_only=True) +class Webhook(TypedNode): + """ + Send HTTP request after the workflow completes. LLM hint: Sends an HTTP + request to an external system after the workflow completes. The payload + is a Jinja-templated JSON body with access to `workflow_run_id`, + `initial_context`, `gathered_context`, `annotations`, and call metadata. + """ + + type: ClassVar[str] = 'webhook' + + name: str = 'Webhook' + """ + Short identifier shown in the canvas and run logs. + """ + + enabled: bool = True + """ + When false, the webhook is skipped at run time. + """ + + http_method: Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] = 'POST' + """ + HTTP verb used for the outbound request. + """ + + endpoint_url: Optional[str] = None + """ + URL the request is sent to. + """ + + credential_uuid: Optional[str] = None + """ + Optional credential applied as the Authorization header. + """ + + custom_headers: list[Webhook_Custom_headersRow] = field(default_factory=list) + """ + Additional HTTP headers to include with the request. + """ + + payload_template: dict[str, Any] = field(default_factory=lambda: {'call_id': '{{workflow_run_id}}', 'first_name': '{{initial_context.first_name}}', 'rsvp': '{{gathered_context.rsvp}}', 'duration': '{{cost_info.call_duration_seconds}}', 'recording_url': '{{recording_url}}', 'transcript_url': '{{transcript_url}}'}) + """ + JSON body of the request. Values are Jinja-rendered against the run + context — `{{workflow_run_id}}`, `{{gathered_context.foo}}`, + `{{annotations.qa_xxx}}`, etc. + """ + + retry_config: Optional[dict[str, Any]] = None + """ + Optional retry settings: `enabled` (bool), `max_retries` (int), + `retry_delay_seconds` (int). + """ + diff --git a/sdk/python/src/dograh_sdk/workflow.py b/sdk/python/src/dograh_sdk/workflow.py new file mode 100644 index 0000000..f905d5d --- /dev/null +++ b/sdk/python/src/dograh_sdk/workflow.py @@ -0,0 +1,247 @@ +"""Workflow builder. + +Users compose workflows by calling `Workflow.add(type="agentNode", ...)` +and `Workflow.edge(source, target, ...)`. Every call is validated +immediately against the spec catalog fetched from the backend, so LLM +hallucinations fail at the call site rather than at save time. + +Wire format matches `ReactFlowDTO` from `api/services/workflow/dto.py` +1:1, so `Workflow.to_json()` output can be round-tripped through +`ReactFlowDTO.model_validate` without further translation. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from ._validation import validate_node_data + +if TYPE_CHECKING: + from ._generated_models import NodeSpec + from .client import DograhClient + from .typed._base import TypedNode + + +@dataclass +class _Node: + id: str + type: str + position: dict[str, float] + data: dict[str, Any] + + +@dataclass +class _Edge: + id: str + source: str + target: str + data: dict[str, Any] + + +@dataclass +class NodeRef: + """Opaque handle returned by `Workflow.add()`. Passed to `edge()` to + wire nodes together without relying on string IDs.""" + + id: str + type: str + + +class Workflow: + """Typed builder that produces `ReactFlowDTO`-compatible JSON. + + Usage: + wf = Workflow(client=client, name="loan_qual") + start = wf.add(type="startCall", name="greeting", prompt="...") + qualify = wf.add(type="agentNode", name="qualify", prompt="...") + wf.edge(start, qualify, label="interested", condition="...") + payload = wf.to_json() + """ + + def __init__(self, *, client: DograhClient, name: str = "", description: str = ""): + self._client = client + self.name = name + self.description = description + self._nodes: list[_Node] = [] + self._edges: list[_Edge] = [] + # Auto-incrementing IDs match the pattern used by the existing UI. + self._next_node_id = 1 + + # ── node construction ────────────────────────────────────────── + + def add( + self, + *, + type: str, + position: tuple[float, float] | None = None, + **kwargs: Any, + ) -> NodeRef: + """Add a node of the given type. + + `type` is a spec name (e.g., "startCall", "agentNode"). Remaining + kwargs are validated against the spec — unknown or missing + required fields raise `ValidationError` immediately. + + `position` is optional (x, y) on the React-Flow canvas; omit for + auto-placement at origin. + """ + spec: NodeSpec = self._client.get_node_type(type) + data = validate_node_data(spec.model_dump(mode="json"), kwargs) + + node_id = str(self._next_node_id) + self._next_node_id += 1 + x, y = position if position is not None else (0.0, 0.0) + self._nodes.append( + _Node( + id=node_id, + type=type, + position={"x": float(x), "y": float(y)}, + data=data, + ) + ) + return NodeRef(id=node_id, type=type) + + def add_typed( + self, + node: "TypedNode", + *, + position: tuple[float, float] | None = None, + ) -> NodeRef: + """Typed variant of `add()` — takes a generated dataclass from + `dograh_sdk.typed` instead of string+kwargs. + + Equivalent to: + wf.add(type=node.type, position=..., **node.to_dict()) + + Benefits: mypy/pyright catches misspelled fields at edit time, + and IDEs show field-level docstrings on hover. + """ + return self.add(type=node.type, position=position, **node.to_dict()) + + # ── edge construction ────────────────────────────────────────── + + def edge( + self, + source: NodeRef, + target: NodeRef, + *, + label: str, + condition: str, + transition_speech: str | None = None, + transition_speech_type: str | None = None, + transition_speech_recording_id: str | None = None, + ) -> None: + """Connect two nodes with a labeled transition. + + `label` identifies the branch in call logs and LLM tool schemas; + `condition` is the natural-language predicate the engine evaluates + to decide when to follow the edge. + """ + if not label or not label.strip(): + from .errors import ValidationError + + raise ValidationError("edge.label is required") + if not condition or not condition.strip(): + from .errors import ValidationError + + raise ValidationError("edge.condition is required") + + data: dict[str, Any] = {"label": label, "condition": condition} + if transition_speech is not None: + data["transition_speech"] = transition_speech + if transition_speech_type is not None: + data["transition_speech_type"] = transition_speech_type + if transition_speech_recording_id is not None: + data["transition_speech_recording_id"] = transition_speech_recording_id + + edge_id = f"{source.id}-{target.id}" + self._edges.append( + _Edge(id=edge_id, source=source.id, target=target.id, data=data) + ) + + # ── serialization ────────────────────────────────────────────── + + def to_json(self) -> dict[str, Any]: + """Serialize to the `ReactFlowDTO` wire format. + + Passes directly through `ReactFlowDTO.model_validate` and the + `WorkflowGraph` constructor — no translation layer needed. + """ + return { + "nodes": [ + { + "id": n.id, + "type": n.type, + "position": n.position, + "data": n.data, + } + for n in self._nodes + ], + "edges": [ + { + "id": e.id, + "source": e.source, + "target": e.target, + "data": e.data, + } + for e in self._edges + ], + "viewport": {"x": 0.0, "y": 0.0, "zoom": 1.0}, + } + + @classmethod + def from_json( + cls, + data: dict[str, Any], + *, + client: DograhClient, + name: str = "", + ) -> Workflow: + """Rebuild a Workflow from a stored `workflow_json` payload. + + Useful for the MCP edit flow: fetch existing workflow, convert to + SDK objects, let the LLM mutate in code, serialize back. + """ + wf = cls(client=client, name=name) + # Rebuild nodes in the same order, preserving IDs. + for raw in data.get("nodes", []): + node_id = str(raw.get("id")) + spec: NodeSpec = client.get_node_type(raw["type"]) + validated = validate_node_data(spec.model_dump(mode="json"), raw.get("data") or {}) + wf._nodes.append( + _Node( + id=node_id, + type=raw["type"], + position=raw.get("position") or {"x": 0.0, "y": 0.0}, + data=validated, + ) + ) + # Keep ID generator above the highest numeric ID seen so new + # nodes don't collide with existing ones. + numeric_ids = [int(n.id) for n in wf._nodes if n.id.isdigit()] + wf._next_node_id = max(numeric_ids, default=0) + 1 + + for raw in data.get("edges", []): + wf._edges.append( + _Edge( + id=str(raw.get("id") or f"{raw['source']}-{raw['target']}"), + source=str(raw["source"]), + target=str(raw["target"]), + data=raw.get("data") or {}, + ) + ) + return wf + + def find_node(self, predicate_or_id: Any) -> NodeRef | None: + """Lookup a NodeRef by node id or custom predicate. Handy after + `from_json` when the LLM needs to reference an existing node.""" + if callable(predicate_or_id): + for n in self._nodes: + if predicate_or_id(n): + return NodeRef(id=n.id, type=n.type) + return None + for n in self._nodes: + if n.id == str(predicate_or_id): + return NodeRef(id=n.id, type=n.type) + return None diff --git a/sdk/typescript/.gitignore b/sdk/typescript/.gitignore new file mode 100644 index 0000000..4bf7b95 --- /dev/null +++ b/sdk/typescript/.gitignore @@ -0,0 +1,2 @@ +dist/ +*.tsbuildinfo diff --git a/sdk/typescript/LICENSE b/sdk/typescript/LICENSE new file mode 100644 index 0000000..ca08b45 --- /dev/null +++ b/sdk/typescript/LICENSE @@ -0,0 +1,24 @@ +BSD 2-Clause License + +Copyright (c) 2025, Zansat Technologies Private Limited + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md new file mode 100644 index 0000000..515ca69 --- /dev/null +++ b/sdk/typescript/README.md @@ -0,0 +1,91 @@ +# @dograh/sdk + +Typed builder for Dograh voice-AI workflows. Fetches the node-spec catalog from +the Dograh backend at session start, validates every call against it at the +call site, and produces wire-format JSON that round-trips through the Python +`ReactFlowDTO`. + +## Install + +```bash +npm install @dograh/sdk +# or +pnpm add @dograh/sdk +``` + +For local development against a checked-out monorepo, add a tsconfig paths +entry: + +```json +{ + "paths": { + "@dograh/sdk": ["../sdk/typescript/src/index.ts"] + } +} +``` + +## Usage + +```ts +import { DograhClient, Workflow } from "@dograh/sdk"; + +const client = new DograhClient({ + baseUrl: "http://localhost:8000", + apiKey: process.env.DOGRAH_API_KEY, +}); + +const wf = new Workflow({ client, name: "loan_qualification" }); + +const start = await wf.add({ + type: "startCall", + name: "greeting", + prompt: "You are Sarah from Acme Loans. Greet the caller warmly.", + greeting_type: "text", + greeting: "Hi {{first_name}}, this is Sarah.", +}); + +const qualify = await wf.add({ + type: "agentNode", + name: "qualify", + prompt: "Ask about loan amount and timeline.", +}); + +const done = await wf.add({ type: "endCall", name: "done", prompt: "Thank them." }); + +wf.edge(start, qualify, { label: "interested", condition: "Caller expressed interest." }); +wf.edge(qualify, done, { label: "done", condition: "Qualification complete." }); + +await client.saveWorkflow(123, wf); +``` + +## Client-side validation + +Each `add()` call validates kwargs against the fetched spec. `ValidationError` +is thrown immediately when: + +- an unknown field is passed (catches typos) +- a required field is missing or empty +- a scalar type is wrong (e.g., string for a boolean) +- an `options` value isn't in the allowed list + +When a spec carries an `llm_hint`, the hint is appended to the error so an LLM +agent can self-correct on retry: + +``` +tool_uuids: expected tool_refs, got string + Hint: List of tool UUIDs from `list_tools`. +``` + +Server-side Pydantic validators run on save and surface anything the client +lets through. + +## Environment + +```bash +DOGRAH_API_URL=http://localhost:8000 # default +DOGRAH_API_KEY=sk-... # sent as X-API-Key +``` + +## License + +BSD 2-Clause — see `LICENSE`. diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json new file mode 100644 index 0000000..921002e --- /dev/null +++ b/sdk/typescript/package-lock.json @@ -0,0 +1,389 @@ +{ + "name": "@dograh/sdk", + "version": "0.1.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@dograh/sdk", + "version": "0.1.1", + "license": "BSD-2-Clause", + "devDependencies": { + "openapi-typescript": "^7.13.0", + "typescript": "^5.3.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.11", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.11.tgz", + "integrity": "sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json new file mode 100644 index 0000000..51e98fb --- /dev/null +++ b/sdk/typescript/package.json @@ -0,0 +1,52 @@ +{ + "name": "@dograh/sdk", + "version": "0.1.2", + "description": "Typed builder for Dograh voice-AI workflows", + "license": "BSD-2-Clause", + "author": "Zansat Technologies Private Limited", + "homepage": "https://dograh.com", + "repository": { + "type": "git", + "url": "https://github.com/dograh-hq/dograh.git", + "directory": "sdk/typescript" + }, + "keywords": [ + "dograh", + "voice-ai", + "workflow", + "sdk", + "llm", + "agent" + ], + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./typed": { + "types": "./dist/typed/index.d.ts", + "import": "./dist/typed/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "codegen": "node scripts/codegen.mts --api http://localhost:8000 --out src/typed", + "test": "tsc && node --test --test-reporter=spec tests/sdk.test.mts tests/typed.test.mts" + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "openapi-typescript": "^7.13.0", + "typescript": "^5.3.0" + } +} diff --git a/sdk/typescript/scripts/codegen.mts b/sdk/typescript/scripts/codegen.mts new file mode 100644 index 0000000..954b1c0 --- /dev/null +++ b/sdk/typescript/scripts/codegen.mts @@ -0,0 +1,258 @@ +// Typed SDK code generator (TypeScript). +// +// Reads NodeSpecs from the live backend or a local JSON file and emits +// one `.ts` per node type into `src/typed/` — each with a +// discriminated-union interface + a factory. The generated files are +// committed so `npm install @dograh/sdk` ships typed classes without +// requiring a regen step. +// +// Run via `npm run codegen` or: +// +// node scripts/codegen.mts --api http://localhost:8000 --out src/typed +// node scripts/codegen.mts --input specs.json --out src/typed + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +// ─── Spec types (structural; loaded at runtime via JSON) ────────────────── + +interface PropertyOption { + value: string | number | boolean; + label: string; + description?: string; +} + +interface PropertySpec { + name: string; + type: string; + display_name: string; + description: string; + llm_hint?: string | null; + default?: unknown; + required?: boolean; + options?: PropertyOption[]; + properties?: PropertySpec[]; +} + +interface NodeSpec { + name: string; + display_name: string; + description: string; + llm_hint?: string | null; + category: string; + icon: string; + version: string; + properties: PropertySpec[]; +} + +// ─── Property type → TS type ────────────────────────────────────────────── + +const SCALAR_TS_TYPES: Record = { + string: "string", + number: "number", + boolean: "boolean", + json: "Record", + mention_textarea: "string", + url: "string", + recording_ref: "string", + credential_ref: "string", + tool_refs: "string[]", + document_refs: "string[]", +}; + +function pascalCase(name: string): string { + // startCall → StartCall; agentNode → AgentNode + return name[0]!.toUpperCase() + name.slice(1); +} + +function kebabCase(name: string): string { + // startCall → start-call; agentNode → agent-node + return name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +} + +function literalUnion(options: PropertyOption[] | undefined): string { + if (!options || options.length === 0) return "string"; + return options.map((o) => JSON.stringify(o.value)).join(" | "); +} + +function tsTypeFor(prop: PropertySpec, ownerClass: string): string { + if (prop.type === "options") return literalUnion(prop.options); + if (prop.type === "multi_options") { + return `Array<${literalUnion(prop.options)}>`; + } + if (prop.type === "fixed_collection") { + return `Array<${ownerClass}${pascalCase(prop.name)}Row>`; + } + return SCALAR_TS_TYPES[prop.type] ?? "unknown"; +} + +// ─── JSDoc rendering ────────────────────────────────────────────────────── + +function renderJsDoc(description: string, llmHint?: string | null, indent = 0): string { + const pad = " ".repeat(indent); + const body = [description, ...(llmHint ? ["", `LLM hint: ${llmHint}`] : [])] + .join("\n") + .split("\n") + .map((line) => `${pad} * ${line}`.trimEnd()) + .join("\n"); + return `${pad}/**\n${body}\n${pad} */`; +} + +// ─── Source rendering ───────────────────────────────────────────────────── + +function renderNestedRowInterface( + ownerClass: string, + parent: PropertySpec, +): string { + const rowClass = `${ownerClass}${pascalCase(parent.name)}Row`; + const props = parent.properties ?? []; + const lines: string[] = []; + lines.push( + renderJsDoc(parent.description ?? `Row in ${parent.name}.`, null), + ); + lines.push(`export interface ${rowClass} {`); + for (const sub of props) { + if (sub.description) lines.push(renderJsDoc(sub.description, null, 4)); + const annotation = tsTypeFor(sub, rowClass); + const optional = sub.required ? "" : "?"; + lines.push(` ${sub.name}${optional}: ${annotation};`); + } + lines.push("}"); + return lines.join("\n"); +} + +function renderSpecFile(spec: NodeSpec): string { + const className = pascalCase(spec.name); + + const header = `// GENERATED — do not edit by hand. +// +// Regenerate with \`npm run codegen\` against the target Dograh backend. +// Source of truth: each node's NodeSpec in the backend's +// \`api/services/workflow/node_specs/\` directory. +`; + + const nested: string[] = []; + for (const prop of spec.properties) { + if (prop.type === "fixed_collection") { + nested.push(renderNestedRowInterface(className, prop)); + } + } + + const classDoc = renderJsDoc(spec.description, spec.llm_hint); + const fieldLines: string[] = []; + fieldLines.push(` type: ${JSON.stringify(spec.name)};`); + for (const prop of spec.properties) { + if (prop.description) { + fieldLines.push(renderJsDoc(prop.description, prop.llm_hint, 4)); + } + const annotation = tsTypeFor(prop, className); + // Required field (no spec default) has no `?`; everything else + // optional, the runtime SDK applies spec defaults. + const hasDefault = prop.default !== undefined && prop.default !== null; + const optional = prop.required && !hasDefault ? "" : "?"; + fieldLines.push(` ${prop.name}${optional}: ${annotation};`); + } + + const iface = `${classDoc} +export interface ${className} { +${fieldLines.join("\n")} +}`; + + const factoryDoc = `/** Factory — sets \`type\` for you so you don't repeat the discriminator. */`; + const factory = `${factoryDoc} +export function ${spec.name}(input: Omit<${className}, "type">): ${className} { + return { type: ${JSON.stringify(spec.name)}, ...input }; +}`; + + return [header, ...nested, "", iface, "", factory, ""].join("\n"); +} + +function renderIndex(specs: NodeSpec[]): string { + const lines: string[] = [ + "// GENERATED — do not edit by hand.", + "//", + "// Re-exports every typed node interface + factory. Also exports the", + "// `TypedNode` discriminated-union that `Workflow.addTyped` accepts.", + "", + ]; + const classNames: string[] = []; + for (const spec of specs.slice().sort((a, b) => a.name.localeCompare(b.name))) { + const className = pascalCase(spec.name); + const module = kebabCase(spec.name); + lines.push( + `export { type ${className}, ${spec.name} } from "./${module}.js";`, + ); + classNames.push(className); + } + lines.push(""); + lines.push("import type {"); + for (const name of classNames) lines.push(` ${name},`); + lines.push('} from "./index.js";'); + lines.push(""); + lines.push("/** Discriminated union of every generated typed node. */"); + lines.push(`export type TypedNode = ${classNames.join(" | ")};`); + lines.push(""); + return lines.join("\n"); +} + +// ─── CLI ───────────────────────────────────────────────────────────────── + +function parseArgs(argv: string[]): { api?: string; input?: string; out: string } { + let api: string | undefined; + let input: string | undefined; + let out = ""; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--api") api = argv[++i]; + else if (a === "--input") input = argv[++i]; + else if (a === "--out") out = argv[++i]!; + } + if (!out) throw new Error("--out is required"); + if (!api && !input) throw new Error("Provide --api URL or --input PATH"); + return { api, input, out }; +} + +async function loadSpecs(args: { + api?: string; + input?: string; +}): Promise { + if (args.api) { + const resp = await fetch(`${args.api.replace(/\/$/, "")}/api/v1/node-types`); + if (!resp.ok) { + throw new Error( + `GET /api/v1/node-types failed: ${resp.status} ${resp.statusText}`, + ); + } + const body = (await resp.json()) as { node_types: NodeSpec[] }; + return body.node_types ?? []; + } + const raw = JSON.parse(readFileSync(args.input!, "utf-8")); + if (Array.isArray(raw)) return raw as NodeSpec[]; + if (raw && typeof raw === "object" && "node_types" in raw) { + return (raw as { node_types: NodeSpec[] }).node_types; + } + throw new Error("JSON must be an array or { node_types: [...] }"); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const specs = await loadSpecs(args); + mkdirSync(args.out, { recursive: true }); + + for (const spec of specs) { + const module = kebabCase(spec.name); + writeFileSync(join(args.out, `${module}.ts`), renderSpecFile(spec)); + } + writeFileSync(join(args.out, "index.ts"), renderIndex(specs)); + + console.log( + `Generated ${specs.length} typed node modules (${specs + .map((s) => s.name) + .join(", ")}) into ${args.out}`, + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/sdk/typescript/src/_generated_client.ts b/sdk/typescript/src/_generated_client.ts new file mode 100644 index 0000000..717dd10 --- /dev/null +++ b/sdk/typescript/src/_generated_client.ts @@ -0,0 +1,96 @@ +// GENERATED — do not edit. Source: filtered OpenAPI from `api.app`. +// +// Regenerate with `./scripts/generate_sdk.sh`. +// +// `DograhClient` extends this base to get HTTP methods for every route +// decorated with `sdk_expose(...)`. Request/response types come from +// `_generated_models` (openapi-typescript output, --root-types). + +import type { + CredentialResponse, + DocumentListResponseSchema, + InitiateCallRequest, + NodeSpec, + NodeTypesResponse, + RecordingListResponseSchema, + ToolResponse, + UpdateWorkflowRequest, + WorkflowListResponse, + WorkflowResponse, +} from "./_generated_models.js"; + +export abstract class _GeneratedClient { + protected abstract request( + method: string, + path: string, + opts?: { json?: unknown; params?: Record }, + ): Promise; + + /** Fetch a single node spec by name. */ + async getNodeType(name: string): Promise { + return this.request("GET", `/node-types/${name}`); + } + + /** Get a single workflow by ID (returns draft if one exists, else published). */ + async getWorkflow(workflowId: number): Promise { + return this.request("GET", `/workflow/fetch/${workflowId}`); + } + + /** List webhook credentials available to the authenticated organization. */ + async listCredentials(): Promise { + return this.request("GET", "/credentials/"); + } + + /** List knowledge base documents available to the authenticated organization. */ + async listDocuments(opts: { status?: string; limit?: number; offset?: number } = {}): Promise { + const params: Record = { + ...(opts.status !== undefined ? { "status": opts.status } : {}), + ...(opts.limit !== undefined ? { "limit": opts.limit } : {}), + ...(opts.offset !== undefined ? { "offset": opts.offset } : {}), + }; + return this.request("GET", "/knowledge-base/documents", { params }); + } + + /** List every registered node type with its spec. Pinned to spec_version. */ + async listNodeTypes(): Promise { + return this.request("GET", "/node-types"); + } + + /** List workflow recordings available to the authenticated organization. */ + async listRecordings(opts: { workflowId?: number; ttsProvider?: string; ttsModel?: string; ttsVoiceId?: string } = {}): Promise { + const params: Record = { + ...(opts.workflowId !== undefined ? { "workflow_id": opts.workflowId } : {}), + ...(opts.ttsProvider !== undefined ? { "tts_provider": opts.ttsProvider } : {}), + ...(opts.ttsModel !== undefined ? { "tts_model": opts.ttsModel } : {}), + ...(opts.ttsVoiceId !== undefined ? { "tts_voice_id": opts.ttsVoiceId } : {}), + }; + return this.request("GET", "/workflow-recordings/", { params }); + } + + /** List tools available to the authenticated organization. */ + async listTools(opts: { status?: string; category?: string } = {}): Promise { + const params: Record = { + ...(opts.status !== undefined ? { "status": opts.status } : {}), + ...(opts.category !== undefined ? { "category": opts.category } : {}), + }; + return this.request("GET", "/tools/", { params }); + } + + /** List all workflows in the authenticated organization. */ + async listWorkflows(opts: { status?: string } = {}): Promise { + const params: Record = { + ...(opts.status !== undefined ? { "status": opts.status } : {}), + }; + return this.request("GET", "/workflow/fetch", { params }); + } + + /** Place a test call from a workflow to a phone number. */ + async testPhoneCall(opts: { body: InitiateCallRequest }): Promise { + return this.request("POST", "/telephony/initiate-call", { json: opts.body }); + } + + /** Update a workflow's name and/or definition. Saves as a new draft. */ + async updateWorkflow(workflowId: number, opts: { body: UpdateWorkflowRequest }): Promise { + return this.request("PUT", `/workflow/${workflowId}`, { json: opts.body }); + } +} diff --git a/sdk/typescript/src/_generated_models.ts b/sdk/typescript/src/_generated_models.ts new file mode 100644 index 0000000..7b5e039 --- /dev/null +++ b/sdk/typescript/src/_generated_models.ts @@ -0,0 +1,1165 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/v1/telephony/initiate-call": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Initiate Call + * @description Initiate a call using the configured telephony provider from web browser. This is + * supposed to be a test call method for the draft version of the agent. + */ + post: operations["initiate_call_api_v1_telephony_initiate_call_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/workflow/fetch": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Workflows + * @description Get all workflows for the authenticated user's organization. + * + * Returns a lightweight response with only essential fields for listing. + * Use GET /workflow/fetch/{workflow_id} to get full workflow details. + */ + get: operations["get_workflows_api_v1_workflow_fetch_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/workflow/fetch/{workflow_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Workflow + * @description Get a single workflow by ID. + * + * If a draft version exists, returns the draft content for editing. + * Otherwise returns the published version's content. + */ + get: operations["get_workflow_api_v1_workflow_fetch__workflow_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/workflow/{workflow_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update Workflow + * @description Update an existing workflow. + * + * Args: + * workflow_id: The ID of the workflow to update + * request: The update request containing the new name and workflow definition + * + * Returns: + * The updated workflow + * + * Raises: + * HTTPException: If the workflow is not found or if there's a database error + */ + put: operations["update_workflow_api_v1_workflow__workflow_id__put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/credentials/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Credentials + * @description List all webhook credentials for the user's organization. + * + * Returns: + * List of credentials (without sensitive data) + */ + get: operations["list_credentials_api_v1_credentials__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tools/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Tools + * @description List all tools for the user's organization. + * + * Args: + * status: Optional filter by status (active, archived, draft) + * category: Optional filter by category (http_api, native, integration) + * + * Returns: + * List of tools + */ + get: operations["list_tools_api_v1_tools__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/knowledge-base/documents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List documents + * @description List all documents for the user's organization. + * + * Access Control: + * * Users can only see documents from their organization. + */ + get: operations["list_documents_api_v1_knowledge_base_documents_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/workflow-recordings/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List recordings + * @description List recordings for the organization, optionally filtered. + */ + get: operations["list_recordings_api_v1_workflow_recordings__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/node-types": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Node Types + * @description List every registered NodeSpec. + * + * SDK clients should pin to `spec_version` and warn if the server reports + * a higher version than what they were generated against. + */ + get: operations["list_node_types_api_v1_node_types_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/node-types/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Node Type */ + get: operations["get_node_type_api_v1_node_types__name__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** CallDispositionCodes */ + CallDispositionCodes: { + /** + * Disposition Codes + * @default [] + */ + disposition_codes: string[]; + }; + /** + * CreatedByResponse + * @description Response schema for the user who created a tool. + */ + CreatedByResponse: { + /** Id */ + id: number; + /** Provider Id */ + provider_id: string; + }; + /** + * CredentialResponse + * @description Response schema for a webhook credential (never includes sensitive data). + */ + CredentialResponse: { + /** Uuid */ + uuid: string; + /** Name */ + name: string; + /** Description */ + description: string | null; + /** Credential Type */ + credential_type: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Updated At */ + updated_at: string | null; + }; + /** + * DisplayOptions + * @description Conditional visibility rules. + * + * `show` keys are AND-combined: this property is visible only when EVERY + * referenced field's value matches one of the listed values. + * + * `hide` keys are OR-combined: this property is hidden when ANY referenced + * field's value matches one of the listed values. + * + * Example: + * DisplayOptions(show={"extraction_enabled": [True]}) + * DisplayOptions(show={"greeting_type": ["audio"]}) + */ + DisplayOptions: { + /** Show */ + show?: { + [key: string]: unknown[]; + } | null; + /** Hide */ + hide?: { + [key: string]: unknown[]; + } | null; + }; + /** + * DocumentListResponseSchema + * @description Response schema for list of documents. + */ + DocumentListResponseSchema: { + /** Documents */ + documents: components["schemas"]["DocumentResponseSchema"][]; + /** Total */ + total: number; + /** Limit */ + limit: number; + /** Offset */ + offset: number; + }; + /** + * DocumentResponseSchema + * @description Response schema for document metadata. + */ + DocumentResponseSchema: { + /** Id */ + id: number; + /** Document Uuid */ + document_uuid: string; + /** Filename */ + filename: string; + /** File Size Bytes */ + file_size_bytes: number; + /** File Hash */ + file_hash: string; + /** Mime Type */ + mime_type: string; + /** Processing Status */ + processing_status: string; + /** Processing Error */ + processing_error?: string | null; + /** Total Chunks */ + total_chunks: number; + /** + * Retrieval Mode + * @default chunked + */ + retrieval_mode: string; + /** Custom Metadata */ + custom_metadata: { + [key: string]: unknown; + }; + /** Docling Metadata */ + docling_metadata: { + [key: string]: unknown; + }; + /** Source Url */ + source_url?: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + /** Organization Id */ + organization_id: number; + /** Created By */ + created_by: number; + /** Is Active */ + is_active: boolean; + }; + /** + * GraphConstraints + * @description Per-node-type graph rules. WorkflowGraph enforces these at validation. + */ + GraphConstraints: { + /** Min Incoming */ + min_incoming?: number | null; + /** Max Incoming */ + max_incoming?: number | null; + /** Min Outgoing */ + min_outgoing?: number | null; + /** Max Outgoing */ + max_outgoing?: number | null; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** InitiateCallRequest */ + InitiateCallRequest: { + /** Workflow Id */ + workflow_id: number; + /** Workflow Run Id */ + workflow_run_id?: number | null; + /** Phone Number */ + phone_number?: string | null; + }; + /** + * NodeCategory + * @description Drives grouping in the AddNodePanel UI. + * @enum {string} + */ + NodeCategory: "call_node" | "global_node" | "trigger" | "integration"; + /** + * NodeExample + * @description A worked example LLMs can pattern-match. Keep small and realistic. + */ + NodeExample: { + /** Name */ + name: string; + /** Description */ + description?: string | null; + /** Data */ + data: { + [key: string]: unknown; + }; + }; + /** + * NodeSpec + * @description Single source of truth for a node type. + */ + NodeSpec: { + /** Name */ + name: string; + /** Display Name */ + display_name: string; + /** + * Description + * @description Human-facing explanation shown in AddNodePanel. + */ + description: string; + /** + * Llm Hint + * @description LLM-only guidance; omitted from the UI. + */ + llm_hint?: string | null; + category: components["schemas"]["NodeCategory"]; + /** Icon */ + icon: string; + /** + * Version + * @default 1.0.0 + */ + version: string; + /** Properties */ + properties: components["schemas"]["PropertySpec"][]; + /** Examples */ + examples?: components["schemas"]["NodeExample"][]; + graph_constraints?: components["schemas"]["GraphConstraints"] | null; + }; + /** NodeTypesResponse */ + NodeTypesResponse: { + /** Spec Version */ + spec_version: string; + /** Node Types */ + node_types: components["schemas"]["NodeSpec"][]; + }; + /** + * PropertyOption + * @description An option in an `options` or `multi_options` dropdown. + */ + PropertyOption: { + /** Value */ + value: string | number | boolean; + /** Label */ + label: string; + /** Description */ + description?: string | null; + }; + /** + * PropertySpec + * @description Single field on a node. + * + * `description` is HUMAN-FACING — shown under the field in the edit + * dialog. Keep it concise and explain what the field does. + * + * `llm_hint` is LLM-FACING — appears only in the `get_node_type` MCP + * response and in SDK schema output. Use it for catalog tool references + * (e.g., "Use `list_recordings`"), array shape, expected value idioms, + * or anything that would be noise in the UI. Optional; omit when the + * `description` already suffices for both audiences. + */ + PropertySpec: { + /** Name */ + name: string; + type: components["schemas"]["PropertyType"]; + /** Display Name */ + display_name: string; + /** + * Description + * @description Human-facing explanation shown in the UI. + */ + description: string; + /** + * Llm Hint + * @description LLM-only guidance; omitted from the UI. + */ + llm_hint?: string | null; + /** Default */ + default?: unknown; + /** + * Required + * @default false + */ + required: boolean; + /** Placeholder */ + placeholder?: string | null; + display_options?: components["schemas"]["DisplayOptions"] | null; + /** Options */ + options?: components["schemas"]["PropertyOption"][] | null; + /** Properties */ + properties?: components["schemas"]["PropertySpec"][] | null; + /** Min Value */ + min_value?: number | null; + /** Max Value */ + max_value?: number | null; + /** Min Length */ + min_length?: number | null; + /** Max Length */ + max_length?: number | null; + /** Pattern */ + pattern?: string | null; + /** Editor */ + editor?: string | null; + /** Extra */ + extra?: { + [key: string]: unknown; + }; + }; + /** + * PropertyType + * @description Bounded vocabulary of property types the renderer dispatches on. + * + * Adding a value here requires a matching arm in the frontend + * `` switch and (where relevant) the SDK codegen template. + * @enum {string} + */ + PropertyType: "string" | "number" | "boolean" | "options" | "multi_options" | "fixed_collection" | "json" | "tool_refs" | "document_refs" | "recording_ref" | "credential_ref" | "mention_textarea" | "url"; + /** + * RecordingListResponseSchema + * @description Response schema for list of recordings. + */ + RecordingListResponseSchema: { + /** Recordings */ + recordings: components["schemas"]["RecordingResponseSchema"][]; + /** Total */ + total: number; + }; + /** + * RecordingResponseSchema + * @description Response schema for a single recording. + */ + RecordingResponseSchema: { + /** Id */ + id: number; + /** Recording Id */ + recording_id: string; + /** Workflow Id */ + workflow_id?: number | null; + /** Organization Id */ + organization_id: number; + /** Tts Provider */ + tts_provider?: string | null; + /** Tts Model */ + tts_model?: string | null; + /** Tts Voice Id */ + tts_voice_id?: string | null; + /** Transcript */ + transcript: string; + /** Storage Key */ + storage_key: string; + /** Storage Backend */ + storage_backend: string; + /** Metadata */ + metadata: { + [key: string]: unknown; + }; + /** Created By */ + created_by: number; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Is Active */ + is_active: boolean; + }; + /** + * ToolResponse + * @description Response schema for a tool. + */ + ToolResponse: { + /** Id */ + id: number; + /** Tool Uuid */ + tool_uuid: string; + /** Name */ + name: string; + /** Description */ + description: string | null; + /** Category */ + category: string; + /** Icon */ + icon: string | null; + /** Icon Color */ + icon_color: string | null; + /** Status */ + status: string; + /** Definition */ + definition: { + [key: string]: unknown; + }; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Updated At */ + updated_at: string | null; + created_by?: components["schemas"]["CreatedByResponse"] | null; + }; + /** UpdateWorkflowRequest */ + UpdateWorkflowRequest: { + /** Name */ + name?: string | null; + /** Workflow Definition */ + workflow_definition?: { + [key: string]: unknown; + } | null; + /** Template Context Variables */ + template_context_variables?: { + [key: string]: unknown; + } | null; + /** Workflow Configurations */ + workflow_configurations?: { + [key: string]: unknown; + } | null; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; + }; + /** + * WorkflowListResponse + * @description Lightweight response for workflow listings (excludes large fields). + */ + WorkflowListResponse: { + /** Id */ + id: number; + /** Name */ + name: string; + /** Status */ + status: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Total Runs */ + total_runs: number; + }; + /** WorkflowResponse */ + WorkflowResponse: { + /** Id */ + id: number; + /** Name */ + name: string; + /** Status */ + status: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Workflow Definition */ + workflow_definition: { + [key: string]: unknown; + }; + /** Current Definition Id */ + current_definition_id: number | null; + /** Template Context Variables */ + template_context_variables?: { + [key: string]: unknown; + } | null; + call_disposition_codes?: components["schemas"]["CallDispositionCodes"] | null; + /** Total Runs */ + total_runs?: number | null; + /** Workflow Configurations */ + workflow_configurations?: { + [key: string]: unknown; + } | null; + /** Version Number */ + version_number?: number | null; + /** Version Status */ + version_status?: string | null; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type CallDispositionCodes = components['schemas']['CallDispositionCodes']; +export type CreatedByResponse = components['schemas']['CreatedByResponse']; +export type CredentialResponse = components['schemas']['CredentialResponse']; +export type DisplayOptions = components['schemas']['DisplayOptions']; +export type DocumentListResponseSchema = components['schemas']['DocumentListResponseSchema']; +export type DocumentResponseSchema = components['schemas']['DocumentResponseSchema']; +export type GraphConstraints = components['schemas']['GraphConstraints']; +export type HttpValidationError = components['schemas']['HTTPValidationError']; +export type InitiateCallRequest = components['schemas']['InitiateCallRequest']; +export type NodeCategory = components['schemas']['NodeCategory']; +export type NodeExample = components['schemas']['NodeExample']; +export type NodeSpec = components['schemas']['NodeSpec']; +export type NodeTypesResponse = components['schemas']['NodeTypesResponse']; +export type PropertyOption = components['schemas']['PropertyOption']; +export type PropertySpec = components['schemas']['PropertySpec']; +export type PropertyType = components['schemas']['PropertyType']; +export type RecordingListResponseSchema = components['schemas']['RecordingListResponseSchema']; +export type RecordingResponseSchema = components['schemas']['RecordingResponseSchema']; +export type ToolResponse = components['schemas']['ToolResponse']; +export type UpdateWorkflowRequest = components['schemas']['UpdateWorkflowRequest']; +export type ValidationError = components['schemas']['ValidationError']; +export type WorkflowListResponse = components['schemas']['WorkflowListResponse']; +export type WorkflowResponse = components['schemas']['WorkflowResponse']; +export type $defs = Record; +export interface operations { + initiate_call_api_v1_telephony_initiate_call_post: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-API-Key"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["InitiateCallRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_workflows_api_v1_workflow_fetch_get: { + parameters: { + query?: { + /** @description Filter by status - can be single value (active/archived) or comma-separated (active,archived) */ + status?: string | null; + }; + header?: { + authorization?: string | null; + "X-API-Key"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowListResponse"][]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_workflow_api_v1_workflow_fetch__workflow_id__get: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-API-Key"?: string | null; + }; + path: { + workflow_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowResponse"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_workflow_api_v1_workflow__workflow_id__put: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-API-Key"?: string | null; + }; + path: { + workflow_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateWorkflowRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowResponse"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_credentials_api_v1_credentials__get: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-API-Key"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CredentialResponse"][]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_tools_api_v1_tools__get: { + parameters: { + query?: { + status?: string | null; + category?: string | null; + }; + header?: { + authorization?: string | null; + "X-API-Key"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ToolResponse"][]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_documents_api_v1_knowledge_base_documents_get: { + parameters: { + query?: { + /** @description Filter by processing status */ + status?: string | null; + limit?: number; + offset?: number; + }; + header?: { + authorization?: string | null; + "X-API-Key"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DocumentListResponseSchema"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_recordings_api_v1_workflow_recordings__get: { + parameters: { + query?: { + /** @description Filter by workflow ID */ + workflow_id?: number | null; + /** @description Filter by TTS provider */ + tts_provider?: string | null; + /** @description Filter by TTS model */ + tts_model?: string | null; + /** @description Filter by TTS voice ID */ + tts_voice_id?: string | null; + }; + header?: { + authorization?: string | null; + "X-API-Key"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RecordingListResponseSchema"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_node_types_api_v1_node_types_get: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-API-Key"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NodeTypesResponse"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_node_type_api_v1_node_types__name__get: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-API-Key"?: string | null; + }; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NodeSpec"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; +} diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts new file mode 100644 index 0000000..5c65c01 --- /dev/null +++ b/sdk/typescript/src/client.ts @@ -0,0 +1,175 @@ +// HTTP client for the Dograh REST API. +// +// Most endpoint methods come from `_GeneratedClient` (auto-generated from +// the FastAPI OpenAPI spec — see `scripts/generate_sdk.sh`). This class +// adds session/auth/caching around that base plus the ergonomic +// `loadWorkflow` / `saveWorkflow` wrappers that compose a generated call +// with local `Workflow` hydration. + +import { _GeneratedClient } from "./_generated_client.js"; +import type { + NodeSpec, + NodeTypesResponse, + UpdateWorkflowRequest, + WorkflowResponse, +} from "./_generated_models.js"; +import { ApiError, SpecMismatchError } from "./errors.js"; +import { Workflow, type SpecProvider } from "./workflow.js"; + +export interface DograhClientOptions { + baseUrl?: string; + apiKey?: string; + /** Request timeout in ms. */ + timeoutMs?: number; + /** Optional fetch override for tests / custom transports. */ + fetch?: typeof globalThis.fetch; +} + +export class DograhClient extends _GeneratedClient implements SpecProvider { + readonly baseUrl: string; + readonly apiKey: string | undefined; + private readonly fetchImpl: typeof globalThis.fetch; + private readonly timeoutMs: number; + private readonly headers: Record; + private readonly specCache = new Map(); + private specVersionCache: string | null = null; + + constructor(opts: DograhClientOptions = {}) { + super(); + const rawBase = + opts.baseUrl ?? + (typeof process !== "undefined" ? process.env.DOGRAH_API_URL : undefined) ?? + "http://localhost:8000"; + this.baseUrl = rawBase.replace(/\/+$/, ""); + this.apiKey = + opts.apiKey ?? + (typeof process !== "undefined" ? process.env.DOGRAH_API_KEY : undefined); + this.fetchImpl = opts.fetch ?? globalThis.fetch; + this.timeoutMs = opts.timeoutMs ?? 30_000; + this.headers = { Accept: "application/json" }; + if (this.apiKey) this.headers["X-API-Key"] = this.apiKey; + } + + /** Spec contract version reported by the server, or null until the + * first `listNodeTypes` / `getNodeType` call. */ + get specVersion(): string | null { + return this.specVersionCache; + } + + // ── spec discovery overrides (generated methods + caching) ──────── + + async listNodeTypes(): Promise { + const resp = await super.listNodeTypes(); + this.specVersionCache = resp.spec_version; + for (const spec of resp.node_types ?? []) { + this.specCache.set(spec.name, spec); + } + return resp; + } + + async getNodeType(name: string): Promise { + const cached = this.specCache.get(name); + if (cached) return cached; + try { + const spec = await super.getNodeType(name); + this.specCache.set(name, spec); + return spec; + } catch (err) { + if (err instanceof ApiError && err.statusCode === 404) { + throw new SpecMismatchError(`Unknown node type: ${JSON.stringify(name)}`); + } + throw err; + } + } + + // ── ergonomic workflow wrappers ─────────────────────────────────── + + /** Fetch a workflow and return it as an editable `Workflow` builder. */ + async loadWorkflow(workflowId: number): Promise { + const resp = await this.getWorkflow(workflowId); + if (!resp.workflow_definition) { + throw new ApiError( + 200, + `Workflow ${workflowId} has no definition to load`, + resp, + ); + } + return Workflow.fromJson( + resp.workflow_definition as Parameters[0], + { client: this, name: resp.name ?? "" }, + ); + } + + async saveWorkflow(workflowId: number, workflow: Workflow): Promise { + const body: UpdateWorkflowRequest = { + name: workflow.name, + workflow_definition: workflow.toJson() as unknown as Record, + }; + return this.updateWorkflow(workflowId, { body }); + } + + // ── low-level (overrides `_GeneratedClient.request`) ────────────── + + protected async request( + method: string, + path: string, + opts?: { json?: unknown; params?: Record }, + ): Promise { + let url = `${this.baseUrl}/api/v1${path}`; + if (opts?.params) { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(opts.params)) { + if (v !== undefined && v !== null) qs.append(k, String(v)); + } + const q = qs.toString(); + if (q) url += (url.includes("?") ? "&" : "?") + q; + } + + const hasBody = opts?.json !== undefined; + const init: RequestInit = { + method, + headers: { + ...this.headers, + ...(hasBody ? { "Content-Type": "application/json" } : {}), + }, + body: hasBody ? JSON.stringify(opts!.json) : undefined, + }; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + init.signal = controller.signal; + + let resp: Response; + try { + resp = await this.fetchImpl(url, init); + } finally { + clearTimeout(timer); + } + + if (!resp.ok) { + let parsed: unknown; + let message = resp.statusText; + try { + parsed = await resp.json(); + if (parsed && typeof parsed === "object") { + const p = parsed as Record; + if (typeof p.detail === "string") message = p.detail; + else if (typeof p.message === "string") message = p.message; + } + } catch { + parsed = await resp.text().catch(() => ""); + if (typeof parsed === "string" && parsed !== "") message = parsed; + } + throw new ApiError(resp.status, message, parsed); + } + + if (resp.status === 204) return undefined as T; + const text = await resp.text(); + if (text === "") return undefined as T; + try { + return JSON.parse(text) as T; + } catch { + return text as unknown as T; + } + } +} diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts new file mode 100644 index 0000000..1f09267 --- /dev/null +++ b/sdk/typescript/src/errors.ts @@ -0,0 +1,45 @@ +// SDK-level exceptions. All subclass `DograhSdkError` so callers can +// catch them as one category. + +export class DograhSdkError extends Error { + constructor(message: string) { + super(message); + this.name = "DograhSdkError"; + } +} + +/** + * Raised when node data fails client-side validation (unknown field, + * missing required field, obvious type mismatch). + * + * Server-side Pydantic validation runs on save and may raise further + * errors via `ApiError` — this class covers the fast-fail cases caught + * at the `Workflow.add()` call site. + */ +export class ValidationError extends DograhSdkError { + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } +} + +/** Raised when the Dograh backend returns a non-2xx response. */ +export class ApiError extends DograhSdkError { + readonly statusCode: number; + readonly body: unknown; + + constructor(statusCode: number, message: string, body?: unknown) { + super(`[${statusCode}] ${message}`); + this.name = "ApiError"; + this.statusCode = statusCode; + this.body = body; + } +} + +/** Raised when a referenced node type isn't registered on the server. */ +export class SpecMismatchError extends DograhSdkError { + constructor(message: string) { + super(message); + this.name = "SpecMismatchError"; + } +} diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts new file mode 100644 index 0000000..a529760 --- /dev/null +++ b/sdk/typescript/src/index.ts @@ -0,0 +1,59 @@ +/** + * Dograh SDK — typed builder for voice-AI workflows. + * + * Runtime SDK: fetches the spec catalog from the Dograh backend at session + * start and validates every `Workflow.add()` call against it. Don't import + * per-node-type classes — the `type` argument is a string keyed against the + * fetched spec catalog. + * + * @example + * ```ts + * import { DograhClient, Workflow } from "@dograh/sdk"; + * + * const client = new DograhClient({ baseUrl: "http://localhost:8000", apiKey: "..." }); + * const wf = new Workflow({ client, name: "loan_qualification" }); + * + * const start = await wf.add({ + * type: "startCall", + * name: "greeting", + * prompt: "You are Sarah from Acme Loans...", + * }); + * const done = await wf.add({ type: "endCall", name: "done", prompt: "Thank them." }); + * wf.edge(start, done, { label: "done", condition: "Conversation wrapped." }); + * + * await client.saveWorkflow(123, wf); + * ``` + */ + +export { DograhClient } from "./client.js"; +export type { DograhClientOptions } from "./client.js"; +export { + ApiError, + DograhSdkError, + SpecMismatchError, + ValidationError, +} from "./errors.js"; +export type { + AddNodeOptions, + EdgeOptions, + SpecProvider, + WorkflowOptions, +} from "./workflow.js"; +export { Workflow } from "./workflow.js"; +export type { + DisplayOptions, + NodeCategory, + NodeRef, + NodeSpec, + PropertyOption, + PropertySpec, + PropertyType, + WireEdge, + WireNode, + WireWorkflow, +} from "./types.js"; + +// Typed SDK — generated per-node interfaces + factories. Importable as +// `import { startCall, type StartCall } from "@dograh/sdk/typed"` for +// tree-shaking, or via the `TypedNode` union here. +export type { TypedNode } from "./typed/index.js"; diff --git a/sdk/typescript/src/typed/agent-node.ts b/sdk/typescript/src/typed/agent-node.ts new file mode 100644 index 0000000..2fd05e8 --- /dev/null +++ b/sdk/typescript/src/typed/agent-node.ts @@ -0,0 +1,77 @@ +// GENERATED — do not edit by hand. +// +// Regenerate with `npm run codegen` against the target Dograh backend. +// Source of truth: each node's NodeSpec in the backend's +// `api/services/workflow/node_specs/` directory. + +/** + * Each entry declares one variable to capture from the conversation, with its name, type, and per-variable hint. + */ +export interface AgentNodeExtraction_variablesRow { + /** + * snake_case identifier used downstream. + */ + name: string; + /** + * Data type of the extracted value. + */ + type: "string" | "number" | "boolean"; + /** + * Per-variable hint describing what to look for. + */ + prompt?: string; +} + +/** + * Conversational step — the LLM runs one focused exchange. + * + * LLM hint: Mid-call step executed by the LLM. Most workflows are a chain of agent nodes connected by edges that describe transition conditions. Each agent node can invoke tools and reference documents. + */ +export interface AgentNode { + type: "agentNode"; + /** + * Short identifier for this step (e.g., 'Qualify Budget'). Appears in call logs and edge transition tools. + */ + name?: string; + /** + * Agent system prompt for this step. Supports {{template_variables}} from extraction or pre-call fetch. + */ + prompt: string; + /** + * When true, the user can interrupt the agent mid-utterance. Set false for non-interruptible disclosures. + */ + allow_interrupt?: boolean; + /** + * When true and a Global node exists, prepends the global prompt to this node's prompt at runtime. + */ + add_global_prompt?: boolean; + /** + * When true, runs an LLM extraction pass on transition out of this node to capture variables from the conversation. + */ + extraction_enabled?: boolean; + /** + * Overall instructions guiding variable extraction. + */ + extraction_prompt?: string; + /** + * Each entry declares one variable to capture from the conversation, with its name, type, and per-variable hint. + */ + extraction_variables?: Array; + /** + * Tools the agent can invoke during this step. + * + * LLM hint: List of tool UUIDs from `list_tools`. + */ + tool_uuids?: string[]; + /** + * Documents the agent can reference during this step. + * + * LLM hint: List of document UUIDs from `list_documents`. + */ + document_uuids?: string[]; +} + +/** Factory — sets `type` for you so you don't repeat the discriminator. */ +export function agentNode(input: Omit): AgentNode { + return { type: "agentNode", ...input }; +} diff --git a/sdk/typescript/src/typed/end-call.ts b/sdk/typescript/src/typed/end-call.ts new file mode 100644 index 0000000..7d0aed6 --- /dev/null +++ b/sdk/typescript/src/typed/end-call.ts @@ -0,0 +1,61 @@ +// GENERATED — do not edit by hand. +// +// Regenerate with `npm run codegen` against the target Dograh backend. +// Source of truth: each node's NodeSpec in the backend's +// `api/services/workflow/node_specs/` directory. + +/** + * Each entry declares one variable to capture from the conversation, with its name, data type, and a per-variable extraction hint. + */ +export interface EndCallExtraction_variablesRow { + /** + * snake_case identifier used downstream. + */ + name: string; + /** + * The data type of the extracted value. + */ + type: "string" | "number" | "boolean"; + /** + * Per-variable hint describing what to look for in the conversation. + */ + prompt?: string; +} + +/** + * Closes the conversation and hangs up. + * + * LLM hint: Terminal node that politely closes the conversation. Variable extraction can run before hangup. A workflow can have multiple endCall nodes reached via different edge conditions. + */ +export interface EndCall { + type: "endCall"; + /** + * Short identifier shown in call logs. Should describe the ending context (e.g., 'Successful close', 'Polite decline'). + */ + name?: string; + /** + * Agent system prompt for the closing exchange. Supports {{template_variables}} from extraction or pre-call fetch. + */ + prompt: string; + /** + * When true and a Global node exists, prepends the global prompt to this node's prompt at runtime. + */ + add_global_prompt?: boolean; + /** + * When true, runs an LLM extraction pass before hangup to capture variables from the conversation. + */ + extraction_enabled?: boolean; + /** + * Overall instructions guiding how variables should be extracted from the conversation. + */ + extraction_prompt?: string; + /** + * Each entry declares one variable to capture from the conversation, with its name, data type, and a per-variable extraction hint. + */ + extraction_variables?: Array; +} + +/** Factory — sets `type` for you so you don't repeat the discriminator. */ +export function endCall(input: Omit): EndCall { + return { type: "endCall", ...input }; +} diff --git a/sdk/typescript/src/typed/global-node.ts b/sdk/typescript/src/typed/global-node.ts new file mode 100644 index 0000000..98140fd --- /dev/null +++ b/sdk/typescript/src/typed/global-node.ts @@ -0,0 +1,28 @@ +// GENERATED — do not edit by hand. +// +// Regenerate with `npm run codegen` against the target Dograh backend. +// Source of truth: each node's NodeSpec in the backend's +// `api/services/workflow/node_specs/` directory. + + +/** + * Persona/tone appended to every agent node's prompt. + * + * LLM hint: System-level prompt appended to every prompted node whose `add_global_prompt` is true. Use it for persona, tone, and shared rules that apply across the entire conversation. At most one global node per workflow. + */ +export interface GlobalNode { + type: "globalNode"; + /** + * Short identifier shown in the canvas and call logs. Has no runtime effect. + */ + name?: string; + /** + * Text appended to every prompted node's system prompt when that node has `add_global_prompt=true`. Supports {{template_variables}}. + */ + prompt?: string; +} + +/** Factory — sets `type` for you so you don't repeat the discriminator. */ +export function globalNode(input: Omit): GlobalNode { + return { type: "globalNode", ...input }; +} diff --git a/sdk/typescript/src/typed/index.ts b/sdk/typescript/src/typed/index.ts new file mode 100644 index 0000000..72450a8 --- /dev/null +++ b/sdk/typescript/src/typed/index.ts @@ -0,0 +1,25 @@ +// GENERATED — do not edit by hand. +// +// Re-exports every typed node interface + factory. Also exports the +// `TypedNode` discriminated-union that `Workflow.addTyped` accepts. + +export { type AgentNode, agentNode } from "./agent-node.js"; +export { type EndCall, endCall } from "./end-call.js"; +export { type GlobalNode, globalNode } from "./global-node.js"; +export { type Qa, qa } from "./qa.js"; +export { type StartCall, startCall } from "./start-call.js"; +export { type Trigger, trigger } from "./trigger.js"; +export { type Webhook, webhook } from "./webhook.js"; + +import type { + AgentNode, + EndCall, + GlobalNode, + Qa, + StartCall, + Trigger, + Webhook, +} from "./index.js"; + +/** Discriminated union of every generated typed node. */ +export type TypedNode = AgentNode | EndCall | GlobalNode | Qa | StartCall | Trigger | Webhook; diff --git a/sdk/typescript/src/typed/qa.ts b/sdk/typescript/src/typed/qa.ts new file mode 100644 index 0000000..7f93152 --- /dev/null +++ b/sdk/typescript/src/typed/qa.ts @@ -0,0 +1,64 @@ +// GENERATED — do not edit by hand. +// +// Regenerate with `npm run codegen` against the target Dograh backend. +// Source of truth: each node's NodeSpec in the backend's +// `api/services/workflow/node_specs/` directory. + + +/** + * Run LLM quality analysis on the call transcript. + * + * LLM hint: Runs an LLM quality review on the call transcript after completion. Per-node analysis splits the conversation by node and evaluates each segment against the configured system prompt. Sampling, minimum duration, and voicemail filters are supported. + */ +export interface Qa { + type: "qa"; + /** + * Short identifier for this QA configuration. + */ + name?: string; + /** + * When false, the QA run is skipped. + */ + qa_enabled?: boolean; + /** + * Instructions to the QA reviewer LLM. Supports placeholders: `{node_summary}`, `{previous_conversation_summary}`, `{transcript}`, `{metrics}`. + */ + qa_system_prompt?: string; + /** + * Calls shorter than this are skipped. + */ + qa_min_call_duration?: number; + /** + * When false, calls flagged as voicemail are skipped. + */ + qa_voicemail_calls?: boolean; + /** + * Percent of eligible calls QA'd. 100 means every call; lower values use random sampling. + */ + qa_sample_rate?: number; + /** + * When true, the QA pass uses the same LLM the workflow runs with. Set false to specify a separate provider/model. + */ + qa_use_workflow_llm?: boolean; + /** + * LLM provider used for the QA pass. + */ + qa_provider?: "openai" | "azure" | "openrouter" | "anthropic"; + /** + * Model identifier (e.g., 'gpt-4o', 'claude-sonnet-4-6'). Provider-specific. + */ + qa_model?: string; + /** + * API key for the chosen provider. + */ + qa_api_key?: string; + /** + * Required for the Azure provider. + */ + qa_endpoint?: string; +} + +/** Factory — sets `type` for you so you don't repeat the discriminator. */ +export function qa(input: Omit): Qa { + return { type: "qa", ...input }; +} diff --git a/sdk/typescript/src/typed/start-call.ts b/sdk/typescript/src/typed/start-call.ts new file mode 100644 index 0000000..f243850 --- /dev/null +++ b/sdk/typescript/src/typed/start-call.ts @@ -0,0 +1,113 @@ +// GENERATED — do not edit by hand. +// +// Regenerate with `npm run codegen` against the target Dograh backend. +// Source of truth: each node's NodeSpec in the backend's +// `api/services/workflow/node_specs/` directory. + +/** + * Each entry declares one variable to capture, with its name, data type, and per-variable extraction hint. + */ +export interface StartCallExtraction_variablesRow { + /** + * snake_case identifier used downstream. + */ + name: string; + /** + * Data type of the extracted value. + */ + type: "string" | "number" | "boolean"; + /** + * Per-variable hint describing what to look for. + */ + prompt?: string; +} + +/** + * Entry point of the workflow — plays a greeting and opens the conversation. + * + * LLM hint: The entry point of every workflow (exactly one required). Plays an optional greeting, can fetch context from an external API before the call begins, and executes the first conversational turn. + */ +export interface StartCall { + type: "startCall"; + /** + * Short identifier shown in the canvas and call logs. + */ + name?: string; + /** + * Whether the optional greeting is spoken via TTS from text or played from a pre-recorded audio file. + */ + greeting_type?: "text" | "audio"; + /** + * Text spoken via TTS at the start of the call. Supports {{template_variables}}. Leave empty to skip the greeting. + */ + greeting?: string; + /** + * Pre-recorded audio file played at the start of the call. + * + * LLM hint: Value is the `recording_id` string. Use the `list_recordings` MCP tool to discover available recordings. + */ + greeting_recording_id?: string; + /** + * Agent system prompt for the opening turn. Supports {{template_variables}} from pre-call fetch and the initial context. + */ + prompt: string; + /** + * When true, the user can interrupt the agent mid-utterance. + */ + allow_interrupt?: boolean; + /** + * When true and a Global node exists, prepends the global prompt to this node's prompt at runtime. + */ + add_global_prompt?: boolean; + /** + * When true, the agent waits before speaking after pickup. Useful for outbound calls where the called party needs a moment to settle. + */ + delayed_start?: boolean; + /** + * Seconds to wait before the agent speaks. 0.1–10. + */ + delayed_start_duration?: number; + /** + * When true, runs an LLM extraction pass on transition out of this node to capture variables from the opening turn. + */ + extraction_enabled?: boolean; + /** + * Overall instructions guiding variable extraction. + */ + extraction_prompt?: string; + /** + * Each entry declares one variable to capture, with its name, data type, and per-variable extraction hint. + */ + extraction_variables?: Array; + /** + * Tools the agent can invoke during the opening turn. + * + * LLM hint: List of tool UUIDs from `list_tools`. + */ + tool_uuids?: string[]; + /** + * Documents the agent can reference. + * + * LLM hint: List of document UUIDs from `list_documents`. + */ + document_uuids?: string[]; + /** + * When true, makes a POST request to an external API before the call starts and merges the JSON response into the call context as template variables. + */ + pre_call_fetch_enabled?: boolean; + /** + * URL the pre-call POST request is sent to. The request body includes caller and called numbers. + */ + pre_call_fetch_url?: string; + /** + * Optional credential attached to the pre-call request. + * + * LLM hint: Credential UUID from `list_credentials`. + */ + pre_call_fetch_credential_uuid?: string; +} + +/** Factory — sets `type` for you so you don't repeat the discriminator. */ +export function startCall(input: Omit): StartCall { + return { type: "startCall", ...input }; +} diff --git a/sdk/typescript/src/typed/trigger.ts b/sdk/typescript/src/typed/trigger.ts new file mode 100644 index 0000000..7f75790 --- /dev/null +++ b/sdk/typescript/src/typed/trigger.ts @@ -0,0 +1,32 @@ +// GENERATED — do not edit by hand. +// +// Regenerate with `npm run codegen` against the target Dograh backend. +// Source of truth: each node's NodeSpec in the backend's +// `api/services/workflow/node_specs/` directory. + + +/** + * Public HTTP endpoint that launches the workflow. + * + * LLM hint: Exposes a public HTTP POST endpoint. External systems call the URL (derived from the auto-generated `trigger_path`) to launch this workflow. Requires an API key in the `X-API-Key` header. + */ +export interface Trigger { + type: "trigger"; + /** + * Short identifier shown in the canvas. No runtime effect. + */ + name?: string; + /** + * When false, the trigger URL returns 404. + */ + enabled?: boolean; + /** + * Auto-generated UUID-style path segment that uniquely identifies this trigger. Do not edit manually. + */ + trigger_path?: string; +} + +/** Factory — sets `type` for you so you don't repeat the discriminator. */ +export function trigger(input: Omit): Trigger { + return { type: "trigger", ...input }; +} diff --git a/sdk/typescript/src/typed/webhook.ts b/sdk/typescript/src/typed/webhook.ts new file mode 100644 index 0000000..1058285 --- /dev/null +++ b/sdk/typescript/src/typed/webhook.ts @@ -0,0 +1,67 @@ +// GENERATED — do not edit by hand. +// +// Regenerate with `npm run codegen` against the target Dograh backend. +// Source of truth: each node's NodeSpec in the backend's +// `api/services/workflow/node_specs/` directory. + +/** + * Additional HTTP headers to include with the request. + */ +export interface WebhookCustom_headersRow { + /** + * HTTP header name (e.g., 'X-Source'). + */ + key: string; + /** + * Header value (supports {{template_variables}}). + */ + value: string; +} + +/** + * Send HTTP request after the workflow completes. + * + * LLM hint: Sends an HTTP request to an external system after the workflow completes. The payload is a Jinja-templated JSON body with access to `workflow_run_id`, `initial_context`, `gathered_context`, `annotations`, and call metadata. + */ +export interface Webhook { + type: "webhook"; + /** + * Short identifier shown in the canvas and run logs. + */ + name?: string; + /** + * When false, the webhook is skipped at run time. + */ + enabled?: boolean; + /** + * HTTP verb used for the outbound request. + */ + http_method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** + * URL the request is sent to. + */ + endpoint_url?: string; + /** + * Optional credential applied as the Authorization header. + * + * LLM hint: Credential UUID from `list_credentials`. + */ + credential_uuid?: string; + /** + * Additional HTTP headers to include with the request. + */ + custom_headers?: Array; + /** + * JSON body of the request. Values are Jinja-rendered against the run context — `{{workflow_run_id}}`, `{{gathered_context.foo}}`, `{{annotations.qa_xxx}}`, etc. + */ + payload_template?: Record; + /** + * Optional retry settings: `enabled` (bool), `max_retries` (int), `retry_delay_seconds` (int). + */ + retry_config?: Record; +} + +/** Factory — sets `type` for you so you don't repeat the discriminator. */ +export function webhook(input: Omit): Webhook { + return { type: "webhook", ...input }; +} diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts new file mode 100644 index 0000000..30b8b4b --- /dev/null +++ b/sdk/typescript/src/types.ts @@ -0,0 +1,101 @@ +// Structural types mirroring the NodeSpec schema served by the Dograh +// backend at /api/v1/node-types. Kept local (no dependency on the UI's +// generated client) so this package is self-contained and publishable. + +export type PropertyType = + | "string" + | "number" + | "boolean" + | "options" + | "multi_options" + | "fixed_collection" + | "json" + | "tool_refs" + | "document_refs" + | "recording_ref" + | "credential_ref" + | "mention_textarea" + | "url"; + +export interface PropertyOption { + value: string | number | boolean; + label: string; + description?: string | null; +} + +export interface DisplayOptions { + show?: Record | null; + hide?: Record | null; +} + +export interface PropertySpec { + name: string; + type: PropertyType; + display_name: string; + description: string; + llm_hint?: string | null; + default?: unknown; + required?: boolean; + placeholder?: string | null; + display_options?: DisplayOptions | null; + options?: PropertyOption[] | null; + properties?: PropertySpec[] | null; + min_value?: number | null; + max_value?: number | null; + min_length?: number | null; + max_length?: number | null; + pattern?: string | null; + editor?: string | null; + extra?: Record; +} + +export type NodeCategory = + | "call_node" + | "global_node" + | "trigger" + | "integration"; + +export interface NodeSpec { + name: string; + display_name: string; + description: string; + llm_hint?: string | null; + category: NodeCategory; + icon: string; + version: string; + properties: PropertySpec[]; + examples?: Array<{ + name: string; + description?: string | null; + data: Record; + }>; + // migrations and graph_constraints exist on the wire but aren't + // needed for the SDK's client-side validation — intentionally omitted. +} + +/** Opaque handle returned by `Workflow.add()` and passed to `edge()`. */ +export interface NodeRef { + id: string; + type: string; +} + +/** Wire-format shapes matching `ReactFlowDTO` in the backend. */ +export interface WireNode { + id: string; + type: string; + position: { x: number; y: number }; + data: Record; +} + +export interface WireEdge { + id: string; + source: string; + target: string; + data: Record; +} + +export interface WireWorkflow { + nodes: WireNode[]; + edges: WireEdge[]; + viewport: { x: number; y: number; zoom: number }; +} diff --git a/sdk/typescript/src/validation.ts b/sdk/typescript/src/validation.ts new file mode 100644 index 0000000..17b0563 --- /dev/null +++ b/sdk/typescript/src/validation.ts @@ -0,0 +1,157 @@ +// Client-side validation of node data against a fetched spec. Mirrors +// `sdk/python/src/dograh_sdk/_validation.py` byte-for-byte where possible +// so the two SDKs raise identical error messages for identical bad input. +// +// Intentionally lightweight: catch typos / missing required / obvious +// scalar mismatches at the call site; leave rigorous coercion to the +// backend Pydantic validators at save time. + +import { ValidationError } from "./errors.js"; +import type { NodeSpec, PropertySpec } from "./types.js"; + +// PropertyType → expected JS typeof values (after accounting for `null` +// and arrays). `null` here means "skip scalar-type check" (compound +// types, refs, JSON, etc.). +const SCALAR_TYPES: Record | null> = { + string: ["string"], + number: ["number"], + boolean: ["boolean"], + options: null, + multi_options: null, + fixed_collection: ["array"], + json: null, + tool_refs: ["array"], + document_refs: ["array"], + recording_ref: ["string"], + credential_ref: ["string"], + mention_textarea: ["string"], + url: ["string"], +}; + +function jsTypeOf(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} + +function withHint(prop: PropertySpec, message: string): string { + return prop.llm_hint ? `${message}\n Hint: ${prop.llm_hint}` : message; +} + +function checkScalar(prop: PropertySpec, value: unknown): void { + if (value === undefined || value === null) return; + const allowed = SCALAR_TYPES[prop.type]; + if (!allowed) return; + const got = jsTypeOf(value); + if (!allowed.includes(got)) { + throw new ValidationError( + withHint(prop, `${prop.name}: expected ${prop.type}, got ${got}`), + ); + } +} + +function checkOptions(prop: PropertySpec, value: unknown): void { + if (value === undefined || value === null) return; + const allowed = new Set((prop.options ?? []).map((o) => o.value)); + if (allowed.size === 0) return; + if (prop.type === "multi_options") { + if (!Array.isArray(value)) { + throw new ValidationError( + withHint( + prop, + `${prop.name}: expected list, got ${jsTypeOf(value)}`, + ), + ); + } + const bad = value.filter( + (v) => !allowed.has(v as string | number | boolean), + ); + if (bad.length > 0) { + throw new ValidationError( + withHint( + prop, + `${prop.name}: values ${JSON.stringify(bad)} not in allowed ${JSON.stringify( + [...allowed].sort(), + )}`, + ), + ); + } + } else if (!allowed.has(value as string | number | boolean)) { + throw new ValidationError( + withHint( + prop, + `${prop.name}: ${JSON.stringify(value)} not in allowed ${JSON.stringify( + [...allowed].sort(), + )}`, + ), + ); + } +} + +export function validateNodeData( + spec: NodeSpec | { name: string; properties: PropertySpec[] }, + kwargs: Record, +): Record { + const declared = new Map(spec.properties.map((p) => [p.name, p])); + + // Unknown field names — the most common LLM hallucination. + const unknown = Object.keys(kwargs).filter((k) => !declared.has(k)); + if (unknown.length > 0) { + throw new ValidationError( + `${spec.name}: unknown field(s) ${JSON.stringify(unknown.sort())}. ` + + `Allowed: ${JSON.stringify([...declared.keys()].sort())}`, + ); + } + + const data: Record = {}; + for (const [name, prop] of declared) { + let value: unknown; + if (name in kwargs) { + value = kwargs[name]; + } else if (prop.default !== undefined && prop.default !== null) { + value = prop.default; + } else { + value = undefined; + } + + if (prop.type === "options" || prop.type === "multi_options") { + checkOptions(prop, value); + } else { + checkScalar(prop, value); + } + + // Nested fixed_collection rows — validate each row as a sub-spec. + if (prop.type === "fixed_collection" && Array.isArray(value)) { + const subSpec = { + name: `${spec.name}.${name}`, + properties: prop.properties ?? [], + }; + data[name] = value.map((row) => + validateNodeData(subSpec, row as Record), + ); + continue; + } + + if (value !== undefined) data[name] = value; + } + + // Required check — must be set AND non-empty for strings. + for (const [name, prop] of declared) { + if (!prop.required) continue; + const val = data[name]; + if ( + val === undefined || + val === null || + (typeof val === "string" && val.trim() === "") + ) { + throw new ValidationError( + withHint( + prop, + `${spec.name}: required field missing: ${name}`, + ), + ); + } + } + + return data; +} diff --git a/sdk/typescript/src/workflow.ts b/sdk/typescript/src/workflow.ts new file mode 100644 index 0000000..b075daa --- /dev/null +++ b/sdk/typescript/src/workflow.ts @@ -0,0 +1,194 @@ +// Workflow builder mirroring `sdk/python/src/dograh_sdk/workflow.py`. +// +// Users compose workflows via `workflow.add({ type: "agentNode", ... })` +// and `workflow.edge(source, target, ...)`. Each `add()` call is +// validated against the fetched spec immediately, so LLM hallucinations +// fail at the call site rather than at save time. +// +// Wire format matches `ReactFlowDTO` from the backend 1:1 — `toJson()` +// output round-trips through `ReactFlowDTO.model_validate` unchanged. + +import type { NodeSpec } from "./_generated_models.js"; +import { ValidationError } from "./errors.js"; +import type { NodeRef, WireEdge, WireNode, WireWorkflow } from "./types.js"; +import { validateNodeData } from "./validation.js"; + +/** Minimal interface the Workflow builder needs from a client. Any object + * satisfying this shape works (real HTTP client, in-memory stub, etc.). */ +export interface SpecProvider { + getNodeType(name: string): Promise; +} + +export interface WorkflowOptions { + client: SpecProvider; + name?: string; + description?: string; +} + +export interface AddNodeOptions { + type: string; + position?: [number, number]; + /** Remaining node data fields are validated against the spec. */ + [key: string]: unknown; +} + +export interface EdgeOptions { + label: string; + condition: string; + transitionSpeech?: string; + transitionSpeechType?: "text" | "audio"; + transitionSpeechRecordingId?: string; +} + +export class Workflow { + readonly name: string; + readonly description: string; + private readonly client: SpecProvider; + private readonly nodes: WireNode[] = []; + private readonly edges: WireEdge[] = []; + // Auto-incrementing IDs match the pattern used by the existing UI. + private nextNodeId = 1; + + constructor(opts: WorkflowOptions) { + this.client = opts.client; + this.name = opts.name ?? ""; + this.description = opts.description ?? ""; + } + + /** + * Add a node of the given type. + * + * `type` is a spec name (e.g., "startCall", "agentNode"). Remaining + * properties are validated against the spec — unknown or missing + * required fields throw `ValidationError` immediately. + */ + async add(opts: AddNodeOptions): Promise { + const { type, position, ...rest } = opts; + const spec = await this.client.getNodeType(type); + const data = validateNodeData(spec, rest); + + const nodeId = String(this.nextNodeId++); + const [x, y] = position ?? [0, 0]; + this.nodes.push({ + id: nodeId, + type, + position: { x, y }, + data, + }); + return { id: nodeId, type }; + } + + /** + * Typed variant of `add()` — takes a typed node object from + * `@dograh/sdk/typed` (or its discriminated-union form) instead of + * raw kwargs. + * + * Equivalent to: + * const { type, ...rest } = node; + * wf.add({ type, position, ...rest }); + * + * Benefits: TS narrows the allowed fields per `type` at edit time, + * and IDEs surface the spec's description + llm_hint as JSDoc. + */ + async addTyped( + node: T, + opts?: { position?: [number, number] }, + ): Promise { + const { type, ...rest } = node as unknown as { type: string } & Record< + string, + unknown + >; + return this.add({ type, position: opts?.position, ...rest }); + } + + /** + * Connect two nodes with a labeled transition. + * + * `label` identifies the branch in call logs and LLM tool schemas; + * `condition` is the natural-language predicate the engine evaluates + * to decide when to follow the edge. + */ + edge(source: NodeRef, target: NodeRef, opts: EdgeOptions): void { + if (!opts.label || opts.label.trim() === "") { + throw new ValidationError("edge.label is required"); + } + if (!opts.condition || opts.condition.trim() === "") { + throw new ValidationError("edge.condition is required"); + } + + const data: Record = { + label: opts.label, + condition: opts.condition, + }; + if (opts.transitionSpeech !== undefined) { + data.transition_speech = opts.transitionSpeech; + } + if (opts.transitionSpeechType !== undefined) { + data.transition_speech_type = opts.transitionSpeechType; + } + if (opts.transitionSpeechRecordingId !== undefined) { + data.transition_speech_recording_id = opts.transitionSpeechRecordingId; + } + + this.edges.push({ + id: `${source.id}-${target.id}`, + source: source.id, + target: target.id, + data, + }); + } + + /** Serialize to the `ReactFlowDTO` wire format. */ + toJson(): WireWorkflow { + return { + nodes: this.nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })), + edges: this.edges.map((e) => ({ ...e, data: { ...e.data } })), + viewport: { x: 0, y: 0, zoom: 1 }, + }; + } + + /** + * Rebuild a Workflow from a stored `workflow_json` payload. Useful + * for the "view/edit as code" flow: fetch existing workflow, convert + * to SDK objects, let the LLM mutate in code, serialize back. + */ + static async fromJson( + payload: { nodes?: WireNode[]; edges?: WireEdge[] } & Record, + opts: WorkflowOptions, + ): Promise { + const wf = new Workflow(opts); + for (const raw of payload.nodes ?? []) { + const spec = await wf.client.getNodeType(raw.type); + const validated = validateNodeData(spec, raw.data ?? {}); + wf.nodes.push({ + id: String(raw.id), + type: raw.type, + position: raw.position ?? { x: 0, y: 0 }, + data: validated, + }); + } + // Keep ID generator above the highest numeric ID seen so new + // nodes don't collide with existing ones. + const numericIds = wf.nodes + .map((n) => Number(n.id)) + .filter((n) => Number.isInteger(n)); + wf.nextNodeId = (numericIds.length > 0 ? Math.max(...numericIds) : 0) + 1; + + for (const raw of payload.edges ?? []) { + wf.edges.push({ + id: String(raw.id ?? `${raw.source}-${raw.target}`), + source: String(raw.source), + target: String(raw.target), + data: raw.data ?? {}, + }); + } + return wf; + } + + /** Find a NodeRef by ID. Useful after `fromJson` to reference + * pre-existing nodes when building new edges. */ + findNode(id: string): NodeRef | null { + const found = this.nodes.find((n) => n.id === id); + return found ? { id: found.id, type: found.type } : null; + } +} diff --git a/sdk/typescript/tests/sdk.test.mts b/sdk/typescript/tests/sdk.test.mts new file mode 100644 index 0000000..46ff94e --- /dev/null +++ b/sdk/typescript/tests/sdk.test.mts @@ -0,0 +1,423 @@ +// Unit tests for @dograh/sdk. Uses Node's built-in `node:test` runner and +// an in-memory spec stub — no HTTP, no backend dependency. Mirrors the +// Python SDK tests in api/tests/test_dograh_sdk.py. +// +// Run via `npm test` in sdk/typescript/. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// Import the BUILT artifact — same shape consumers get from `npm install`. +// `npm test` runs `tsc` first so dist/ is fresh. +import { + ApiError, + DograhClient, + SpecMismatchError, + ValidationError, + Workflow, +} from "../dist/index.js"; +import type { NodeSpec, SpecProvider } from "../dist/index.js"; + +// ─── Minimal fixture specs (enough to cover the SDK's code paths) ───────── + +const SPECS: Record = { + startCall: { + name: "startCall", + display_name: "Start Call", + description: "Entry point.", + category: "call_node", + icon: "Play", + version: "1.0.0", + properties: [ + { + name: "name", + type: "string", + display_name: "Name", + description: "n", + required: true, + default: "Start Call", + }, + { + name: "prompt", + type: "mention_textarea", + display_name: "Prompt", + description: "p", + required: true, + }, + { + name: "allow_interrupt", + type: "boolean", + display_name: "Allow Interrupt", + description: "a", + default: false, + }, + { + name: "greeting_type", + type: "options", + display_name: "Greeting Type", + description: "g", + default: "text", + options: [ + { value: "text", label: "Text" }, + { value: "audio", label: "Audio" }, + ], + }, + ], + }, + agentNode: { + name: "agentNode", + display_name: "Agent", + description: "Mid-call step.", + category: "call_node", + icon: "Headset", + version: "1.0.0", + properties: [ + { + name: "name", + type: "string", + display_name: "Name", + description: "n", + required: true, + }, + { + name: "prompt", + type: "mention_textarea", + display_name: "Prompt", + description: "p", + required: true, + }, + { + name: "allow_interrupt", + type: "boolean", + display_name: "Allow", + description: "a", + default: true, + }, + { + name: "tool_uuids", + type: "tool_refs", + display_name: "Tools", + description: "Tools the agent can invoke.", + llm_hint: "List of tool UUIDs from `list_tools`.", + }, + ], + }, + endCall: { + name: "endCall", + display_name: "End", + description: "Terminal.", + category: "call_node", + icon: "OctagonX", + version: "1.0.0", + properties: [ + { + name: "name", + type: "string", + display_name: "Name", + description: "n", + required: true, + }, + { + name: "prompt", + type: "mention_textarea", + display_name: "Prompt", + description: "p", + required: true, + }, + ], + }, +}; + +class StubClient implements SpecProvider { + async getNodeType(name: string): Promise { + const spec = SPECS[name]; + if (!spec) throw new SpecMismatchError(`Unknown spec: ${name}`); + return spec; + } +} + +const client = new StubClient(); + +// ─── Builder + toJson round-trip ────────────────────────────────────────── + +describe("Workflow builder", () => { + it("builds a minimal workflow and serializes the wire shape", async () => { + const wf = new Workflow({ client, name: "minimal" }); + const start = await wf.add({ + type: "startCall", + name: "greeting", + prompt: "Say hi.", + }); + const end = await wf.add({ + type: "endCall", + name: "close", + prompt: "Thank them.", + }); + wf.edge(start, end, { label: "done", condition: "All greeted." }); + + const payload = wf.toJson(); + assert.equal(payload.nodes.length, 2); + assert.deepEqual( + payload.nodes.map((n) => n.type).sort(), + ["endCall", "startCall"], + ); + assert.equal(payload.edges.length, 1); + const edge = payload.edges[0]!; + assert.equal(edge.source, start.id); + assert.equal(edge.target, end.id); + }); + + it("applies spec defaults when fields are omitted", async () => { + const wf = new Workflow({ client }); + const start = await wf.add({ + type: "startCall", + name: "g", + prompt: "hi", + }); + const data = wf.toJson().nodes[0]!.data; + assert.equal(data.allow_interrupt, false); + assert.equal(data.greeting_type, "text"); + assert.ok(start.id); + }); +}); + +// ─── Validation errors ──────────────────────────────────────────────────── + +describe("validation", () => { + it("catches unknown field names", async () => { + const wf = new Workflow({ client }); + await assert.rejects( + () => + wf.add({ + type: "startCall", + name: "g", + prompt: "hi", + promt: "typo", + }), + (err: unknown) => { + assert.ok(err instanceof ValidationError); + assert.match(err.message, /unknown field/); + return true; + }, + ); + }); + + it("catches missing required fields", async () => { + const wf = new Workflow({ client }); + await assert.rejects( + () => wf.add({ type: "startCall", name: "g" }), + (err: unknown) => { + assert.ok(err instanceof ValidationError); + assert.match(err.message, /required field missing: prompt/); + return true; + }, + ); + }); + + it("catches wrong scalar types", async () => { + const wf = new Workflow({ client }); + await assert.rejects( + () => + wf.add({ + type: "agentNode", + name: "x", + prompt: "y", + allow_interrupt: "yes", + }), + (err: unknown) => { + assert.ok(err instanceof ValidationError); + assert.match(err.message, /expected boolean/); + return true; + }, + ); + }); + + it("catches invalid options values", async () => { + const wf = new Workflow({ client }); + await assert.rejects( + () => + wf.add({ + type: "startCall", + name: "g", + prompt: "hi", + greeting_type: "video", + }), + (err: unknown) => { + assert.ok(err instanceof ValidationError); + assert.match(err.message, /not in allowed/); + return true; + }, + ); + }); + + it("surfaces llm_hint in error messages when the spec has one", async () => { + const wf = new Workflow({ client }); + await assert.rejects( + () => + wf.add({ + type: "agentNode", + name: "x", + prompt: "y", + tool_uuids: "single-uuid-not-a-list", + }), + (err: unknown) => { + assert.ok(err instanceof ValidationError); + assert.match(err.message, /tool_uuids/); + assert.match(err.message, /Hint:/); + assert.match(err.message, /list_tools/); + return true; + }, + ); + }); + + it("does not add 'Hint:' when a spec has no llm_hint", async () => { + const wf = new Workflow({ client }); + await assert.rejects( + () => + wf.add({ + type: "agentNode", + name: "x", + prompt: "y", + allow_interrupt: "yes", + }), + (err: unknown) => { + assert.ok(err instanceof ValidationError); + assert.ok(!err.message.includes("Hint:")); + return true; + }, + ); + }); + + it("rejects edges without label or condition", async () => { + const wf = new Workflow({ client }); + const a = await wf.add({ type: "startCall", name: "a", prompt: "hi" }); + const b = await wf.add({ type: "endCall", name: "b", prompt: "bye" }); + assert.throws(() => wf.edge(a, b, { label: "", condition: "x" }), ValidationError); + assert.throws(() => wf.edge(a, b, { label: "x", condition: "" }), ValidationError); + }); +}); + +// ─── Round-trip fromJson → edit → toJson ────────────────────────────────── + +describe("round-trip", () => { + it("fromJson preserves IDs and subsequent add() does not collide", async () => { + const wf0 = new Workflow({ client }); + const start = await wf0.add({ type: "startCall", name: "g", prompt: "hi" }); + const end = await wf0.add({ type: "endCall", name: "e", prompt: "bye" }); + wf0.edge(start, end, { label: "done", condition: "done" }); + + const payload = wf0.toJson(); + const wf1 = await Workflow.fromJson(payload, { client }); + + assert.deepEqual( + wf1.toJson().nodes.map((n) => n.id), + [start.id, end.id], + ); + const fresh = await wf1.add({ + type: "agentNode", + name: "mid", + prompt: "do stuff", + }); + assert.notEqual(fresh.id, start.id); + assert.notEqual(fresh.id, end.id); + assert.ok(Number(fresh.id) > Math.max(Number(start.id), Number(end.id))); + }); + + it("fromJson validates data — unknown field raises", async () => { + const bad = { + nodes: [ + { + id: "1", + type: "startCall", + position: { x: 0, y: 0 }, + data: { name: "g", prompt: "hi", bogus: 1 }, + }, + ], + edges: [], + }; + await assert.rejects( + () => Workflow.fromJson(bad, { client }), + (err: unknown) => { + assert.ok(err instanceof ValidationError); + assert.match(err.message, /unknown field/); + return true; + }, + ); + }); +}); + +// ─── DograhClient HTTP plumbing (stubbed fetch) ─────────────────────────── + +describe("DograhClient", () => { + it("sends the API key as X-API-Key", async () => { + let capturedHeaders: Headers | undefined; + const stubFetch: typeof fetch = async (_input, init) => { + capturedHeaders = new Headers(init?.headers); + return new Response( + JSON.stringify({ spec_version: "1.0.0", node_types: [] }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }; + const c = new DograhClient({ + baseUrl: "http://api.example", + apiKey: "sk-test", + fetch: stubFetch, + }); + await c.listNodeTypes(); + assert.equal(capturedHeaders?.get("x-api-key"), "sk-test"); + }); + + it("surfaces 4xx responses as ApiError", async () => { + const stubFetch: typeof fetch = async () => + new Response(JSON.stringify({ detail: "Unknown node type: 'foo'" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + const c = new DograhClient({ + baseUrl: "http://api.example", + apiKey: "k", + fetch: stubFetch, + }); + await assert.rejects( + () => c.getNodeType("foo"), + (err: unknown) => { + assert.ok(err instanceof SpecMismatchError); + return true; + }, + ); + }); + + it("caches specs per client so a second get_node_type is free", async () => { + let calls = 0; + const spec: NodeSpec = { + name: "startCall", + display_name: "Start", + description: "d", + category: "call_node", + icon: "Play", + version: "1.0.0", + properties: [], + }; + const stubFetch: typeof fetch = async () => { + calls++; + return new Response(JSON.stringify(spec), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + const c = new DograhClient({ + baseUrl: "http://api.example", + apiKey: "k", + fetch: stubFetch, + }); + await c.getNodeType("startCall"); + await c.getNodeType("startCall"); + assert.equal(calls, 1); + }); + + it("ApiError constructor stores statusCode and body", () => { + const err = new ApiError(500, "boom", { detail: "oops" }); + assert.equal(err.statusCode, 500); + assert.deepEqual(err.body, { detail: "oops" }); + }); +}); diff --git a/sdk/typescript/tests/typed.test.mts b/sdk/typescript/tests/typed.test.mts new file mode 100644 index 0000000..376db15 --- /dev/null +++ b/sdk/typescript/tests/typed.test.mts @@ -0,0 +1,131 @@ +// Tests for the typed SDK (`@dograh/sdk/typed`). Mirrors +// api/tests/test_dograh_sdk_typed.py — checks that generated factories +// produce objects consumable by `workflow.addTyped()`. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + agentNode, + endCall, + startCall, + type AgentNode, + type EndCall, + type StartCall, + type Trigger, + type TypedNode, +} from "../dist/typed/index.js"; +import { Workflow, type NodeSpec } from "../dist/index.js"; +import type { SpecProvider } from "../dist/workflow.js"; + +// Minimal spec stub matching the shape `getNodeType` returns — we just +// need `properties` for the validator to do its job. +const MINIMAL_SPECS: Record = { + startCall: { + name: "startCall", + display_name: "Start Call", + description: "entry", + category: "call_node", + icon: "Play", + version: "1.0.0", + properties: [ + { name: "name", type: "string", display_name: "N", description: "d", required: true, default: "Start Call" }, + { name: "prompt", type: "mention_textarea", display_name: "P", description: "d", required: true }, + ], + }, + agentNode: { + name: "agentNode", + display_name: "Agent", + description: "step", + category: "call_node", + icon: "Headset", + version: "1.0.0", + properties: [ + { name: "name", type: "string", display_name: "N", description: "d", required: true }, + { name: "prompt", type: "mention_textarea", display_name: "P", description: "d", required: true }, + ], + }, + endCall: { + name: "endCall", + display_name: "End", + description: "terminal", + category: "call_node", + icon: "OctagonX", + version: "1.0.0", + properties: [ + { name: "name", type: "string", display_name: "N", description: "d", required: true }, + { name: "prompt", type: "mention_textarea", display_name: "P", description: "d", required: true }, + ], + }, +}; + +class StubClient implements SpecProvider { + async getNodeType(name: string): Promise { + const s = MINIMAL_SPECS[name]; + if (!s) throw new Error(`Unknown spec: ${name}`); + return s; + } +} + +// ─── Factories stamp the `type` discriminator ───────────────────────────── + +describe("typed factories", () => { + it("startCall() fills in the type discriminator", () => { + const node = startCall({ name: "g", prompt: "hi" }); + assert.equal(node.type, "startCall"); + assert.equal(node.name, "g"); + assert.equal(node.prompt, "hi"); + }); + + it("agentNode() fills in the type discriminator", () => { + const node = agentNode({ name: "a", prompt: "ask" }); + assert.equal(node.type, "agentNode"); + }); + + it("endCall() fills in the type discriminator", () => { + const node = endCall({ name: "e", prompt: "bye" }); + assert.equal(node.type, "endCall"); + }); +}); + +// ─── Workflow.addTyped integrates with the generic builder ──────────────── + +describe("Workflow.addTyped", () => { + it("accepts a typed factory result and round-trips through toJson", async () => { + const wf = new Workflow({ client: new StubClient(), name: "typed-e2e" }); + const start = await wf.addTyped(startCall({ name: "g", prompt: "hi" })); + const end = await wf.addTyped(endCall({ name: "e", prompt: "bye" })); + wf.edge(start, end, { label: "done", condition: "done" }); + + const payload = wf.toJson(); + assert.equal(payload.nodes.length, 2); + assert.equal(payload.nodes[0]!.type, "startCall"); + assert.equal(payload.nodes[1]!.type, "endCall"); + assert.equal(payload.edges.length, 1); + }); + + it("addTyped and add produce identical node data for equivalent inputs", async () => { + const typedWf = new Workflow({ client: new StubClient() }); + await typedWf.addTyped(agentNode({ name: "q", prompt: "ask" })); + + const genericWf = new Workflow({ client: new StubClient() }); + await genericWf.add({ type: "agentNode", name: "q", prompt: "ask" }); + + assert.deepEqual( + typedWf.toJson().nodes[0]!.data, + genericWf.toJson().nodes[0]!.data, + ); + }); + + it("TypedNode union narrows correctly on `type`", async () => { + // Compile-time check — TS narrows on the literal discriminator. + const node: TypedNode = startCall({ name: "g", prompt: "hi" }); + if (node.type === "startCall") { + // `node` is narrowed to StartCall here; the following access + // compiles without a cast. + assert.equal(node.prompt, "hi"); + } else { + assert.fail("expected StartCall narrowing"); + } + }); +}); diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json new file mode 100644 index 0000000..3fb9984 --- /dev/null +++ b/sdk/typescript/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "noUncheckedIndexedAccess": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/ui/package.json b/ui/package.json index efe510b..f2bbccb 100644 --- a/ui/package.json +++ b/ui/package.json @@ -8,7 +8,8 @@ "start": "next start", "lint": "next lint", "fix-lint": "npx eslint --fix . --ignore-pattern '.next/*' --ignore-pattern 'node_modules/*' --ignore-pattern 'next-env.d.ts'", - "generate-client": "openapi-ts" + "generate-client": "openapi-ts", + "test:display-options": "node scripts/test-display-options.mts" }, "dependencies": { "@dagrejs/dagre": "^1.1.4", diff --git a/ui/scripts/test-display-options.mts b/ui/scripts/test-display-options.mts new file mode 100644 index 0000000..99d9e38 --- /dev/null +++ b/ui/scripts/test-display-options.mts @@ -0,0 +1,50 @@ +// Golden-test parity: run every case in +// api/services/workflow/node_specs/display_options_fixtures.json through +// the TypeScript evaluator and assert the result matches `expected`. +// +// Run via `npm run test:display-options` from ui/, or `node +// ui/scripts/test-display-options.mts` directly (Node 24+ strips TS types +// natively). +// +// Mirrors `api/tests/test_display_options_evaluator.py`. + +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { evaluateDisplayOptions } from "../src/components/flow/renderer/displayOptions.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURES_PATH = resolve( + __dirname, + "../../api/services/workflow/node_specs/display_options_fixtures.json", +); + +interface Case { + name: string; + rules: Parameters[0]; + values: Record; + expected: boolean; +} + +const data = JSON.parse(readFileSync(FIXTURES_PATH, "utf-8")) as { cases: Case[] }; + +let failed = 0; +for (const c of data.cases) { + const actual = evaluateDisplayOptions(c.rules, c.values); + if (actual !== c.expected) { + console.error( + `FAIL ${c.name}: expected ${c.expected}, got ${actual}\n` + + ` rules=${JSON.stringify(c.rules)} values=${JSON.stringify(c.values)}`, + ); + failed++; + } else { + console.log(`PASS ${c.name}`); + } +} + +if (failed > 0) { + console.error(`\n${failed} of ${data.cases.length} cases failed`); + process.exit(1); +} +console.log(`\nAll ${data.cases.length} cases passed`); diff --git a/ui/src/app/files/DocumentUpload.tsx b/ui/src/app/files/DocumentUpload.tsx index 345b928..d2e9cc1 100644 --- a/ui/src/app/files/DocumentUpload.tsx +++ b/ui/src/app/files/DocumentUpload.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FileText, Upload, X } from 'lucide-react'; +import { FileText, Info, Upload, X } from 'lucide-react'; import { useRef, useState } from 'react'; import { toast } from 'sonner'; @@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Progress } from '@/components/ui/progress'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { useAppConfig } from '@/context/AppConfigContext'; import logger from '@/lib/logger'; interface DocumentUploadProps { @@ -23,6 +24,8 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const ACCEPTED_FILE_TYPES = ['.pdf', '.docx', '.doc', '.txt', '.json']; export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps) { + const { config } = useAppConfig(); + const isOSS = config?.deploymentMode === 'oss'; const [selectedFile, setSelectedFile] = useState(null); const [retrievalMode, setRetrievalMode] = useState('full_document'); const [uploading, setUploading] = useState(false); @@ -30,6 +33,21 @@ export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps) const [dragActive, setDragActive] = useState(false); const fileInputRef = useRef(null); + const ossNotice = isOSS ? ( +
+ +
+

Processed by an external service

+

+ Uploaded documents are sent to Dograh's managed Model Proxy Service for + parsing and chunking. Dograh Model Proxy Service does not store or read your documents - + the extracted text and embeddings are returned and stored locally in your + self-hosted database. +

+
+
+ ) : null; + const validateFile = (file: File): boolean => { const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); if (!ACCEPTED_FILE_TYPES.includes(fileExtension)) { @@ -164,6 +182,7 @@ export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps) if (selectedFile && !uploading) { return (
+ {ossNotice} {/* Selected file info */}
@@ -225,6 +244,7 @@ export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps) return (
+ {ossNotice} [t, GenericNode]), +); const edgeTypes = { custom: CustomEdge, diff --git a/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts b/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts index 8443acf..7411c97 100644 --- a/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts +++ b/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts @@ -17,141 +17,54 @@ import { updateWorkflowApiV1WorkflowWorkflowIdPut, validateWorkflowApiV1WorkflowWorkflowIdValidatePost } from "@/client"; -import { WorkflowError } from "@/client/types.gen"; -import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types"; +import { NodeSpec, WorkflowError } from "@/client/types.gen"; +import { useNodeSpecs } from "@/components/flow/renderer"; +import { FlowEdge, FlowNode, FlowNodeData, NodeType } from "@/components/flow/types"; import { PostHogEvent } from "@/constants/posthog-events"; import logger from '@/lib/logger'; import { getNextNodeId, getRandomId } from "@/lib/utils"; import { DEFAULT_WORKFLOW_CONFIGURATIONS, WorkflowConfigurations } from "@/types/workflow-configurations"; -const DEFAULT_QA_SYSTEM_PROMPT = `You are a QA analyst evaluating a specific segment of a voice AI conversation. - -## Node Purpose -{{node_summary}} - -## Previous Conversation Context (For start of conversation, previous conversation summary can be empty.) -{{previous_conversation_summary}} - -## Tags to evaluate - -Examine the conversation carefully and identify which of the following tags apply: - -- UNCLEAR_CONVERSATION - The conversation is not coherent or clear, messages don't connect logically -- ASSISTANT_IN_LOOP - The assistant asks the same question multiple times or gets stuck repeating itself -- ASSISTANT_REPLY_IMPROPER - The assistant did not reply properly to the user's question/query or seems confused by what the user said -- USER_FRUSTRATED - The user seems angry, frustrated, or is complaining about something in the call -- USER_NOT_UNDERSTANDING - The user explicitly says they don't understand or repeatedly asks for clarification -- HEARING_ISSUES - Either party can't hear the other ("hello?", "are you there?", "can you hear me?") -- DEAD_AIR - Unusually long silences in the conversation (use the timestamps to judge) -- USER_REQUESTING_FEATURE - The user asks for something the assistant can't fulfill -- ASSISTANT_LACKS_EMPATHY - The assistant ignores the user's personal situation or emotional state and continues pitching or pushing the agenda. -- USER_DETECTS_AI - The user suspects or identifies that they are talking to an AI/robot/bot rather than a real human. - -## Call metrics (pre-computed) - -Use these alongside the transcript for your analysis: -{{metrics}} - -## Output format - -Return ONLY a valid JSON object (no markdown): -{ - "tags": [ - { - "tag": "TAG_NAME", - "reason": "Short reason with evidence from the transcript" +// Build initial node data from spec defaults. Replaces the per-type +// hardcoded `getNewNode` switch — adding a new node type is now zero +// frontend code: declare the spec on the backend and the defaults flow +// through here. +function buildDataFromSpec(spec: NodeSpec): Record { + const data: Record = {}; + for (const prop of spec.properties) { + if (prop.default !== undefined && prop.default !== null) { + data[prop.name] = prop.default; } - ], - "overall_sentiment": "positive|neutral|negative", - "call_quality_score": <1-10>, - "summary": "1-2 sentence summary of this segment" -} - -If no tags apply, return an empty tags list. Always provide sentiment, score, and summary.`; - -export function getDefaultAllowInterrupt(type: string = NodeType.START_CALL): boolean { - switch (type) { - case NodeType.AGENT_NODE: - return true; // Agents can be interrupted - case NodeType.START_CALL: - case NodeType.END_CALL: - return false; // Start/End messages should not be interrupted - default: - return false; } + return data; } -const defaultNodes: FlowNode[] = [ - { - id: "1", - type: NodeType.START_CALL, - position: { x: 200, y: 200 }, - data: { - prompt: "", - name: "", - allow_interrupt: getDefaultAllowInterrupt(NodeType.START_CALL), - }, - }, -]; - -const getNewNode = (type: string, position: { x: number, y: number }, existingNodes: FlowNode[]) => { - // Base node configuration - const baseNode = { +function buildNewNode( + type: string, + position: { x: number; y: number }, + existingNodes: FlowNode[], + spec: NodeSpec, +): FlowNode { + const data = buildDataFromSpec(spec) as Partial & Record; + if (type === NodeType.START_CALL) data.is_start = true; + if (type === NodeType.END_CALL) data.is_end = true; + return { id: getNextNodeId(existingNodes), type, position, - data: { - prompt: { - [NodeType.GLOBAL_NODE]: "You are a helpful assistant whose mode of interaction with the user is voice. So don't use any special characters which can not be pronounced. Use short sentences and simple language.", - }[type] || "", - name: { - [NodeType.GLOBAL_NODE]: "Global Node", - [NodeType.START_CALL]: "Start Call", - [NodeType.END_CALL]: "End Call", - [NodeType.WEBHOOK]: "Webhook", - [NodeType.QA]: "QA Analysis", - }[type] || "", - allow_interrupt: getDefaultAllowInterrupt(type), - }, + data: data as unknown as FlowNode["data"], }; +} - // Add webhook-specific defaults - if (type === NodeType.WEBHOOK) { - return { - ...baseNode, - data: { - ...baseNode.data, - enabled: true, - http_method: "POST" as const, - endpoint_url: "", - custom_headers: [], - payload_template: { - call_id: "{{workflow_run_id}}", - first_name: "{{initial_context.first_name}}", - rsvp: "{{gathered_context.rsvp}}", - duration: "{{cost_info.call_duration_seconds}}", - recording_url: "{{recording_url}}", - transcript_url: "{{transcript_url}}", - }, - }, - }; - } - - // Add QA-specific defaults - if (type === NodeType.QA) { - return { - ...baseNode, - data: { - ...baseNode.data, - qa_enabled: true, - qa_model: "default", - qa_system_prompt: DEFAULT_QA_SYSTEM_PROMPT, - }, - }; - } - - return baseNode; -}; +// Look up the spec default for `allow_interrupt`. Used as a load-time +// fallback for older saved workflows whose nodes lack the field. +function specAllowInterrupt( + type: string, + bySpecName: Map, +): boolean | undefined { + const prop = bySpecName.get(type)?.properties.find((p) => p.name === "allow_interrupt"); + return prop?.default as boolean | undefined; +} interface UseWorkflowStateProps { initialWorkflowName: string; @@ -181,6 +94,10 @@ export const useWorkflowState = ({ const router = useRouter(); const rfInstance = useRef | null>(null); + // Spec catalog. Workflow init waits on this to populate defaults; node + // creation looks up per-type schemas through it. + const { bySpecName, loading: specsLoading } = useNodeSpecs(); + // Get state and actions from the store const { nodes, @@ -214,20 +131,32 @@ export const useWorkflowState = ({ const canUndo = useWorkflowStore((state) => state.canUndo()); const canRedo = useWorkflowStore((state) => state.canRedo()); - // Initialize workflow on mount + // Initialize workflow on mount. Waits for the spec catalog so defaults + // (allow_interrupt, prompt placeholders, etc.) come from one source. useEffect(() => { + if (specsLoading) return; + + const startSpec = bySpecName.get(NodeType.START_CALL); + const fallbackStartNodes: FlowNode[] = startSpec + ? [buildNewNode(NodeType.START_CALL, { x: 200, y: 200 }, [], startSpec)] + : []; + const initialNodes = initialFlow?.nodes?.length - ? initialFlow.nodes.map(node => ({ - ...node, - data: { - ...node.data, - invalid: false, - allow_interrupt: node.data.allow_interrupt !== undefined - ? node.data.allow_interrupt - : getDefaultAllowInterrupt(node.type), - } - })) - : defaultNodes; + ? initialFlow.nodes.map((node) => { + const fallbackAllowInterrupt = specAllowInterrupt(node.type, bySpecName) ?? false; + return { + ...node, + data: { + ...node.data, + invalid: false, + allow_interrupt: + node.data.allow_interrupt !== undefined + ? node.data.allow_interrupt + : fallbackAllowInterrupt, + }, + }; + }) + : fallbackStartNodes; initializeWorkflow( workflowId, @@ -238,7 +167,7 @@ export const useWorkflowState = ({ initialWorkflowConfigurations, initialWorkflowConfigurations?.dictionary ?? '' ); - }, [workflowId, initialWorkflowName, initialFlow?.nodes, initialFlow?.edges, initialTemplateContextVariables, initialWorkflowConfigurations, initializeWorkflow]); + }, [workflowId, initialWorkflowName, initialFlow?.nodes, initialFlow?.edges, initialTemplateContextVariables, initialWorkflowConfigurations, initializeWorkflow, specsLoading, bySpecName]); // Set up keyboard shortcuts for undo/redo useEffect(() => { @@ -280,8 +209,13 @@ export const useWorkflowState = ({ y: window.innerHeight / 2, }); + const spec = bySpecName.get(nodeType); + if (!spec) { + logger.warn({ nodeType }, "No spec registered for node type — cannot add"); + return; + } const newNode = { - ...getNewNode(nodeType, position, nodes), + ...buildNewNode(nodeType, position, nodes, spec), selected: true, // Mark the new node as selected }; @@ -297,7 +231,7 @@ export const useWorkflowState = ({ workflow_id: workflowId, }); setIsAddNodePanelOpen(false); - }, [nodes, setIsAddNodePanelOpen, workflowId]); + }, [nodes, setIsAddNodePanelOpen, workflowId, bySpecName]); const handleNameChange = (e: React.ChangeEvent) => { setWorkflowName(e.target.value); diff --git a/ui/src/client/index.ts b/ui/src/client/index.ts index c5d7da7..8142612 100644 --- a/ui/src/client/index.ts +++ b/ui/src/client/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { archiveApiKeyApiV1UserApiKeysApiKeyIdDelete, archiveServiceKeyApiV1UserServiceKeysServiceKeyIdDelete, completeTransferFunctionCallApiV1TelephonyTransferResultTransferIdPost, createApiKeyApiV1UserApiKeysPost, createCampaignApiV1CampaignCreatePost, createCredentialApiV1CredentialsPost, createLoadTestApiV1LooptalkLoadTestsPost, createOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPost, createRecordingsApiV1WorkflowRecordingsPost, createServiceKeyApiV1UserServiceKeysPost, createSessionApiV1IntegrationSessionPost, createTestSessionApiV1LooptalkTestSessionsPost, createToolApiV1ToolsPost, createWorkflowApiV1WorkflowCreateDefinitionPost, createWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPost, createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost, createWorkflowRunApiV1WorkflowWorkflowIdRunsPost, deactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDelete, deleteCredentialApiV1CredentialsCredentialUuidDelete, deleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDelete, deleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDelete, deleteRecordingApiV1WorkflowRecordingsRecordingIdDelete, deleteToolApiV1ToolsToolUuidDelete, downloadCampaignReportApiV1CampaignCampaignIdReportGet, downloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGet, downloadWorkflowReportApiV1WorkflowWorkflowIdReportGet, duplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePost, duplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePost, getActiveTestsApiV1LooptalkActiveTestsGet, getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost, getApiKeysApiV1UserApiKeysGet, getAuthUserApiV1UserAuthUserGet, getCampaignApiV1CampaignCampaignIdGet, getCampaignDefaultsApiV1OrganizationsCampaignDefaultsGet, getCampaignProgressApiV1CampaignCampaignIdProgressGet, getCampaignRunsApiV1CampaignCampaignIdRunsGet, getCampaignsApiV1CampaignGet, getCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGet, getCredentialApiV1CredentialsCredentialUuidGet, getCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGet, getCurrentUserApiV1AuthMeGet, getDailyReportApiV1OrganizationsReportsDailyGet, getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet, getDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGet, getEmbedConfigApiV1PublicEmbedConfigTokenGet, getEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGet, getFileMetadataApiV1S3FileMetadataGet, getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet, getIntegrationsApiV1IntegrationGet, getLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGet, getLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getPresignedUploadUrlApiV1S3PresignedUploadUrlPost, getPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGet, getServiceKeysApiV1UserServiceKeysGet, getSignedUrlApiV1S3SignedUrlGet, getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet, getTestSessionApiV1LooptalkTestSessionsTestSessionIdGet, getTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGet, getToolApiV1ToolsToolUuidGet, getTurnCredentialsApiV1TurnCredentialsGet, getUploadUrlApiV1KnowledgeBaseUploadUrlPost, getUploadUrlsApiV1WorkflowRecordingsUploadUrlPost, getUsageHistoryApiV1OrganizationsUsageRunsGet, getUserConfigurationsApiV1UserConfigurationsUserGet, getVoicesApiV1UserConfigurationsVoicesProviderGet, getWorkflowApiV1WorkflowFetchWorkflowIdGet, getWorkflowCountApiV1WorkflowCountGet, getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet, getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet, getWorkflowRunsApiV1SuperuserWorkflowRunsGet, getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet, getWorkflowsApiV1WorkflowFetchGet, getWorkflowsSummaryApiV1WorkflowSummaryGet, getWorkflowTemplatesApiV1WorkflowTemplatesGet, getWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGet, handleCloudonixCdrApiV1TelephonyCloudonixCdrPost, handleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPost, handleInboundFallbackApiV1TelephonyInboundFallbackPost, handleInboundTelephonyApiV1TelephonyInboundWorkflowIdPost, handleTelnyxEventsApiV1TelephonyTelnyxEventsWorkflowRunIdPost, handleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPost, handleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPost, handleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPost, handleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPost, handleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPost, healthApiV1HealthGet, impersonateApiV1SuperuserImpersonatePost, initializeEmbedSessionApiV1PublicEmbedInitPost, initiateCallApiV1PublicAgentUuidPost, initiateCallApiV1TelephonyInitiateCallPost, initiateCallTransferApiV1TelephonyCallTransferPost, listCredentialsApiV1CredentialsGet, listDocumentsApiV1KnowledgeBaseDocumentsGet, listRecordingsApiV1WorkflowRecordingsGet, listTestSessionsApiV1LooptalkTestSessionsGet, listToolsApiV1ToolsGet, loginApiV1AuthLoginPost, type Options, optionsConfigApiV1PublicEmbedConfigTokenOptions, optionsInitApiV1PublicEmbedInitOptions, optionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptions, pauseCampaignApiV1CampaignCampaignIdPausePost, processDocumentApiV1KnowledgeBaseProcessDocumentPost, publishWorkflowApiV1WorkflowWorkflowIdPublishPost, reactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePut, reactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePut, redialCampaignApiV1CampaignCampaignIdRedialPost, resumeCampaignApiV1CampaignCampaignIdResumePost, saveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPost, saveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPost, searchChunksApiV1KnowledgeBaseSearchPost, signupApiV1AuthSignupPost, startCampaignApiV1CampaignCampaignIdStartPost, startTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPost, stopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPost, transcribeAudioApiV1WorkflowRecordingsTranscribePost, unarchiveToolApiV1ToolsToolUuidUnarchivePost, updateCampaignApiV1CampaignCampaignIdPatch, updateCredentialApiV1CredentialsCredentialUuidPut, updateIntegrationApiV1IntegrationIntegrationIdPut, updateRecordingApiV1WorkflowRecordingsIdPatch, updateToolApiV1ToolsToolUuidPut, updateUserConfigurationsApiV1UserConfigurationsUserPut, updateWorkflowApiV1WorkflowWorkflowIdPut, updateWorkflowStatusApiV1WorkflowWorkflowIdStatusPut, validateUserConfigurationsApiV1UserConfigurationsUserValidateGet, validateWorkflowApiV1WorkflowWorkflowIdValidatePost } from './sdk.gen'; -export type { AccessTokenResponse, AmbientNoiseUploadRequest, AmbientNoiseUploadResponse, ApiKeyResponse, ApiKeyStatus, ApiKeyStatusResponse, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteError, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteErrors, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponse, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponses, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteError, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteErrors, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteResponses, AriConfigurationRequest, AriConfigurationResponse, AuthResponse, AuthUserResponse, BatchRecordingCreateRequestSchema, BatchRecordingCreateResponseSchema, BatchRecordingUploadRequestSchema, BatchRecordingUploadResponseSchema, BodyTranscribeAudioApiV1WorkflowRecordingsTranscribePost, CalculatorToolDefinition, CallDispositionCodes, CallType, CampaignDefaultsResponse, CampaignProgressResponse, CampaignResponse, CampaignRunsResponse, CampaignSourceDownloadResponse, CampaignsResponse, ChunkResponseSchema, ChunkSearchRequestSchema, ChunkSearchResponseSchema, CircuitBreakerConfigRequest, CircuitBreakerConfigResponse, ClientOptions, CloudonixConfigurationRequest, CloudonixConfigurationResponse, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostData, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostError, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostErrors, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostResponses, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostError, CreateApiKeyApiV1UserApiKeysPostErrors, CreateApiKeyApiV1UserApiKeysPostResponse, CreateApiKeyApiV1UserApiKeysPostResponses, CreateApiKeyRequest, CreateApiKeyResponse, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostError, CreateCampaignApiV1CampaignCreatePostErrors, CreateCampaignApiV1CampaignCreatePostResponse, CreateCampaignApiV1CampaignCreatePostResponses, CreateCampaignRequest, CreateCredentialApiV1CredentialsPostData, CreateCredentialApiV1CredentialsPostError, CreateCredentialApiV1CredentialsPostErrors, CreateCredentialApiV1CredentialsPostResponse, CreateCredentialApiV1CredentialsPostResponses, CreateCredentialRequest, CreatedByResponse, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostError, CreateLoadTestApiV1LooptalkLoadTestsPostErrors, CreateLoadTestApiV1LooptalkLoadTestsPostResponse, CreateLoadTestApiV1LooptalkLoadTestsPostResponses, CreateLoadTestRequest, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostError, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostErrors, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponse, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponses, CreateRecordingsApiV1WorkflowRecordingsPostData, CreateRecordingsApiV1WorkflowRecordingsPostError, CreateRecordingsApiV1WorkflowRecordingsPostErrors, CreateRecordingsApiV1WorkflowRecordingsPostResponse, CreateRecordingsApiV1WorkflowRecordingsPostResponses, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostError, CreateServiceKeyApiV1UserServiceKeysPostErrors, CreateServiceKeyApiV1UserServiceKeysPostResponse, CreateServiceKeyApiV1UserServiceKeysPostResponses, CreateServiceKeyRequest, CreateServiceKeyResponse, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostError, CreateSessionApiV1IntegrationSessionPostErrors, CreateSessionApiV1IntegrationSessionPostResponse, CreateSessionApiV1IntegrationSessionPostResponses, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostError, CreateTestSessionApiV1LooptalkTestSessionsPostErrors, CreateTestSessionApiV1LooptalkTestSessionsPostResponse, CreateTestSessionApiV1LooptalkTestSessionsPostResponses, CreateTestSessionRequest, CreateToolApiV1ToolsPostData, CreateToolApiV1ToolsPostError, CreateToolApiV1ToolsPostErrors, CreateToolApiV1ToolsPostResponse, CreateToolApiV1ToolsPostResponses, CreateToolRequest, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostError, CreateWorkflowApiV1WorkflowCreateDefinitionPostErrors, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponse, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponses, CreateWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPostData, CreateWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPostError, CreateWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPostErrors, CreateWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPostResponse, CreateWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPostResponses, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostError, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostErrors, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponse, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponses, CreateWorkflowRequest, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostError, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostErrors, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponse, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponses, CreateWorkflowRunRequest, CreateWorkflowRunResponse, CreateWorkflowTemplateRequest, CredentialResponse, CurrentUsageResponse, DailyReportResponse, DailyUsageBreakdownResponse, DailyUsageItem, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteError, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteErrors, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponse, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponses, DefaultConfigurationsResponse, DeleteCredentialApiV1CredentialsCredentialUuidDeleteData, DeleteCredentialApiV1CredentialsCredentialUuidDeleteError, DeleteCredentialApiV1CredentialsCredentialUuidDeleteErrors, DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponse, DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponses, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteData, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteError, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteErrors, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteResponses, DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteData, DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteError, DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteErrors, DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteResponses, DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteData, DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteError, DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteErrors, DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteResponses, DeleteToolApiV1ToolsToolUuidDeleteData, DeleteToolApiV1ToolsToolUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteErrors, DeleteToolApiV1ToolsToolUuidDeleteResponse, DeleteToolApiV1ToolsToolUuidDeleteResponses, DocumentListResponseSchema, DocumentResponseSchema, DocumentUploadRequestSchema, DocumentUploadResponseSchema, DownloadCampaignReportApiV1CampaignCampaignIdReportGetData, DownloadCampaignReportApiV1CampaignCampaignIdReportGetError, DownloadCampaignReportApiV1CampaignCampaignIdReportGetErrors, DownloadCampaignReportApiV1CampaignCampaignIdReportGetResponses, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetData, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetError, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetErrors, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetResponses, DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetData, DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetError, DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetErrors, DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetResponses, DuplicateTemplateRequest, DuplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePostData, DuplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePostError, DuplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePostErrors, DuplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePostResponse, DuplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePostResponses, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostErrors, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponses, EmbedConfigResponse, EmbedTokenRequest, EmbedTokenResponse, EndCallConfig, EndCallToolDefinition, FileDescriptor, FileMetadataResponse, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetError, GetActiveTestsApiV1LooptalkActiveTestsGetErrors, GetActiveTestsApiV1LooptalkActiveTestsGetResponses, GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostData, GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostError, GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostErrors, GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostResponse, GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostResponses, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetError, GetApiKeysApiV1UserApiKeysGetErrors, GetApiKeysApiV1UserApiKeysGetResponse, GetApiKeysApiV1UserApiKeysGetResponses, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetError, GetAuthUserApiV1UserAuthUserGetErrors, GetAuthUserApiV1UserAuthUserGetResponse, GetAuthUserApiV1UserAuthUserGetResponses, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetError, GetCampaignApiV1CampaignCampaignIdGetErrors, GetCampaignApiV1CampaignCampaignIdGetResponse, GetCampaignApiV1CampaignCampaignIdGetResponses, GetCampaignDefaultsApiV1OrganizationsCampaignDefaultsGetData, GetCampaignDefaultsApiV1OrganizationsCampaignDefaultsGetError, GetCampaignDefaultsApiV1OrganizationsCampaignDefaultsGetErrors, GetCampaignDefaultsApiV1OrganizationsCampaignDefaultsGetResponse, GetCampaignDefaultsApiV1OrganizationsCampaignDefaultsGetResponses, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetError, GetCampaignProgressApiV1CampaignCampaignIdProgressGetErrors, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponses, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetError, GetCampaignRunsApiV1CampaignCampaignIdRunsGetErrors, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetError, GetCampaignsApiV1CampaignGetErrors, GetCampaignsApiV1CampaignGetResponse, GetCampaignsApiV1CampaignGetResponses, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetErrors, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponses, GetCredentialApiV1CredentialsCredentialUuidGetData, GetCredentialApiV1CredentialsCredentialUuidGetError, GetCredentialApiV1CredentialsCredentialUuidGetErrors, GetCredentialApiV1CredentialsCredentialUuidGetResponse, GetCredentialApiV1CredentialsCredentialUuidGetResponses, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetError, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetErrors, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponses, GetCurrentUserApiV1AuthMeGetData, GetCurrentUserApiV1AuthMeGetError, GetCurrentUserApiV1AuthMeGetErrors, GetCurrentUserApiV1AuthMeGetResponse, GetCurrentUserApiV1AuthMeGetResponses, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetError, GetDailyReportApiV1OrganizationsReportsDailyGetErrors, GetDailyReportApiV1OrganizationsReportsDailyGetResponse, GetDailyReportApiV1OrganizationsReportsDailyGetResponses, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetError, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetErrors, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponses, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetError, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetErrors, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponse, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponses, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetErrors, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponse, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponses, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetData, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetError, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetErrors, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponse, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponses, GetEmbedConfigApiV1PublicEmbedConfigTokenGetData, GetEmbedConfigApiV1PublicEmbedConfigTokenGetError, GetEmbedConfigApiV1PublicEmbedConfigTokenGetErrors, GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponse, GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetError, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetErrors, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponse, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponses, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetError, GetFileMetadataApiV1S3FileMetadataGetErrors, GetFileMetadataApiV1S3FileMetadataGetResponse, GetFileMetadataApiV1S3FileMetadataGetResponses, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetError, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetErrors, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponses, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetError, GetIntegrationsApiV1IntegrationGetErrors, GetIntegrationsApiV1IntegrationGetResponse, GetIntegrationsApiV1IntegrationGetResponses, GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetData, GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetError, GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetErrors, GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetResponse, GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetResponses, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetError, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetErrors, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponse, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponses, GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetData, GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetError, GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetErrors, GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponse, GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostErrors, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponses, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetData, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetError, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetErrors, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetResponse, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetResponses, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetError, GetServiceKeysApiV1UserServiceKeysGetErrors, GetServiceKeysApiV1UserServiceKeysGetResponse, GetServiceKeysApiV1UserServiceKeysGetResponses, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetError, GetSignedUrlApiV1S3SignedUrlGetErrors, GetSignedUrlApiV1S3SignedUrlGetResponse, GetSignedUrlApiV1S3SignedUrlGetResponses, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetErrors, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponses, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetError, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetErrors, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponse, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponses, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetError, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetErrors, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetResponses, GetToolApiV1ToolsToolUuidGetData, GetToolApiV1ToolsToolUuidGetError, GetToolApiV1ToolsToolUuidGetErrors, GetToolApiV1ToolsToolUuidGetResponse, GetToolApiV1ToolsToolUuidGetResponses, GetTurnCredentialsApiV1TurnCredentialsGetData, GetTurnCredentialsApiV1TurnCredentialsGetError, GetTurnCredentialsApiV1TurnCredentialsGetErrors, GetTurnCredentialsApiV1TurnCredentialsGetResponse, GetTurnCredentialsApiV1TurnCredentialsGetResponses, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostData, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostError, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostErrors, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponse, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponses, GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostData, GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostError, GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostErrors, GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostResponse, GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostResponses, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetErrors, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponse, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponses, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetError, GetUserConfigurationsApiV1UserConfigurationsUserGetErrors, GetUserConfigurationsApiV1UserConfigurationsUserGetResponse, GetUserConfigurationsApiV1UserConfigurationsUserGetResponses, GetVoicesApiV1UserConfigurationsVoicesProviderGetData, GetVoicesApiV1UserConfigurationsVoicesProviderGetError, GetVoicesApiV1UserConfigurationsVoicesProviderGetErrors, GetVoicesApiV1UserConfigurationsVoicesProviderGetResponse, GetVoicesApiV1UserConfigurationsVoicesProviderGetResponses, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetError, GetWorkflowApiV1WorkflowFetchWorkflowIdGetErrors, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponse, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponses, GetWorkflowCountApiV1WorkflowCountGetData, GetWorkflowCountApiV1WorkflowCountGetError, GetWorkflowCountApiV1WorkflowCountGetErrors, GetWorkflowCountApiV1WorkflowCountGetResponse, GetWorkflowCountApiV1WorkflowCountGetResponses, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetError, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetErrors, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponse, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponses, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetError, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetErrors, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponse, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponses, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetError, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetErrors, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponse, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponses, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetError, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetErrors, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponse, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponses, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetError, GetWorkflowsApiV1WorkflowFetchGetErrors, GetWorkflowsApiV1WorkflowFetchGetResponse, GetWorkflowsApiV1WorkflowFetchGetResponses, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetError, GetWorkflowsSummaryApiV1WorkflowSummaryGetErrors, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponse, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponses, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetErrors, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponse, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponses, GetWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGetData, GetWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGetError, GetWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGetErrors, GetWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGetResponse, GetWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGetResponses, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostData, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostErrors, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostResponses, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostError, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostErrors, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostResponses, HandleInboundFallbackApiV1TelephonyInboundFallbackPostData, HandleInboundFallbackApiV1TelephonyInboundFallbackPostErrors, HandleInboundFallbackApiV1TelephonyInboundFallbackPostResponses, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostError, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostErrors, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostResponses, HandleTelnyxEventsApiV1TelephonyTelnyxEventsWorkflowRunIdPostData, HandleTelnyxEventsApiV1TelephonyTelnyxEventsWorkflowRunIdPostError, HandleTelnyxEventsApiV1TelephonyTelnyxEventsWorkflowRunIdPostErrors, HandleTelnyxEventsApiV1TelephonyTelnyxEventsWorkflowRunIdPostResponses, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostError, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostErrors, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostResponses, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostData, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostErrors, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostResponses, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostError, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostErrors, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostResponses, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostData, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostError, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostErrors, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostResponses, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostData, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostError, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostErrors, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostResponses, HealthApiV1HealthGetData, HealthApiV1HealthGetErrors, HealthApiV1HealthGetResponse, HealthApiV1HealthGetResponses, HealthResponse, HttpApiConfig, HttpApiToolDefinition, HttpValidationError, ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostError, ImpersonateApiV1SuperuserImpersonatePostErrors, ImpersonateApiV1SuperuserImpersonatePostResponse, ImpersonateApiV1SuperuserImpersonatePostResponses, ImpersonateRequest, ImpersonateResponse, InitEmbedRequest, InitEmbedResponse, InitializeEmbedSessionApiV1PublicEmbedInitPostData, InitializeEmbedSessionApiV1PublicEmbedInitPostError, InitializeEmbedSessionApiV1PublicEmbedInitPostErrors, InitializeEmbedSessionApiV1PublicEmbedInitPostResponse, InitializeEmbedSessionApiV1PublicEmbedInitPostResponses, InitiateCallApiV1PublicAgentUuidPostData, InitiateCallApiV1PublicAgentUuidPostError, InitiateCallApiV1PublicAgentUuidPostErrors, InitiateCallApiV1PublicAgentUuidPostResponse, InitiateCallApiV1PublicAgentUuidPostResponses, InitiateCallApiV1TelephonyInitiateCallPostData, InitiateCallApiV1TelephonyInitiateCallPostError, InitiateCallApiV1TelephonyInitiateCallPostErrors, InitiateCallApiV1TelephonyInitiateCallPostResponses, InitiateCallRequest, InitiateCallTransferApiV1TelephonyCallTransferPostData, InitiateCallTransferApiV1TelephonyCallTransferPostError, InitiateCallTransferApiV1TelephonyCallTransferPostErrors, InitiateCallTransferApiV1TelephonyCallTransferPostResponses, IntegrationResponse, ItemKind, LangfuseCredentialsRequest, LangfuseCredentialsResponse, LastCampaignSettingsResponse, ListCredentialsApiV1CredentialsGetData, ListCredentialsApiV1CredentialsGetError, ListCredentialsApiV1CredentialsGetErrors, ListCredentialsApiV1CredentialsGetResponse, ListCredentialsApiV1CredentialsGetResponses, ListDocumentsApiV1KnowledgeBaseDocumentsGetData, ListDocumentsApiV1KnowledgeBaseDocumentsGetError, ListDocumentsApiV1KnowledgeBaseDocumentsGetErrors, ListDocumentsApiV1KnowledgeBaseDocumentsGetResponse, ListDocumentsApiV1KnowledgeBaseDocumentsGetResponses, ListRecordingsApiV1WorkflowRecordingsGetData, ListRecordingsApiV1WorkflowRecordingsGetError, ListRecordingsApiV1WorkflowRecordingsGetErrors, ListRecordingsApiV1WorkflowRecordingsGetResponse, ListRecordingsApiV1WorkflowRecordingsGetResponses, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetError, ListTestSessionsApiV1LooptalkTestSessionsGetErrors, ListTestSessionsApiV1LooptalkTestSessionsGetResponse, ListTestSessionsApiV1LooptalkTestSessionsGetResponses, ListToolsApiV1ToolsGetData, ListToolsApiV1ToolsGetError, ListToolsApiV1ToolsGetErrors, ListToolsApiV1ToolsGetResponse, ListToolsApiV1ToolsGetResponses, LoadTestStatsResponse, LoginApiV1AuthLoginPostData, LoginApiV1AuthLoginPostError, LoginApiV1AuthLoginPostErrors, LoginApiV1AuthLoginPostResponse, LoginApiV1AuthLoginPostResponses, LoginRequest, MpsCreditsResponse, OptionsConfigApiV1PublicEmbedConfigTokenOptionsData, OptionsConfigApiV1PublicEmbedConfigTokenOptionsError, OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors, OptionsConfigApiV1PublicEmbedConfigTokenOptionsResponses, OptionsInitApiV1PublicEmbedInitOptionsData, OptionsInitApiV1PublicEmbedInitOptionsErrors, OptionsInitApiV1PublicEmbedInitOptionsResponses, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsData, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsError, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsErrors, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsResponses, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostError, PauseCampaignApiV1CampaignCampaignIdPausePostErrors, PauseCampaignApiV1CampaignCampaignIdPausePostResponse, PauseCampaignApiV1CampaignCampaignIdPausePostResponses, PresignedUploadUrlRequest, PresignedUploadUrlResponse, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostData, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostError, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostErrors, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponse, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponses, ProcessDocumentRequestSchema, PublishWorkflowApiV1WorkflowWorkflowIdPublishPostData, PublishWorkflowApiV1WorkflowWorkflowIdPublishPostError, PublishWorkflowApiV1WorkflowWorkflowIdPublishPostErrors, PublishWorkflowApiV1WorkflowWorkflowIdPublishPostResponses, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutError, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutErrors, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponse, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponses, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutError, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutErrors, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutResponses, RecordingCreateRequestSchema, RecordingListResponseSchema, RecordingResponseSchema, RecordingUpdateRequestSchema, RecordingUploadResponseSchema, RedialCampaignApiV1CampaignCampaignIdRedialPostData, RedialCampaignApiV1CampaignCampaignIdRedialPostError, RedialCampaignApiV1CampaignCampaignIdRedialPostErrors, RedialCampaignApiV1CampaignCampaignIdRedialPostResponse, RedialCampaignApiV1CampaignCampaignIdRedialPostResponses, RedialCampaignRequest, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostError, ResumeCampaignApiV1CampaignCampaignIdResumePostErrors, ResumeCampaignApiV1CampaignCampaignIdResumePostResponse, ResumeCampaignApiV1CampaignCampaignIdResumePostResponses, RetryConfigRequest, RetryConfigResponse, S3SignedUrlResponse, SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostData, SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostError, SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostErrors, SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostResponses, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostErrors, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostResponses, ScheduleConfigRequest, ScheduleConfigResponse, SearchChunksApiV1KnowledgeBaseSearchPostData, SearchChunksApiV1KnowledgeBaseSearchPostError, SearchChunksApiV1KnowledgeBaseSearchPostErrors, SearchChunksApiV1KnowledgeBaseSearchPostResponse, SearchChunksApiV1KnowledgeBaseSearchPostResponses, ServiceKeyResponse, SessionResponse, SignupApiV1AuthSignupPostData, SignupApiV1AuthSignupPostError, SignupApiV1AuthSignupPostErrors, SignupApiV1AuthSignupPostResponse, SignupApiV1AuthSignupPostResponses, SignupRequest, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostError, StartCampaignApiV1CampaignCampaignIdStartPostErrors, StartCampaignApiV1CampaignCampaignIdStartPostResponse, StartCampaignApiV1CampaignCampaignIdStartPostResponses, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostError, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostErrors, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostResponses, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostError, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostErrors, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostResponses, SuperuserWorkflowRunResponse, SuperuserWorkflowRunsListResponse, TelephonyConfigurationResponse, TelnyxConfigurationRequest, TelnyxConfigurationResponse, TestSessionResponse, TimeSlotRequest, TimeSlotResponse, ToolParameter, ToolResponse, TranscribeAudioApiV1WorkflowRecordingsTranscribePostData, TranscribeAudioApiV1WorkflowRecordingsTranscribePostError, TranscribeAudioApiV1WorkflowRecordingsTranscribePostErrors, TranscribeAudioApiV1WorkflowRecordingsTranscribePostResponses, TransferCallConfig, TransferCallRequest, TransferCallToolDefinition, TriggerCallRequest, TriggerCallResponse, TurnCredentialsResponse, TwilioConfigurationRequest, TwilioConfigurationResponse, UnarchiveToolApiV1ToolsToolUuidUnarchivePostData, UnarchiveToolApiV1ToolsToolUuidUnarchivePostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostErrors, UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse, UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses, UpdateCampaignApiV1CampaignCampaignIdPatchData, UpdateCampaignApiV1CampaignCampaignIdPatchError, UpdateCampaignApiV1CampaignCampaignIdPatchErrors, UpdateCampaignApiV1CampaignCampaignIdPatchResponse, UpdateCampaignApiV1CampaignCampaignIdPatchResponses, UpdateCampaignRequest, UpdateCredentialApiV1CredentialsCredentialUuidPutData, UpdateCredentialApiV1CredentialsCredentialUuidPutError, UpdateCredentialApiV1CredentialsCredentialUuidPutErrors, UpdateCredentialApiV1CredentialsCredentialUuidPutResponse, UpdateCredentialApiV1CredentialsCredentialUuidPutResponses, UpdateCredentialRequest, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutError, UpdateIntegrationApiV1IntegrationIntegrationIdPutErrors, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponse, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponses, UpdateIntegrationRequest, UpdateRecordingApiV1WorkflowRecordingsIdPatchData, UpdateRecordingApiV1WorkflowRecordingsIdPatchError, UpdateRecordingApiV1WorkflowRecordingsIdPatchErrors, UpdateRecordingApiV1WorkflowRecordingsIdPatchResponse, UpdateRecordingApiV1WorkflowRecordingsIdPatchResponses, UpdateToolApiV1ToolsToolUuidPutData, UpdateToolApiV1ToolsToolUuidPutError, UpdateToolApiV1ToolsToolUuidPutErrors, UpdateToolApiV1ToolsToolUuidPutResponse, UpdateToolApiV1ToolsToolUuidPutResponses, UpdateToolRequest, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutError, UpdateUserConfigurationsApiV1UserConfigurationsUserPutErrors, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponses, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutError, UpdateWorkflowApiV1WorkflowWorkflowIdPutErrors, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponse, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponses, UpdateWorkflowRequest, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutError, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutErrors, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponses, UpdateWorkflowStatusRequest, UsageHistoryResponse, UserConfigurationRequestResponseSchema, UserResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetError, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetErrors, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponses, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostError, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostErrors, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponse, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponses, ValidateWorkflowResponse, ValidationError, VobizConfigurationRequest, VobizConfigurationResponse, VoiceInfo, VoicesResponse, VonageConfigurationRequest, VonageConfigurationResponse, WebhookCredentialType, WorkflowCountResponse, WorkflowError, WorkflowListResponse, WorkflowOption, WorkflowResponse, WorkflowRunDetail, WorkflowRunResponseSchema, WorkflowRunsResponse, WorkflowRunUsageResponse, WorkflowSummaryResponse, WorkflowTemplateResponse, WorkflowVersionResponse } from './types.gen'; +export { archiveApiKeyApiV1UserApiKeysApiKeyIdDelete, archiveServiceKeyApiV1UserServiceKeysServiceKeyIdDelete, completeTransferFunctionCallApiV1TelephonyTransferResultTransferIdPost, createApiKeyApiV1UserApiKeysPost, createCampaignApiV1CampaignCreatePost, createCredentialApiV1CredentialsPost, createLoadTestApiV1LooptalkLoadTestsPost, createOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPost, createRecordingsApiV1WorkflowRecordingsPost, createServiceKeyApiV1UserServiceKeysPost, createSessionApiV1IntegrationSessionPost, createTestSessionApiV1LooptalkTestSessionsPost, createToolApiV1ToolsPost, createWorkflowApiV1WorkflowCreateDefinitionPost, createWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPost, createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost, createWorkflowRunApiV1WorkflowWorkflowIdRunsPost, deactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDelete, deleteCredentialApiV1CredentialsCredentialUuidDelete, deleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDelete, deleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDelete, deleteRecordingApiV1WorkflowRecordingsRecordingIdDelete, deleteToolApiV1ToolsToolUuidDelete, downloadCampaignReportApiV1CampaignCampaignIdReportGet, downloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGet, downloadWorkflowReportApiV1WorkflowWorkflowIdReportGet, duplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePost, duplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePost, getActiveTestsApiV1LooptalkActiveTestsGet, getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost, getApiKeysApiV1UserApiKeysGet, getAuthUserApiV1UserAuthUserGet, getCampaignApiV1CampaignCampaignIdGet, getCampaignDefaultsApiV1OrganizationsCampaignDefaultsGet, getCampaignProgressApiV1CampaignCampaignIdProgressGet, getCampaignRunsApiV1CampaignCampaignIdRunsGet, getCampaignsApiV1CampaignGet, getCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGet, getCredentialApiV1CredentialsCredentialUuidGet, getCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGet, getCurrentUserApiV1AuthMeGet, getDailyReportApiV1OrganizationsReportsDailyGet, getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet, getDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGet, getEmbedConfigApiV1PublicEmbedConfigTokenGet, getEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGet, getFileMetadataApiV1S3FileMetadataGet, getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet, getIntegrationsApiV1IntegrationGet, getLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGet, getLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getNodeTypeApiV1NodeTypesNameGet, getPresignedUploadUrlApiV1S3PresignedUploadUrlPost, getPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGet, getServiceKeysApiV1UserServiceKeysGet, getSignedUrlApiV1S3SignedUrlGet, getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet, getTestSessionApiV1LooptalkTestSessionsTestSessionIdGet, getTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGet, getToolApiV1ToolsToolUuidGet, getTurnCredentialsApiV1TurnCredentialsGet, getUploadUrlApiV1KnowledgeBaseUploadUrlPost, getUploadUrlsApiV1WorkflowRecordingsUploadUrlPost, getUsageHistoryApiV1OrganizationsUsageRunsGet, getUserConfigurationsApiV1UserConfigurationsUserGet, getVoicesApiV1UserConfigurationsVoicesProviderGet, getWorkflowApiV1WorkflowFetchWorkflowIdGet, getWorkflowCountApiV1WorkflowCountGet, getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet, getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet, getWorkflowRunsApiV1SuperuserWorkflowRunsGet, getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet, getWorkflowsApiV1WorkflowFetchGet, getWorkflowsSummaryApiV1WorkflowSummaryGet, getWorkflowTemplatesApiV1WorkflowTemplatesGet, getWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGet, handleCloudonixCdrApiV1TelephonyCloudonixCdrPost, handleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPost, handleInboundFallbackApiV1TelephonyInboundFallbackPost, handleInboundTelephonyApiV1TelephonyInboundWorkflowIdPost, handleTelnyxEventsApiV1TelephonyTelnyxEventsWorkflowRunIdPost, handleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPost, handleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPost, handleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPost, handleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPost, handleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPost, healthApiV1HealthGet, impersonateApiV1SuperuserImpersonatePost, initializeEmbedSessionApiV1PublicEmbedInitPost, initiateCallApiV1PublicAgentUuidPost, initiateCallApiV1TelephonyInitiateCallPost, initiateCallTransferApiV1TelephonyCallTransferPost, listCredentialsApiV1CredentialsGet, listDocumentsApiV1KnowledgeBaseDocumentsGet, listNodeTypesApiV1NodeTypesGet, listRecordingsApiV1WorkflowRecordingsGet, listTestSessionsApiV1LooptalkTestSessionsGet, listToolsApiV1ToolsGet, loginApiV1AuthLoginPost, type Options, optionsConfigApiV1PublicEmbedConfigTokenOptions, optionsInitApiV1PublicEmbedInitOptions, optionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptions, pauseCampaignApiV1CampaignCampaignIdPausePost, processDocumentApiV1KnowledgeBaseProcessDocumentPost, publishWorkflowApiV1WorkflowWorkflowIdPublishPost, reactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePut, reactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePut, redialCampaignApiV1CampaignCampaignIdRedialPost, resumeCampaignApiV1CampaignCampaignIdResumePost, saveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPost, saveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPost, searchChunksApiV1KnowledgeBaseSearchPost, signupApiV1AuthSignupPost, startCampaignApiV1CampaignCampaignIdStartPost, startTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPost, stopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPost, transcribeAudioApiV1WorkflowRecordingsTranscribePost, unarchiveToolApiV1ToolsToolUuidUnarchivePost, updateCampaignApiV1CampaignCampaignIdPatch, updateCredentialApiV1CredentialsCredentialUuidPut, updateIntegrationApiV1IntegrationIntegrationIdPut, updateRecordingApiV1WorkflowRecordingsIdPatch, updateToolApiV1ToolsToolUuidPut, updateUserConfigurationsApiV1UserConfigurationsUserPut, updateWorkflowApiV1WorkflowWorkflowIdPut, updateWorkflowStatusApiV1WorkflowWorkflowIdStatusPut, validateUserConfigurationsApiV1UserConfigurationsUserValidateGet, validateWorkflowApiV1WorkflowWorkflowIdValidatePost } from './sdk.gen'; +export type { AccessTokenResponse, AmbientNoiseUploadRequest, AmbientNoiseUploadResponse, ApiKeyResponse, ApiKeyStatus, ApiKeyStatusResponse, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteError, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteErrors, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponse, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponses, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteError, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteErrors, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteResponses, AriConfigurationRequest, AriConfigurationResponse, AuthResponse, AuthUserResponse, BatchRecordingCreateRequestSchema, BatchRecordingCreateResponseSchema, BatchRecordingUploadRequestSchema, BatchRecordingUploadResponseSchema, BodyTranscribeAudioApiV1WorkflowRecordingsTranscribePost, CalculatorToolDefinition, CallDispositionCodes, CallType, CampaignDefaultsResponse, CampaignProgressResponse, CampaignResponse, CampaignRunsResponse, CampaignSourceDownloadResponse, CampaignsResponse, ChunkResponseSchema, ChunkSearchRequestSchema, ChunkSearchResponseSchema, CircuitBreakerConfigRequest, CircuitBreakerConfigResponse, ClientOptions, CloudonixConfigurationRequest, CloudonixConfigurationResponse, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostData, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostError, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostErrors, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostResponses, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostError, CreateApiKeyApiV1UserApiKeysPostErrors, CreateApiKeyApiV1UserApiKeysPostResponse, CreateApiKeyApiV1UserApiKeysPostResponses, CreateApiKeyRequest, CreateApiKeyResponse, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostError, CreateCampaignApiV1CampaignCreatePostErrors, CreateCampaignApiV1CampaignCreatePostResponse, CreateCampaignApiV1CampaignCreatePostResponses, CreateCampaignRequest, CreateCredentialApiV1CredentialsPostData, CreateCredentialApiV1CredentialsPostError, CreateCredentialApiV1CredentialsPostErrors, CreateCredentialApiV1CredentialsPostResponse, CreateCredentialApiV1CredentialsPostResponses, CreateCredentialRequest, CreatedByResponse, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostError, CreateLoadTestApiV1LooptalkLoadTestsPostErrors, CreateLoadTestApiV1LooptalkLoadTestsPostResponse, CreateLoadTestApiV1LooptalkLoadTestsPostResponses, CreateLoadTestRequest, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostError, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostErrors, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponse, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponses, CreateRecordingsApiV1WorkflowRecordingsPostData, CreateRecordingsApiV1WorkflowRecordingsPostError, CreateRecordingsApiV1WorkflowRecordingsPostErrors, CreateRecordingsApiV1WorkflowRecordingsPostResponse, CreateRecordingsApiV1WorkflowRecordingsPostResponses, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostError, CreateServiceKeyApiV1UserServiceKeysPostErrors, CreateServiceKeyApiV1UserServiceKeysPostResponse, CreateServiceKeyApiV1UserServiceKeysPostResponses, CreateServiceKeyRequest, CreateServiceKeyResponse, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostError, CreateSessionApiV1IntegrationSessionPostErrors, CreateSessionApiV1IntegrationSessionPostResponse, CreateSessionApiV1IntegrationSessionPostResponses, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostError, CreateTestSessionApiV1LooptalkTestSessionsPostErrors, CreateTestSessionApiV1LooptalkTestSessionsPostResponse, CreateTestSessionApiV1LooptalkTestSessionsPostResponses, CreateTestSessionRequest, CreateToolApiV1ToolsPostData, CreateToolApiV1ToolsPostError, CreateToolApiV1ToolsPostErrors, CreateToolApiV1ToolsPostResponse, CreateToolApiV1ToolsPostResponses, CreateToolRequest, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostError, CreateWorkflowApiV1WorkflowCreateDefinitionPostErrors, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponse, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponses, CreateWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPostData, CreateWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPostError, CreateWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPostErrors, CreateWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPostResponse, CreateWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPostResponses, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostError, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostErrors, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponse, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponses, CreateWorkflowRequest, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostError, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostErrors, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponse, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponses, CreateWorkflowRunRequest, CreateWorkflowRunResponse, CreateWorkflowTemplateRequest, CredentialResponse, CurrentUsageResponse, DailyReportResponse, DailyUsageBreakdownResponse, DailyUsageItem, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteError, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteErrors, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponse, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponses, DefaultConfigurationsResponse, DeleteCredentialApiV1CredentialsCredentialUuidDeleteData, DeleteCredentialApiV1CredentialsCredentialUuidDeleteError, DeleteCredentialApiV1CredentialsCredentialUuidDeleteErrors, DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponse, DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponses, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteData, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteError, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteErrors, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteResponses, DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteData, DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteError, DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteErrors, DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteResponses, DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteData, DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteError, DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteErrors, DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteResponses, DeleteToolApiV1ToolsToolUuidDeleteData, DeleteToolApiV1ToolsToolUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteErrors, DeleteToolApiV1ToolsToolUuidDeleteResponse, DeleteToolApiV1ToolsToolUuidDeleteResponses, DisplayOptions, DocumentListResponseSchema, DocumentResponseSchema, DocumentUploadRequestSchema, DocumentUploadResponseSchema, DownloadCampaignReportApiV1CampaignCampaignIdReportGetData, DownloadCampaignReportApiV1CampaignCampaignIdReportGetError, DownloadCampaignReportApiV1CampaignCampaignIdReportGetErrors, DownloadCampaignReportApiV1CampaignCampaignIdReportGetResponses, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetData, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetError, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetErrors, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetResponses, DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetData, DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetError, DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetErrors, DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetResponses, DuplicateTemplateRequest, DuplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePostData, DuplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePostError, DuplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePostErrors, DuplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePostResponse, DuplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePostResponses, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostErrors, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponses, EmbedConfigResponse, EmbedTokenRequest, EmbedTokenResponse, EndCallConfig, EndCallToolDefinition, FileDescriptor, FileMetadataResponse, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetError, GetActiveTestsApiV1LooptalkActiveTestsGetErrors, GetActiveTestsApiV1LooptalkActiveTestsGetResponses, GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostData, GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostError, GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostErrors, GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostResponse, GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostResponses, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetError, GetApiKeysApiV1UserApiKeysGetErrors, GetApiKeysApiV1UserApiKeysGetResponse, GetApiKeysApiV1UserApiKeysGetResponses, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetError, GetAuthUserApiV1UserAuthUserGetErrors, GetAuthUserApiV1UserAuthUserGetResponse, GetAuthUserApiV1UserAuthUserGetResponses, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetError, GetCampaignApiV1CampaignCampaignIdGetErrors, GetCampaignApiV1CampaignCampaignIdGetResponse, GetCampaignApiV1CampaignCampaignIdGetResponses, GetCampaignDefaultsApiV1OrganizationsCampaignDefaultsGetData, GetCampaignDefaultsApiV1OrganizationsCampaignDefaultsGetError, GetCampaignDefaultsApiV1OrganizationsCampaignDefaultsGetErrors, GetCampaignDefaultsApiV1OrganizationsCampaignDefaultsGetResponse, GetCampaignDefaultsApiV1OrganizationsCampaignDefaultsGetResponses, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetError, GetCampaignProgressApiV1CampaignCampaignIdProgressGetErrors, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponses, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetError, GetCampaignRunsApiV1CampaignCampaignIdRunsGetErrors, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetError, GetCampaignsApiV1CampaignGetErrors, GetCampaignsApiV1CampaignGetResponse, GetCampaignsApiV1CampaignGetResponses, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetErrors, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponses, GetCredentialApiV1CredentialsCredentialUuidGetData, GetCredentialApiV1CredentialsCredentialUuidGetError, GetCredentialApiV1CredentialsCredentialUuidGetErrors, GetCredentialApiV1CredentialsCredentialUuidGetResponse, GetCredentialApiV1CredentialsCredentialUuidGetResponses, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetError, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetErrors, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponses, GetCurrentUserApiV1AuthMeGetData, GetCurrentUserApiV1AuthMeGetError, GetCurrentUserApiV1AuthMeGetErrors, GetCurrentUserApiV1AuthMeGetResponse, GetCurrentUserApiV1AuthMeGetResponses, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetError, GetDailyReportApiV1OrganizationsReportsDailyGetErrors, GetDailyReportApiV1OrganizationsReportsDailyGetResponse, GetDailyReportApiV1OrganizationsReportsDailyGetResponses, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetError, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetErrors, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponses, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetError, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetErrors, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponse, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponses, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetErrors, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponse, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponses, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetData, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetError, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetErrors, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponse, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponses, GetEmbedConfigApiV1PublicEmbedConfigTokenGetData, GetEmbedConfigApiV1PublicEmbedConfigTokenGetError, GetEmbedConfigApiV1PublicEmbedConfigTokenGetErrors, GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponse, GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetError, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetErrors, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponse, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponses, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetError, GetFileMetadataApiV1S3FileMetadataGetErrors, GetFileMetadataApiV1S3FileMetadataGetResponse, GetFileMetadataApiV1S3FileMetadataGetResponses, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetError, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetErrors, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponses, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetError, GetIntegrationsApiV1IntegrationGetErrors, GetIntegrationsApiV1IntegrationGetResponse, GetIntegrationsApiV1IntegrationGetResponses, GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetData, GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetError, GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetErrors, GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetResponse, GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetResponses, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetError, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetErrors, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponse, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponses, GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetData, GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetError, GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetErrors, GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponse, GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses, GetNodeTypeApiV1NodeTypesNameGetData, GetNodeTypeApiV1NodeTypesNameGetError, GetNodeTypeApiV1NodeTypesNameGetErrors, GetNodeTypeApiV1NodeTypesNameGetResponse, GetNodeTypeApiV1NodeTypesNameGetResponses, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostErrors, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponses, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetData, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetError, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetErrors, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetResponse, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetResponses, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetError, GetServiceKeysApiV1UserServiceKeysGetErrors, GetServiceKeysApiV1UserServiceKeysGetResponse, GetServiceKeysApiV1UserServiceKeysGetResponses, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetError, GetSignedUrlApiV1S3SignedUrlGetErrors, GetSignedUrlApiV1S3SignedUrlGetResponse, GetSignedUrlApiV1S3SignedUrlGetResponses, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetErrors, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponses, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetError, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetErrors, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponse, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponses, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetError, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetErrors, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetResponses, GetToolApiV1ToolsToolUuidGetData, GetToolApiV1ToolsToolUuidGetError, GetToolApiV1ToolsToolUuidGetErrors, GetToolApiV1ToolsToolUuidGetResponse, GetToolApiV1ToolsToolUuidGetResponses, GetTurnCredentialsApiV1TurnCredentialsGetData, GetTurnCredentialsApiV1TurnCredentialsGetError, GetTurnCredentialsApiV1TurnCredentialsGetErrors, GetTurnCredentialsApiV1TurnCredentialsGetResponse, GetTurnCredentialsApiV1TurnCredentialsGetResponses, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostData, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostError, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostErrors, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponse, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponses, GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostData, GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostError, GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostErrors, GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostResponse, GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostResponses, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetErrors, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponse, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponses, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetError, GetUserConfigurationsApiV1UserConfigurationsUserGetErrors, GetUserConfigurationsApiV1UserConfigurationsUserGetResponse, GetUserConfigurationsApiV1UserConfigurationsUserGetResponses, GetVoicesApiV1UserConfigurationsVoicesProviderGetData, GetVoicesApiV1UserConfigurationsVoicesProviderGetError, GetVoicesApiV1UserConfigurationsVoicesProviderGetErrors, GetVoicesApiV1UserConfigurationsVoicesProviderGetResponse, GetVoicesApiV1UserConfigurationsVoicesProviderGetResponses, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetError, GetWorkflowApiV1WorkflowFetchWorkflowIdGetErrors, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponse, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponses, GetWorkflowCountApiV1WorkflowCountGetData, GetWorkflowCountApiV1WorkflowCountGetError, GetWorkflowCountApiV1WorkflowCountGetErrors, GetWorkflowCountApiV1WorkflowCountGetResponse, GetWorkflowCountApiV1WorkflowCountGetResponses, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetError, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetErrors, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponse, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponses, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetError, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetErrors, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponse, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponses, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetError, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetErrors, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponse, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponses, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetError, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetErrors, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponse, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponses, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetError, GetWorkflowsApiV1WorkflowFetchGetErrors, GetWorkflowsApiV1WorkflowFetchGetResponse, GetWorkflowsApiV1WorkflowFetchGetResponses, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetError, GetWorkflowsSummaryApiV1WorkflowSummaryGetErrors, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponse, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponses, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetErrors, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponse, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponses, GetWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGetData, GetWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGetError, GetWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGetErrors, GetWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGetResponse, GetWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGetResponses, GraphConstraints, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostData, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostErrors, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostResponses, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostError, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostErrors, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostResponses, HandleInboundFallbackApiV1TelephonyInboundFallbackPostData, HandleInboundFallbackApiV1TelephonyInboundFallbackPostErrors, HandleInboundFallbackApiV1TelephonyInboundFallbackPostResponses, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostError, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostErrors, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostResponses, HandleTelnyxEventsApiV1TelephonyTelnyxEventsWorkflowRunIdPostData, HandleTelnyxEventsApiV1TelephonyTelnyxEventsWorkflowRunIdPostError, HandleTelnyxEventsApiV1TelephonyTelnyxEventsWorkflowRunIdPostErrors, HandleTelnyxEventsApiV1TelephonyTelnyxEventsWorkflowRunIdPostResponses, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostError, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostErrors, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostResponses, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostData, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostErrors, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostResponses, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostError, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostErrors, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostResponses, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostData, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostError, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostErrors, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostResponses, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostData, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostError, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostErrors, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostResponses, HealthApiV1HealthGetData, HealthApiV1HealthGetErrors, HealthApiV1HealthGetResponse, HealthApiV1HealthGetResponses, HealthResponse, HttpApiConfig, HttpApiToolDefinition, HttpValidationError, ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostError, ImpersonateApiV1SuperuserImpersonatePostErrors, ImpersonateApiV1SuperuserImpersonatePostResponse, ImpersonateApiV1SuperuserImpersonatePostResponses, ImpersonateRequest, ImpersonateResponse, InitEmbedRequest, InitEmbedResponse, InitializeEmbedSessionApiV1PublicEmbedInitPostData, InitializeEmbedSessionApiV1PublicEmbedInitPostError, InitializeEmbedSessionApiV1PublicEmbedInitPostErrors, InitializeEmbedSessionApiV1PublicEmbedInitPostResponse, InitializeEmbedSessionApiV1PublicEmbedInitPostResponses, InitiateCallApiV1PublicAgentUuidPostData, InitiateCallApiV1PublicAgentUuidPostError, InitiateCallApiV1PublicAgentUuidPostErrors, InitiateCallApiV1PublicAgentUuidPostResponse, InitiateCallApiV1PublicAgentUuidPostResponses, InitiateCallApiV1TelephonyInitiateCallPostData, InitiateCallApiV1TelephonyInitiateCallPostError, InitiateCallApiV1TelephonyInitiateCallPostErrors, InitiateCallApiV1TelephonyInitiateCallPostResponses, InitiateCallRequest, InitiateCallTransferApiV1TelephonyCallTransferPostData, InitiateCallTransferApiV1TelephonyCallTransferPostError, InitiateCallTransferApiV1TelephonyCallTransferPostErrors, InitiateCallTransferApiV1TelephonyCallTransferPostResponses, IntegrationResponse, ItemKind, LangfuseCredentialsRequest, LangfuseCredentialsResponse, LastCampaignSettingsResponse, ListCredentialsApiV1CredentialsGetData, ListCredentialsApiV1CredentialsGetError, ListCredentialsApiV1CredentialsGetErrors, ListCredentialsApiV1CredentialsGetResponse, ListCredentialsApiV1CredentialsGetResponses, ListDocumentsApiV1KnowledgeBaseDocumentsGetData, ListDocumentsApiV1KnowledgeBaseDocumentsGetError, ListDocumentsApiV1KnowledgeBaseDocumentsGetErrors, ListDocumentsApiV1KnowledgeBaseDocumentsGetResponse, ListDocumentsApiV1KnowledgeBaseDocumentsGetResponses, ListNodeTypesApiV1NodeTypesGetData, ListNodeTypesApiV1NodeTypesGetError, ListNodeTypesApiV1NodeTypesGetErrors, ListNodeTypesApiV1NodeTypesGetResponse, ListNodeTypesApiV1NodeTypesGetResponses, ListRecordingsApiV1WorkflowRecordingsGetData, ListRecordingsApiV1WorkflowRecordingsGetError, ListRecordingsApiV1WorkflowRecordingsGetErrors, ListRecordingsApiV1WorkflowRecordingsGetResponse, ListRecordingsApiV1WorkflowRecordingsGetResponses, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetError, ListTestSessionsApiV1LooptalkTestSessionsGetErrors, ListTestSessionsApiV1LooptalkTestSessionsGetResponse, ListTestSessionsApiV1LooptalkTestSessionsGetResponses, ListToolsApiV1ToolsGetData, ListToolsApiV1ToolsGetError, ListToolsApiV1ToolsGetErrors, ListToolsApiV1ToolsGetResponse, ListToolsApiV1ToolsGetResponses, LoadTestStatsResponse, LoginApiV1AuthLoginPostData, LoginApiV1AuthLoginPostError, LoginApiV1AuthLoginPostErrors, LoginApiV1AuthLoginPostResponse, LoginApiV1AuthLoginPostResponses, LoginRequest, MigrationSpec, MpsCreditsResponse, NodeCategory, NodeExample, NodeSpec, NodeTypesResponse, OptionsConfigApiV1PublicEmbedConfigTokenOptionsData, OptionsConfigApiV1PublicEmbedConfigTokenOptionsError, OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors, OptionsConfigApiV1PublicEmbedConfigTokenOptionsResponses, OptionsInitApiV1PublicEmbedInitOptionsData, OptionsInitApiV1PublicEmbedInitOptionsErrors, OptionsInitApiV1PublicEmbedInitOptionsResponses, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsData, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsError, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsErrors, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsResponses, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostError, PauseCampaignApiV1CampaignCampaignIdPausePostErrors, PauseCampaignApiV1CampaignCampaignIdPausePostResponse, PauseCampaignApiV1CampaignCampaignIdPausePostResponses, PresignedUploadUrlRequest, PresignedUploadUrlResponse, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostData, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostError, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostErrors, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponse, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponses, ProcessDocumentRequestSchema, PropertyOption, PropertySpec, PropertyType, PublishWorkflowApiV1WorkflowWorkflowIdPublishPostData, PublishWorkflowApiV1WorkflowWorkflowIdPublishPostError, PublishWorkflowApiV1WorkflowWorkflowIdPublishPostErrors, PublishWorkflowApiV1WorkflowWorkflowIdPublishPostResponses, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutError, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutErrors, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponse, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponses, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutError, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutErrors, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutResponses, RecordingCreateRequestSchema, RecordingListResponseSchema, RecordingResponseSchema, RecordingUpdateRequestSchema, RecordingUploadResponseSchema, RedialCampaignApiV1CampaignCampaignIdRedialPostData, RedialCampaignApiV1CampaignCampaignIdRedialPostError, RedialCampaignApiV1CampaignCampaignIdRedialPostErrors, RedialCampaignApiV1CampaignCampaignIdRedialPostResponse, RedialCampaignApiV1CampaignCampaignIdRedialPostResponses, RedialCampaignRequest, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostError, ResumeCampaignApiV1CampaignCampaignIdResumePostErrors, ResumeCampaignApiV1CampaignCampaignIdResumePostResponse, ResumeCampaignApiV1CampaignCampaignIdResumePostResponses, RetryConfigRequest, RetryConfigResponse, S3SignedUrlResponse, SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostData, SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostError, SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostErrors, SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostResponses, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostErrors, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostResponses, ScheduleConfigRequest, ScheduleConfigResponse, SearchChunksApiV1KnowledgeBaseSearchPostData, SearchChunksApiV1KnowledgeBaseSearchPostError, SearchChunksApiV1KnowledgeBaseSearchPostErrors, SearchChunksApiV1KnowledgeBaseSearchPostResponse, SearchChunksApiV1KnowledgeBaseSearchPostResponses, ServiceKeyResponse, SessionResponse, SignupApiV1AuthSignupPostData, SignupApiV1AuthSignupPostError, SignupApiV1AuthSignupPostErrors, SignupApiV1AuthSignupPostResponse, SignupApiV1AuthSignupPostResponses, SignupRequest, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostError, StartCampaignApiV1CampaignCampaignIdStartPostErrors, StartCampaignApiV1CampaignCampaignIdStartPostResponse, StartCampaignApiV1CampaignCampaignIdStartPostResponses, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostError, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostErrors, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostResponses, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostError, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostErrors, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostResponses, SuperuserWorkflowRunResponse, SuperuserWorkflowRunsListResponse, TelephonyConfigurationResponse, TelnyxConfigurationRequest, TelnyxConfigurationResponse, TestSessionResponse, TimeSlotRequest, TimeSlotResponse, ToolParameter, ToolResponse, TranscribeAudioApiV1WorkflowRecordingsTranscribePostData, TranscribeAudioApiV1WorkflowRecordingsTranscribePostError, TranscribeAudioApiV1WorkflowRecordingsTranscribePostErrors, TranscribeAudioApiV1WorkflowRecordingsTranscribePostResponses, TransferCallConfig, TransferCallRequest, TransferCallToolDefinition, TriggerCallRequest, TriggerCallResponse, TurnCredentialsResponse, TwilioConfigurationRequest, TwilioConfigurationResponse, UnarchiveToolApiV1ToolsToolUuidUnarchivePostData, UnarchiveToolApiV1ToolsToolUuidUnarchivePostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostErrors, UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse, UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses, UpdateCampaignApiV1CampaignCampaignIdPatchData, UpdateCampaignApiV1CampaignCampaignIdPatchError, UpdateCampaignApiV1CampaignCampaignIdPatchErrors, UpdateCampaignApiV1CampaignCampaignIdPatchResponse, UpdateCampaignApiV1CampaignCampaignIdPatchResponses, UpdateCampaignRequest, UpdateCredentialApiV1CredentialsCredentialUuidPutData, UpdateCredentialApiV1CredentialsCredentialUuidPutError, UpdateCredentialApiV1CredentialsCredentialUuidPutErrors, UpdateCredentialApiV1CredentialsCredentialUuidPutResponse, UpdateCredentialApiV1CredentialsCredentialUuidPutResponses, UpdateCredentialRequest, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutError, UpdateIntegrationApiV1IntegrationIntegrationIdPutErrors, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponse, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponses, UpdateIntegrationRequest, UpdateRecordingApiV1WorkflowRecordingsIdPatchData, UpdateRecordingApiV1WorkflowRecordingsIdPatchError, UpdateRecordingApiV1WorkflowRecordingsIdPatchErrors, UpdateRecordingApiV1WorkflowRecordingsIdPatchResponse, UpdateRecordingApiV1WorkflowRecordingsIdPatchResponses, UpdateToolApiV1ToolsToolUuidPutData, UpdateToolApiV1ToolsToolUuidPutError, UpdateToolApiV1ToolsToolUuidPutErrors, UpdateToolApiV1ToolsToolUuidPutResponse, UpdateToolApiV1ToolsToolUuidPutResponses, UpdateToolRequest, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutError, UpdateUserConfigurationsApiV1UserConfigurationsUserPutErrors, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponses, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutError, UpdateWorkflowApiV1WorkflowWorkflowIdPutErrors, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponse, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponses, UpdateWorkflowRequest, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutError, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutErrors, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponses, UpdateWorkflowStatusRequest, UsageHistoryResponse, UserConfigurationRequestResponseSchema, UserResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetError, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetErrors, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponses, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostError, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostErrors, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponse, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponses, ValidateWorkflowResponse, ValidationError, VobizConfigurationRequest, VobizConfigurationResponse, VoiceInfo, VoicesResponse, VonageConfigurationRequest, VonageConfigurationResponse, WebhookCredentialType, WorkflowCountResponse, WorkflowError, WorkflowListResponse, WorkflowOption, WorkflowResponse, WorkflowRunDetail, WorkflowRunResponseSchema, WorkflowRunsResponse, WorkflowRunUsageResponse, WorkflowSummaryResponse, WorkflowTemplateResponse, WorkflowVersionResponse } from './types.gen'; diff --git a/ui/src/client/sdk.gen.ts b/ui/src/client/sdk.gen.ts index 103eb2e..81e7127 100644 --- a/ui/src/client/sdk.gen.ts +++ b/ui/src/client/sdk.gen.ts @@ -2,7 +2,7 @@ import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client'; import { client } from './client.gen'; -import type { ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteErrors, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponses, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteErrors, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteResponses, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostData, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostErrors, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostResponses, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostErrors, CreateApiKeyApiV1UserApiKeysPostResponses, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostErrors, CreateCampaignApiV1CampaignCreatePostResponses, CreateCredentialApiV1CredentialsPostData, CreateCredentialApiV1CredentialsPostErrors, CreateCredentialApiV1CredentialsPostResponses, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostErrors, CreateLoadTestApiV1LooptalkLoadTestsPostResponses, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostErrors, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponses, CreateRecordingsApiV1WorkflowRecordingsPostData, CreateRecordingsApiV1WorkflowRecordingsPostErrors, CreateRecordingsApiV1WorkflowRecordingsPostResponses, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostErrors, CreateServiceKeyApiV1UserServiceKeysPostResponses, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostErrors, CreateSessionApiV1IntegrationSessionPostResponses, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostErrors, CreateTestSessionApiV1LooptalkTestSessionsPostResponses, CreateToolApiV1ToolsPostData, CreateToolApiV1ToolsPostErrors, CreateToolApiV1ToolsPostResponses, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostErrors, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponses, CreateWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPostData, CreateWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPostErrors, CreateWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPostResponses, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostErrors, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponses, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostErrors, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponses, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteErrors, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponses, DeleteCredentialApiV1CredentialsCredentialUuidDeleteData, DeleteCredentialApiV1CredentialsCredentialUuidDeleteErrors, DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponses, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteData, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteErrors, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteResponses, DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteData, DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteErrors, DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteResponses, DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteData, DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteErrors, DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteResponses, DeleteToolApiV1ToolsToolUuidDeleteData, DeleteToolApiV1ToolsToolUuidDeleteErrors, DeleteToolApiV1ToolsToolUuidDeleteResponses, DownloadCampaignReportApiV1CampaignCampaignIdReportGetData, DownloadCampaignReportApiV1CampaignCampaignIdReportGetErrors, DownloadCampaignReportApiV1CampaignCampaignIdReportGetResponses, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetData, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetErrors, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetResponses, DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetData, DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetErrors, DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetResponses, DuplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePostData, DuplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePostErrors, DuplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePostResponses, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostErrors, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponses, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetErrors, GetActiveTestsApiV1LooptalkActiveTestsGetResponses, GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostData, GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostErrors, GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostResponses, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetErrors, GetApiKeysApiV1UserApiKeysGetResponses, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetErrors, GetAuthUserApiV1UserAuthUserGetResponses, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetErrors, GetCampaignApiV1CampaignCampaignIdGetResponses, GetCampaignDefaultsApiV1OrganizationsCampaignDefaultsGetData, GetCampaignDefaultsApiV1OrganizationsCampaignDefaultsGetErrors, GetCampaignDefaultsApiV1OrganizationsCampaignDefaultsGetResponses, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetErrors, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponses, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetErrors, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetErrors, GetCampaignsApiV1CampaignGetResponses, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetErrors, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponses, GetCredentialApiV1CredentialsCredentialUuidGetData, GetCredentialApiV1CredentialsCredentialUuidGetErrors, GetCredentialApiV1CredentialsCredentialUuidGetResponses, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetErrors, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponses, GetCurrentUserApiV1AuthMeGetData, GetCurrentUserApiV1AuthMeGetErrors, GetCurrentUserApiV1AuthMeGetResponses, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetErrors, GetDailyReportApiV1OrganizationsReportsDailyGetResponses, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetErrors, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponses, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetErrors, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponses, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetErrors, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponses, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetData, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetErrors, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponses, GetEmbedConfigApiV1PublicEmbedConfigTokenGetData, GetEmbedConfigApiV1PublicEmbedConfigTokenGetErrors, GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetErrors, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponses, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetErrors, GetFileMetadataApiV1S3FileMetadataGetResponses, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetErrors, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponses, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetErrors, GetIntegrationsApiV1IntegrationGetResponses, GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetData, GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetErrors, GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetResponses, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetErrors, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponses, GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetData, GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetErrors, GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostErrors, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponses, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetData, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetErrors, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetResponses, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetErrors, GetServiceKeysApiV1UserServiceKeysGetResponses, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetErrors, GetSignedUrlApiV1S3SignedUrlGetResponses, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetErrors, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponses, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetErrors, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponses, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetErrors, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetResponses, GetToolApiV1ToolsToolUuidGetData, GetToolApiV1ToolsToolUuidGetErrors, GetToolApiV1ToolsToolUuidGetResponses, GetTurnCredentialsApiV1TurnCredentialsGetData, GetTurnCredentialsApiV1TurnCredentialsGetErrors, GetTurnCredentialsApiV1TurnCredentialsGetResponses, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostData, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostErrors, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponses, GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostData, GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostErrors, GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostResponses, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetErrors, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponses, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetErrors, GetUserConfigurationsApiV1UserConfigurationsUserGetResponses, GetVoicesApiV1UserConfigurationsVoicesProviderGetData, GetVoicesApiV1UserConfigurationsVoicesProviderGetErrors, GetVoicesApiV1UserConfigurationsVoicesProviderGetResponses, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetErrors, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponses, GetWorkflowCountApiV1WorkflowCountGetData, GetWorkflowCountApiV1WorkflowCountGetErrors, GetWorkflowCountApiV1WorkflowCountGetResponses, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetErrors, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponses, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetErrors, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponses, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetErrors, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponses, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetErrors, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponses, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetErrors, GetWorkflowsApiV1WorkflowFetchGetResponses, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetErrors, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponses, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetErrors, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponses, GetWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGetData, GetWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGetErrors, GetWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGetResponses, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostData, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostErrors, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostResponses, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostErrors, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostResponses, HandleInboundFallbackApiV1TelephonyInboundFallbackPostData, HandleInboundFallbackApiV1TelephonyInboundFallbackPostErrors, HandleInboundFallbackApiV1TelephonyInboundFallbackPostResponses, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostErrors, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostResponses, HandleTelnyxEventsApiV1TelephonyTelnyxEventsWorkflowRunIdPostData, HandleTelnyxEventsApiV1TelephonyTelnyxEventsWorkflowRunIdPostErrors, HandleTelnyxEventsApiV1TelephonyTelnyxEventsWorkflowRunIdPostResponses, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostErrors, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostResponses, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostData, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostErrors, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostResponses, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostErrors, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostResponses, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostData, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostErrors, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostResponses, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostData, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostErrors, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostResponses, HealthApiV1HealthGetData, HealthApiV1HealthGetErrors, HealthApiV1HealthGetResponses, ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostErrors, ImpersonateApiV1SuperuserImpersonatePostResponses, InitializeEmbedSessionApiV1PublicEmbedInitPostData, InitializeEmbedSessionApiV1PublicEmbedInitPostErrors, InitializeEmbedSessionApiV1PublicEmbedInitPostResponses, InitiateCallApiV1PublicAgentUuidPostData, InitiateCallApiV1PublicAgentUuidPostErrors, InitiateCallApiV1PublicAgentUuidPostResponses, InitiateCallApiV1TelephonyInitiateCallPostData, InitiateCallApiV1TelephonyInitiateCallPostErrors, InitiateCallApiV1TelephonyInitiateCallPostResponses, InitiateCallTransferApiV1TelephonyCallTransferPostData, InitiateCallTransferApiV1TelephonyCallTransferPostErrors, InitiateCallTransferApiV1TelephonyCallTransferPostResponses, ListCredentialsApiV1CredentialsGetData, ListCredentialsApiV1CredentialsGetErrors, ListCredentialsApiV1CredentialsGetResponses, ListDocumentsApiV1KnowledgeBaseDocumentsGetData, ListDocumentsApiV1KnowledgeBaseDocumentsGetErrors, ListDocumentsApiV1KnowledgeBaseDocumentsGetResponses, ListRecordingsApiV1WorkflowRecordingsGetData, ListRecordingsApiV1WorkflowRecordingsGetErrors, ListRecordingsApiV1WorkflowRecordingsGetResponses, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetErrors, ListTestSessionsApiV1LooptalkTestSessionsGetResponses, ListToolsApiV1ToolsGetData, ListToolsApiV1ToolsGetErrors, ListToolsApiV1ToolsGetResponses, LoginApiV1AuthLoginPostData, LoginApiV1AuthLoginPostErrors, LoginApiV1AuthLoginPostResponses, OptionsConfigApiV1PublicEmbedConfigTokenOptionsData, OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors, OptionsConfigApiV1PublicEmbedConfigTokenOptionsResponses, OptionsInitApiV1PublicEmbedInitOptionsData, OptionsInitApiV1PublicEmbedInitOptionsErrors, OptionsInitApiV1PublicEmbedInitOptionsResponses, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsData, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsErrors, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsResponses, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostErrors, PauseCampaignApiV1CampaignCampaignIdPausePostResponses, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostData, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostErrors, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponses, PublishWorkflowApiV1WorkflowWorkflowIdPublishPostData, PublishWorkflowApiV1WorkflowWorkflowIdPublishPostErrors, PublishWorkflowApiV1WorkflowWorkflowIdPublishPostResponses, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutErrors, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponses, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutErrors, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutResponses, RedialCampaignApiV1CampaignCampaignIdRedialPostData, RedialCampaignApiV1CampaignCampaignIdRedialPostErrors, RedialCampaignApiV1CampaignCampaignIdRedialPostResponses, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostErrors, ResumeCampaignApiV1CampaignCampaignIdResumePostResponses, SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostData, SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostErrors, SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostResponses, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostErrors, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostResponses, SearchChunksApiV1KnowledgeBaseSearchPostData, SearchChunksApiV1KnowledgeBaseSearchPostErrors, SearchChunksApiV1KnowledgeBaseSearchPostResponses, SignupApiV1AuthSignupPostData, SignupApiV1AuthSignupPostErrors, SignupApiV1AuthSignupPostResponses, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostErrors, StartCampaignApiV1CampaignCampaignIdStartPostResponses, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostErrors, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostResponses, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostErrors, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostResponses, TranscribeAudioApiV1WorkflowRecordingsTranscribePostData, TranscribeAudioApiV1WorkflowRecordingsTranscribePostErrors, TranscribeAudioApiV1WorkflowRecordingsTranscribePostResponses, UnarchiveToolApiV1ToolsToolUuidUnarchivePostData, UnarchiveToolApiV1ToolsToolUuidUnarchivePostErrors, UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses, UpdateCampaignApiV1CampaignCampaignIdPatchData, UpdateCampaignApiV1CampaignCampaignIdPatchErrors, UpdateCampaignApiV1CampaignCampaignIdPatchResponses, UpdateCredentialApiV1CredentialsCredentialUuidPutData, UpdateCredentialApiV1CredentialsCredentialUuidPutErrors, UpdateCredentialApiV1CredentialsCredentialUuidPutResponses, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutErrors, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponses, UpdateRecordingApiV1WorkflowRecordingsIdPatchData, UpdateRecordingApiV1WorkflowRecordingsIdPatchErrors, UpdateRecordingApiV1WorkflowRecordingsIdPatchResponses, UpdateToolApiV1ToolsToolUuidPutData, UpdateToolApiV1ToolsToolUuidPutErrors, UpdateToolApiV1ToolsToolUuidPutResponses, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutErrors, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponses, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutErrors, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponses, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutErrors, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponses, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetErrors, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponses, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostErrors, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponses } from './types.gen'; +import type { ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteErrors, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponses, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteErrors, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteResponses, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostData, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostErrors, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostResponses, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostErrors, CreateApiKeyApiV1UserApiKeysPostResponses, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostErrors, CreateCampaignApiV1CampaignCreatePostResponses, CreateCredentialApiV1CredentialsPostData, CreateCredentialApiV1CredentialsPostErrors, CreateCredentialApiV1CredentialsPostResponses, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostErrors, CreateLoadTestApiV1LooptalkLoadTestsPostResponses, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostErrors, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponses, CreateRecordingsApiV1WorkflowRecordingsPostData, CreateRecordingsApiV1WorkflowRecordingsPostErrors, CreateRecordingsApiV1WorkflowRecordingsPostResponses, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostErrors, CreateServiceKeyApiV1UserServiceKeysPostResponses, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostErrors, CreateSessionApiV1IntegrationSessionPostResponses, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostErrors, CreateTestSessionApiV1LooptalkTestSessionsPostResponses, CreateToolApiV1ToolsPostData, CreateToolApiV1ToolsPostErrors, CreateToolApiV1ToolsPostResponses, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostErrors, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponses, CreateWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPostData, CreateWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPostErrors, CreateWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPostResponses, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostErrors, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponses, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostErrors, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponses, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteErrors, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponses, DeleteCredentialApiV1CredentialsCredentialUuidDeleteData, DeleteCredentialApiV1CredentialsCredentialUuidDeleteErrors, DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponses, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteData, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteErrors, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteResponses, DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteData, DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteErrors, DeleteLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsDeleteResponses, DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteData, DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteErrors, DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteResponses, DeleteToolApiV1ToolsToolUuidDeleteData, DeleteToolApiV1ToolsToolUuidDeleteErrors, DeleteToolApiV1ToolsToolUuidDeleteResponses, DownloadCampaignReportApiV1CampaignCampaignIdReportGetData, DownloadCampaignReportApiV1CampaignCampaignIdReportGetErrors, DownloadCampaignReportApiV1CampaignCampaignIdReportGetResponses, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetData, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetErrors, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetResponses, DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetData, DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetErrors, DownloadWorkflowReportApiV1WorkflowWorkflowIdReportGetResponses, DuplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePostData, DuplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePostErrors, DuplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePostResponses, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostErrors, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponses, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetErrors, GetActiveTestsApiV1LooptalkActiveTestsGetResponses, GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostData, GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostErrors, GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostResponses, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetErrors, GetApiKeysApiV1UserApiKeysGetResponses, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetErrors, GetAuthUserApiV1UserAuthUserGetResponses, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetErrors, GetCampaignApiV1CampaignCampaignIdGetResponses, GetCampaignDefaultsApiV1OrganizationsCampaignDefaultsGetData, GetCampaignDefaultsApiV1OrganizationsCampaignDefaultsGetErrors, GetCampaignDefaultsApiV1OrganizationsCampaignDefaultsGetResponses, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetErrors, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponses, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetErrors, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponses, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetErrors, GetCampaignsApiV1CampaignGetResponses, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetErrors, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponses, GetCredentialApiV1CredentialsCredentialUuidGetData, GetCredentialApiV1CredentialsCredentialUuidGetErrors, GetCredentialApiV1CredentialsCredentialUuidGetResponses, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetErrors, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponses, GetCurrentUserApiV1AuthMeGetData, GetCurrentUserApiV1AuthMeGetErrors, GetCurrentUserApiV1AuthMeGetResponses, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetErrors, GetDailyReportApiV1OrganizationsReportsDailyGetResponses, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetErrors, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponses, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetErrors, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponses, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetErrors, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponses, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetData, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetErrors, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponses, GetEmbedConfigApiV1PublicEmbedConfigTokenGetData, GetEmbedConfigApiV1PublicEmbedConfigTokenGetErrors, GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetErrors, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponses, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetErrors, GetFileMetadataApiV1S3FileMetadataGetResponses, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetErrors, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponses, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetErrors, GetIntegrationsApiV1IntegrationGetResponses, GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetData, GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetErrors, GetLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsGetResponses, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetErrors, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponses, GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetData, GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetErrors, GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses, GetNodeTypeApiV1NodeTypesNameGetData, GetNodeTypeApiV1NodeTypesNameGetErrors, GetNodeTypeApiV1NodeTypesNameGetResponses, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostErrors, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponses, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetData, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetErrors, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetResponses, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetErrors, GetServiceKeysApiV1UserServiceKeysGetResponses, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetErrors, GetSignedUrlApiV1S3SignedUrlGetResponses, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetErrors, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponses, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetErrors, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponses, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetErrors, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetResponses, GetToolApiV1ToolsToolUuidGetData, GetToolApiV1ToolsToolUuidGetErrors, GetToolApiV1ToolsToolUuidGetResponses, GetTurnCredentialsApiV1TurnCredentialsGetData, GetTurnCredentialsApiV1TurnCredentialsGetErrors, GetTurnCredentialsApiV1TurnCredentialsGetResponses, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostData, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostErrors, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponses, GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostData, GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostErrors, GetUploadUrlsApiV1WorkflowRecordingsUploadUrlPostResponses, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetErrors, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponses, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetErrors, GetUserConfigurationsApiV1UserConfigurationsUserGetResponses, GetVoicesApiV1UserConfigurationsVoicesProviderGetData, GetVoicesApiV1UserConfigurationsVoicesProviderGetErrors, GetVoicesApiV1UserConfigurationsVoicesProviderGetResponses, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetErrors, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponses, GetWorkflowCountApiV1WorkflowCountGetData, GetWorkflowCountApiV1WorkflowCountGetErrors, GetWorkflowCountApiV1WorkflowCountGetResponses, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetErrors, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponses, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetErrors, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponses, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetErrors, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponses, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetErrors, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponses, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetErrors, GetWorkflowsApiV1WorkflowFetchGetResponses, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetErrors, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponses, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetErrors, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponses, GetWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGetData, GetWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGetErrors, GetWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGetResponses, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostData, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostErrors, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostResponses, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostErrors, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostResponses, HandleInboundFallbackApiV1TelephonyInboundFallbackPostData, HandleInboundFallbackApiV1TelephonyInboundFallbackPostErrors, HandleInboundFallbackApiV1TelephonyInboundFallbackPostResponses, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostErrors, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostResponses, HandleTelnyxEventsApiV1TelephonyTelnyxEventsWorkflowRunIdPostData, HandleTelnyxEventsApiV1TelephonyTelnyxEventsWorkflowRunIdPostErrors, HandleTelnyxEventsApiV1TelephonyTelnyxEventsWorkflowRunIdPostResponses, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostErrors, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostResponses, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostData, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostErrors, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostResponses, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostErrors, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostResponses, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostData, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostErrors, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostResponses, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostData, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostErrors, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostResponses, HealthApiV1HealthGetData, HealthApiV1HealthGetErrors, HealthApiV1HealthGetResponses, ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostErrors, ImpersonateApiV1SuperuserImpersonatePostResponses, InitializeEmbedSessionApiV1PublicEmbedInitPostData, InitializeEmbedSessionApiV1PublicEmbedInitPostErrors, InitializeEmbedSessionApiV1PublicEmbedInitPostResponses, InitiateCallApiV1PublicAgentUuidPostData, InitiateCallApiV1PublicAgentUuidPostErrors, InitiateCallApiV1PublicAgentUuidPostResponses, InitiateCallApiV1TelephonyInitiateCallPostData, InitiateCallApiV1TelephonyInitiateCallPostErrors, InitiateCallApiV1TelephonyInitiateCallPostResponses, InitiateCallTransferApiV1TelephonyCallTransferPostData, InitiateCallTransferApiV1TelephonyCallTransferPostErrors, InitiateCallTransferApiV1TelephonyCallTransferPostResponses, ListCredentialsApiV1CredentialsGetData, ListCredentialsApiV1CredentialsGetErrors, ListCredentialsApiV1CredentialsGetResponses, ListDocumentsApiV1KnowledgeBaseDocumentsGetData, ListDocumentsApiV1KnowledgeBaseDocumentsGetErrors, ListDocumentsApiV1KnowledgeBaseDocumentsGetResponses, ListNodeTypesApiV1NodeTypesGetData, ListNodeTypesApiV1NodeTypesGetErrors, ListNodeTypesApiV1NodeTypesGetResponses, ListRecordingsApiV1WorkflowRecordingsGetData, ListRecordingsApiV1WorkflowRecordingsGetErrors, ListRecordingsApiV1WorkflowRecordingsGetResponses, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetErrors, ListTestSessionsApiV1LooptalkTestSessionsGetResponses, ListToolsApiV1ToolsGetData, ListToolsApiV1ToolsGetErrors, ListToolsApiV1ToolsGetResponses, LoginApiV1AuthLoginPostData, LoginApiV1AuthLoginPostErrors, LoginApiV1AuthLoginPostResponses, OptionsConfigApiV1PublicEmbedConfigTokenOptionsData, OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors, OptionsConfigApiV1PublicEmbedConfigTokenOptionsResponses, OptionsInitApiV1PublicEmbedInitOptionsData, OptionsInitApiV1PublicEmbedInitOptionsErrors, OptionsInitApiV1PublicEmbedInitOptionsResponses, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsData, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsErrors, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsResponses, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostErrors, PauseCampaignApiV1CampaignCampaignIdPausePostResponses, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostData, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostErrors, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponses, PublishWorkflowApiV1WorkflowWorkflowIdPublishPostData, PublishWorkflowApiV1WorkflowWorkflowIdPublishPostErrors, PublishWorkflowApiV1WorkflowWorkflowIdPublishPostResponses, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutErrors, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponses, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutErrors, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutResponses, RedialCampaignApiV1CampaignCampaignIdRedialPostData, RedialCampaignApiV1CampaignCampaignIdRedialPostErrors, RedialCampaignApiV1CampaignCampaignIdRedialPostResponses, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostErrors, ResumeCampaignApiV1CampaignCampaignIdResumePostResponses, SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostData, SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostErrors, SaveLangfuseCredentialsApiV1OrganizationsLangfuseCredentialsPostResponses, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostErrors, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostResponses, SearchChunksApiV1KnowledgeBaseSearchPostData, SearchChunksApiV1KnowledgeBaseSearchPostErrors, SearchChunksApiV1KnowledgeBaseSearchPostResponses, SignupApiV1AuthSignupPostData, SignupApiV1AuthSignupPostErrors, SignupApiV1AuthSignupPostResponses, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostErrors, StartCampaignApiV1CampaignCampaignIdStartPostResponses, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostErrors, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostResponses, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostErrors, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostResponses, TranscribeAudioApiV1WorkflowRecordingsTranscribePostData, TranscribeAudioApiV1WorkflowRecordingsTranscribePostErrors, TranscribeAudioApiV1WorkflowRecordingsTranscribePostResponses, UnarchiveToolApiV1ToolsToolUuidUnarchivePostData, UnarchiveToolApiV1ToolsToolUuidUnarchivePostErrors, UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses, UpdateCampaignApiV1CampaignCampaignIdPatchData, UpdateCampaignApiV1CampaignCampaignIdPatchErrors, UpdateCampaignApiV1CampaignCampaignIdPatchResponses, UpdateCredentialApiV1CredentialsCredentialUuidPutData, UpdateCredentialApiV1CredentialsCredentialUuidPutErrors, UpdateCredentialApiV1CredentialsCredentialUuidPutResponses, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutErrors, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponses, UpdateRecordingApiV1WorkflowRecordingsIdPatchData, UpdateRecordingApiV1WorkflowRecordingsIdPatchErrors, UpdateRecordingApiV1WorkflowRecordingsIdPatchResponses, UpdateToolApiV1ToolsToolUuidPutData, UpdateToolApiV1ToolsToolUuidPutErrors, UpdateToolApiV1ToolsToolUuidPutResponses, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutErrors, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponses, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutErrors, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponses, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutErrors, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponses, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetErrors, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponses, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostErrors, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponses } from './types.gen'; export type Options = Options2 & { /** @@ -1498,6 +1498,21 @@ export const loginApiV1AuthLoginPost = (op */ export const getCurrentUserApiV1AuthMeGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/auth/me', ...options }); +/** + * List Node Types + * + * List every registered NodeSpec. + * + * SDK clients should pin to `spec_version` and warn if the server reports + * a higher version than what they were generated against. + */ +export const listNodeTypesApiV1NodeTypesGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/node-types', ...options }); + +/** + * Get Node Type + */ +export const getNodeTypeApiV1NodeTypesNameGet = (options: Options) => (options.client ?? client).get({ url: '/api/v1/node-types/{name}', ...options }); + /** * Health */ diff --git a/ui/src/client/types.gen.ts b/ui/src/client/types.gen.ts index 9c819a2..cc68ddf 100644 --- a/ui/src/client/types.gen.ts +++ b/ui/src/client/types.gen.ts @@ -1281,6 +1281,36 @@ export type DefaultConfigurationsResponse = { }; }; +/** + * DisplayOptions + * + * Conditional visibility rules. + * + * `show` keys are AND-combined: this property is visible only when EVERY + * referenced field's value matches one of the listed values. + * + * `hide` keys are OR-combined: this property is hidden when ANY referenced + * field's value matches one of the listed values. + * + * Example: + * DisplayOptions(show={"extraction_enabled": [True]}) + * DisplayOptions(show={"greeting_type": ["audio"]}) + */ +export type DisplayOptions = { + /** + * Show + */ + show?: { + [key: string]: Array; + } | null; + /** + * Hide + */ + hide?: { + [key: string]: Array; + } | null; +}; + /** * DocumentListResponseSchema * @@ -1675,6 +1705,30 @@ export type FileMetadataResponse = { } | null; }; +/** + * GraphConstraints + * + * Per-node-type graph rules. WorkflowGraph enforces these at validation. + */ +export type GraphConstraints = { + /** + * Min Incoming + */ + min_incoming?: number | null; + /** + * Max Incoming + */ + max_incoming?: number | null; + /** + * Min Outgoing + */ + min_outgoing?: number | null; + /** + * Max Outgoing + */ + max_outgoing?: number | null; +}; + /** * HTTPValidationError */ @@ -2056,6 +2110,123 @@ export type MpsCreditsResponse = { total_quota: number; }; +/** + * MigrationSpec + * + * Declared migration step (JSON-serializable view). + * + * The migrate callable is registered out-of-band via `register_migration()` + * and never serialized — LLM and frontend consumers only see version + * metadata and warn on mismatch. + */ +export type MigrationSpec = { + /** + * From Version + */ + from_version: string; + /** + * To Version + */ + to_version: string; + /** + * Description + */ + description: string; +}; + +/** + * NodeCategory + * + * Drives grouping in the AddNodePanel UI. + */ +export type NodeCategory = 'call_node' | 'global_node' | 'trigger' | 'integration'; + +/** + * NodeExample + * + * A worked example LLMs can pattern-match. Keep small and realistic. + */ +export type NodeExample = { + /** + * Name + */ + name: string; + /** + * Description + */ + description?: string | null; + /** + * Data + */ + data: { + [key: string]: unknown; + }; +}; + +/** + * NodeSpec + * + * Single source of truth for a node type. + */ +export type NodeSpec = { + /** + * Name + */ + name: string; + /** + * Display Name + */ + display_name: string; + /** + * Description + * + * Human-facing explanation shown in AddNodePanel. + */ + description: string; + /** + * Llm Hint + * + * LLM-only guidance; omitted from the UI. + */ + llm_hint?: string | null; + category: NodeCategory; + /** + * Icon + */ + icon: string; + /** + * Version + */ + version?: string; + /** + * Properties + */ + properties: Array; + /** + * Examples + */ + examples?: Array; + /** + * Migrations + */ + migrations?: Array; + graph_constraints?: GraphConstraints | null; +}; + +/** + * NodeTypesResponse + */ +export type NodeTypesResponse = { + /** + * Spec Version + */ + spec_version: string; + /** + * Node Types + */ + node_types: Array; +}; + /** * PresignedUploadUrlRequest */ @@ -2124,6 +2295,125 @@ export type ProcessDocumentRequestSchema = { retrieval_mode?: string; }; +/** + * PropertyOption + * + * An option in an `options` or `multi_options` dropdown. + */ +export type PropertyOption = { + /** + * Value + */ + value: string | number | boolean | number; + /** + * Label + */ + label: string; + /** + * Description + */ + description?: string | null; +}; + +/** + * PropertySpec + * + * Single field on a node. + * + * `description` is HUMAN-FACING — shown under the field in the edit + * dialog. Keep it concise and explain what the field does. + * + * `llm_hint` is LLM-FACING — appears only in the `get_node_type` MCP + * response and in SDK schema output. Use it for catalog tool references + * (e.g., "Use `list_recordings`"), array shape, expected value idioms, + * or anything that would be noise in the UI. Optional; omit when the + * `description` already suffices for both audiences. + */ +export type PropertySpec = { + /** + * Name + */ + name: string; + type: PropertyType; + /** + * Display Name + */ + display_name: string; + /** + * Description + * + * Human-facing explanation shown in the UI. + */ + description: string; + /** + * Llm Hint + * + * LLM-only guidance; omitted from the UI. + */ + llm_hint?: string | null; + /** + * Default + */ + default?: unknown; + /** + * Required + */ + required?: boolean; + /** + * Placeholder + */ + placeholder?: string | null; + display_options?: DisplayOptions | null; + /** + * Options + */ + options?: Array | null; + /** + * Properties + */ + properties?: Array | null; + /** + * Min Value + */ + min_value?: number | null; + /** + * Max Value + */ + max_value?: number | null; + /** + * Min Length + */ + min_length?: number | null; + /** + * Max Length + */ + max_length?: number | null; + /** + * Pattern + */ + pattern?: string | null; + /** + * Editor + */ + editor?: string | null; + /** + * Extra + */ + extra?: { + [key: string]: unknown; + }; +}; + +/** + * PropertyType + * + * Bounded vocabulary of property types the renderer dispatches on. + * + * Adding a value here requires a matching arm in the frontend + * `` switch and (where relevant) the SDK codegen template. + */ +export type PropertyType = 'string' | 'number' | 'boolean' | 'options' | 'multi_options' | 'fixed_collection' | 'json' | 'tool_refs' | 'document_refs' | 'recording_ref' | 'credential_ref' | 'mention_textarea' | 'url'; + /** * RecordingCreateRequestSchema * @@ -9385,6 +9675,89 @@ export type GetCurrentUserApiV1AuthMeGetResponses = { export type GetCurrentUserApiV1AuthMeGetResponse = GetCurrentUserApiV1AuthMeGetResponses[keyof GetCurrentUserApiV1AuthMeGetResponses]; +export type ListNodeTypesApiV1NodeTypesGetData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/node-types'; +}; + +export type ListNodeTypesApiV1NodeTypesGetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ListNodeTypesApiV1NodeTypesGetError = ListNodeTypesApiV1NodeTypesGetErrors[keyof ListNodeTypesApiV1NodeTypesGetErrors]; + +export type ListNodeTypesApiV1NodeTypesGetResponses = { + /** + * Successful Response + */ + 200: NodeTypesResponse; +}; + +export type ListNodeTypesApiV1NodeTypesGetResponse = ListNodeTypesApiV1NodeTypesGetResponses[keyof ListNodeTypesApiV1NodeTypesGetResponses]; + +export type GetNodeTypeApiV1NodeTypesNameGetData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path: { + /** + * Name + */ + name: string; + }; + query?: never; + url: '/api/v1/node-types/{name}'; +}; + +export type GetNodeTypeApiV1NodeTypesNameGetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetNodeTypeApiV1NodeTypesNameGetError = GetNodeTypeApiV1NodeTypesNameGetErrors[keyof GetNodeTypeApiV1NodeTypesNameGetErrors]; + +export type GetNodeTypeApiV1NodeTypesNameGetResponses = { + /** + * Successful Response + */ + 200: NodeSpec; +}; + +export type GetNodeTypeApiV1NodeTypesNameGetResponse = GetNodeTypeApiV1NodeTypesNameGetResponses[keyof GetNodeTypeApiV1NodeTypesNameGetResponses]; + export type HealthApiV1HealthGetData = { body?: never; path?: never; diff --git a/ui/src/components/flow/AddNodePanel.tsx b/ui/src/components/flow/AddNodePanel.tsx index 06c2a79..02346f9 100644 --- a/ui/src/components/flow/AddNodePanel.tsx +++ b/ui/src/components/flow/AddNodePanel.tsx @@ -1,118 +1,91 @@ -import { ClipboardCheck, ExternalLink, Globe, Headset, Link2, LucideIcon, OctagonX, Play, Webhook, X } from 'lucide-react'; -import { useEffect } from 'react'; +import * as LucideIcons from 'lucide-react'; +import { Circle, ExternalLink, type LucideIcon, X } from 'lucide-react'; +import { useEffect, useMemo } from 'react'; +import type { NodeSpec } from '@/client/types.gen'; +import { useNodeSpecs } from '@/components/flow/renderer'; import { Button } from '@/components/ui/button'; import { NodeType } from './types'; -type NodeTypeConfig = { - type: NodeType; - label: string; - description: string; - icon: LucideIcon; -}; - type AddNodePanelProps = { isOpen: boolean; onClose: () => void; onNodeSelect: (nodeType: NodeType) => void; }; -const NODE_TYPES: NodeTypeConfig[] = [ - { - type: NodeType.START_CALL, - label: 'Start Call', - description: 'Create a start call node', - icon: Play - }, - { - type: NodeType.AGENT_NODE, - label: 'Agent Node', - description: 'Create an agent node', - icon: Headset - }, - { - type: NodeType.END_CALL, - label: 'End Call', - description: 'Create an end call node', - icon: OctagonX - } +// Section ordering and labels. Drives both the category → section title +// mapping and the rendering order. +const SECTION_ORDER: Array<{ category: NodeSpec['category']; title: string }> = [ + { category: 'trigger', title: 'Triggers' }, + { category: 'call_node', title: 'Agent Nodes' }, + { category: 'global_node', title: 'Global Nodes' }, + { category: 'integration', title: 'Integrations' }, ]; -const GLOBAL_NODE_TYPES: NodeTypeConfig[] = [ - { - type: NodeType.GLOBAL_NODE, - label: 'Global Node', - description: 'Create a global node', - icon: Globe - } -]; - -const TRIGGER_NODE_TYPES: NodeTypeConfig[] = [ - { - type: NodeType.TRIGGER, - label: 'API Trigger', - description: 'Enable API-based call triggering', - icon: Webhook - } -]; - -const INTEGRATION_NODE_TYPES: NodeTypeConfig[] = [ - { - type: NodeType.WEBHOOK, - label: 'Webhook', - description: 'Send HTTP request after workflow completion', - icon: Link2 - }, - { - type: NodeType.QA, - label: 'QA Analysis', - description: 'Run LLM quality analysis after each call', - icon: ClipboardCheck - } -]; +function resolveIcon(name: string): LucideIcon { + const icons = LucideIcons as unknown as Record; + return icons[name] ?? Circle; +} function NodeSection({ title, - nodes, - onNodeSelect + specs, + onNodeSelect, }: { title: string; - nodes: NodeTypeConfig[]; + specs: NodeSpec[]; onNodeSelect: (nodeType: NodeType) => void; }) { + if (specs.length === 0) return null; return (

{title}

- {nodes.map((node) => ( -
- - ))} + + ); + })}
); } export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodePanelProps) { + const { specs } = useNodeSpecs(); + + // Group registered specs by category, preserving the SECTION_ORDER. + // Adding a new node type with a new spec.category just shows up here. + const sections = useMemo(() => { + return SECTION_ORDER.map(({ category, title }) => ({ + title, + specs: specs.filter((s) => s.category === category), + })); + }, [specs]); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape' && isOpen) { @@ -149,29 +122,14 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
- - - - - - - + {sections.map(({ title, specs }) => ( + + ))}
diff --git a/ui/src/components/flow/nodes/AgentNode.tsx b/ui/src/components/flow/nodes/AgentNode.tsx deleted file mode 100644 index adaaedd..0000000 --- a/ui/src/components/flow/nodes/AgentNode.tsx +++ /dev/null @@ -1,415 +0,0 @@ -import { NodeProps, NodeToolbar, Position } from "@xyflow/react"; -import { Edit, FileText, Headset, PlusIcon, Trash2Icon, Wrench } from "lucide-react"; -import { memo, useCallback, useEffect, useMemo, useState } from "react"; - -import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext"; -import type { DocumentResponseSchema, RecordingResponseSchema, ToolResponse } from "@/client/types.gen"; -import { DocumentBadges } from "@/components/flow/DocumentBadges"; -import { DocumentSelector } from "@/components/flow/DocumentSelector"; -import { MentionTextarea } from "@/components/flow/MentionTextarea"; -import { ToolBadges } from "@/components/flow/ToolBadges"; -import { ToolSelector } from "@/components/flow/ToolSelector"; -import { ExtractionVariable, FlowNodeData } from "@/components/flow/types"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; -import { CONTEXT_VARIABLES_DOC_URL, NODE_DOCUMENTATION_URLS } from "@/constants/documentation"; - -import { NodeContent } from "./common/NodeContent"; -import { NodeEditDialog } from "./common/NodeEditDialog"; -import { useNodeHandlers } from "./common/useNodeHandlers"; - -interface AgentNodeEditFormProps { - nodeData: FlowNodeData; - prompt: string; - setPrompt: (value: string) => void; - name: string; - setName: (value: string) => void; - allowInterrupt: boolean; - setAllowInterrupt: (value: boolean) => void; - extractionEnabled: boolean; - setExtractionEnabled: (value: boolean) => void; - extractionPrompt: string; - setExtractionPrompt: (value: string) => void; - variables: ExtractionVariable[]; - setVariables: (vars: ExtractionVariable[]) => void; - addGlobalPrompt: boolean; - setAddGlobalPrompt: (value: boolean) => void; - toolUuids: string[]; - setToolUuids: (value: string[]) => void; - documentUuids: string[]; - setDocumentUuids: (value: string[]) => void; - tools: ToolResponse[]; - documents: DocumentResponseSchema[]; - recordings: RecordingResponseSchema[]; -} - -interface AgentNodeProps extends NodeProps { - data: FlowNodeData; -} - -export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => { - const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id }); - const { saveWorkflow, tools, documents, recordings } = useWorkflow(); - - // Form state - const [prompt, setPrompt] = useState(data.prompt); - const [name, setName] = useState(data.name); - const [allowInterrupt, setAllowInterrupt] = useState(data.allow_interrupt ?? true); - - // Variable Extraction state - const [extractionEnabled, setExtractionEnabled] = useState(data.extraction_enabled ?? false); - const [extractionPrompt, setExtractionPrompt] = useState(data.extraction_prompt ?? ""); - const [variables, setVariables] = useState(data.extraction_variables ?? []); - const [addGlobalPrompt, setAddGlobalPrompt] = useState(data.add_global_prompt ?? true); - const [toolUuids, setToolUuids] = useState(data.tool_uuids ?? []); - const [documentUuids, setDocumentUuids] = useState(data.document_uuids ?? []); - - // Compute if form has unsaved changes (only check prompt, name) - const isDirty = useMemo(() => { - return ( - prompt !== (data.prompt ?? "") || - name !== (data.name ?? "") - ); - }, [prompt, name, data]); - - const handleSave = async () => { - handleSaveNodeData({ - ...data, - prompt, - name, - allow_interrupt: allowInterrupt, - extraction_enabled: extractionEnabled, - extraction_prompt: extractionPrompt, - extraction_variables: variables, - add_global_prompt: addGlobalPrompt, - tool_uuids: toolUuids.length > 0 ? toolUuids : undefined, - document_uuids: documentUuids.length > 0 ? documentUuids : undefined, - }); - setOpen(false); - await saveWorkflow(); - }; - - // Reset form state when dialog opens - const handleOpenChange = (newOpen: boolean) => { - if (newOpen) { - setPrompt(data.prompt); - setName(data.name); - setAllowInterrupt(data.allow_interrupt ?? true); - setExtractionEnabled(data.extraction_enabled ?? false); - setExtractionPrompt(data.extraction_prompt ?? ""); - setVariables(data.extraction_variables ?? []); - setAddGlobalPrompt(data.add_global_prompt ?? true); - setToolUuids(data.tool_uuids ?? []); - setDocumentUuids(data.document_uuids ?? []); - } - setOpen(newOpen); - }; - - // Update form state when data changes (e.g., from undo/redo) - useEffect(() => { - if (open) { - setPrompt(data.prompt); - setName(data.name); - setAllowInterrupt(data.allow_interrupt ?? true); - setExtractionEnabled(data.extraction_enabled ?? false); - setExtractionPrompt(data.extraction_prompt ?? ""); - setVariables(data.extraction_variables ?? []); - setAddGlobalPrompt(data.add_global_prompt ?? true); - setToolUuids(data.tool_uuids ?? []); - setDocumentUuids(data.document_uuids ?? []); - } - }, [data, open]); - - // Handle cleanup of stale document UUIDs - const handleStaleDocuments = useCallback(async (staleUuids: string[]) => { - const cleanedUuids = (data.document_uuids ?? []).filter(uuid => !staleUuids.includes(uuid)); - handleSaveNodeData({ - ...data, - document_uuids: cleanedUuids.length > 0 ? cleanedUuids : undefined, - }); - await saveWorkflow(); - }, [data, handleSaveNodeData, saveWorkflow]); - - // Handle cleanup of stale tool UUIDs - const handleStaleTools = useCallback(async (staleUuids: string[]) => { - const cleanedUuids = (data.tool_uuids ?? []).filter(uuid => !staleUuids.includes(uuid)); - handleSaveNodeData({ - ...data, - tool_uuids: cleanedUuids.length > 0 ? cleanedUuids : undefined, - }); - await saveWorkflow(); - }, [data, handleSaveNodeData, saveWorkflow]); - - return ( - <> - } - nodeType="agent" - hasSourceHandle={true} - hasTargetHandle={true} - onDoubleClick={() => setOpen(true)} - nodeId={id} - > -

- {data.prompt || 'No prompt configured'} -

- {data.tool_uuids && data.tool_uuids.length > 0 && ( -
-
- - Tools: -
- -
- )} - {data.document_uuids && data.document_uuids.length > 0 && ( -
-
- - Documents: -
- -
- )} -
- - -
- - -
-
- - - {open && ( - - )} - - - ); -}); - -const AgentNodeEditForm = ({ - prompt, - setPrompt, - name, - setName, - allowInterrupt, - setAllowInterrupt, - extractionEnabled, - setExtractionEnabled, - extractionPrompt, - setExtractionPrompt, - variables, - setVariables, - addGlobalPrompt, - setAddGlobalPrompt, - toolUuids, - setToolUuids, - documentUuids, - setDocumentUuids, - tools, - documents, - recordings, -}: AgentNodeEditFormProps) => { - const handleVariableNameChange = (idx: number, value: string) => { - const newVars = [...variables]; - newVars[idx] = { ...newVars[idx], name: value }; - setVariables(newVars); - }; - - const handleVariableTypeChange = (idx: number, value: 'string' | 'number' | 'boolean') => { - const newVars = [...variables]; - newVars[idx] = { ...newVars[idx], type: value }; - setVariables(newVars); - }; - - const handleVariablePromptChange = (idx: number, value: string) => { - const newVars = [...variables]; - newVars[idx] = { ...newVars[idx], prompt: value }; - setVariables(newVars); - }; - - const handleRemoveVariable = (idx: number) => { - const newVars = variables.filter((_, i) => i !== idx); - setVariables(newVars); - }; - - const handleAddVariable = () => { - setVariables([...variables, { name: '', type: 'string', prompt: '' }]); - }; - - return ( -
- - - setName(e.target.value)} - /> - -
- - - -
- -
- - - -
- - -
- - - -
- - - {/* Variable Extraction Section */} -
- - - -
- - {extractionEnabled && ( -
- - -