mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
feat: refactor node spec and add mcp tools (#244)
* refactor: carve out extraction panel * refactor: create spec versions for node types * refactor: create a GenericNode and remove custom nodes * feat: add python and typescript sdk * add dograh sdk * fix: fetch draft workflow definition over published one * fix: fix routes of SDKs to use code gen * chore: remove doclink dependency to reduce image size * chore: format files * chore: bump pipecat * feat: let mcp fetch archived workflows on demand * chore: fix tests * feat: add sdk documentation * chore: change banner and add badge
This commit is contained in:
parent
0a61ef295f
commit
00a1a22b74
162 changed files with 14355 additions and 3554 deletions
0
sdk/codegen/__init__.py
Normal file
0
sdk/codegen/__init__.py
Normal file
355
sdk/codegen/client_codegen.py
Normal file
355
sdk/codegen/client_codegen.py
Normal file
|
|
@ -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<string, unknown> = {")
|
||||
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<T = unknown>(
|
||||
method: string,
|
||||
path: string,
|
||||
opts?: {{ json?: unknown; params?: Record<string, unknown> }},
|
||||
): Promise<T>;
|
||||
|
||||
"""
|
||||
|
||||
|
||||
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()
|
||||
3
sdk/python/.gitignore
vendored
Normal file
3
sdk/python/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
24
sdk/python/LICENSE
Normal file
24
sdk/python/LICENSE
Normal file
|
|
@ -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.
|
||||
77
sdk/python/README.md
Normal file
77
sdk/python/README.md
Normal file
|
|
@ -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`.
|
||||
49
sdk/python/pyproject.toml
Normal file
49
sdk/python/pyproject.toml
Normal file
|
|
@ -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"]
|
||||
35
sdk/python/src/dograh_sdk/__init__.py
Normal file
35
sdk/python/src/dograh_sdk/__init__.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
102
sdk/python/src/dograh_sdk/_generated_client.py
Normal file
102
sdk/python/src/dograh_sdk/_generated_client.py
Normal file
|
|
@ -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)
|
||||
358
sdk/python/src/dograh_sdk/_generated_models.py
Normal file
358
sdk/python/src/dograh_sdk/_generated_models.py
Normal file
|
|
@ -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
|
||||
`<PropertyInput>` 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()
|
||||
166
sdk/python/src/dograh_sdk/_validation.py
Normal file
166
sdk/python/src/dograh_sdk/_validation.py
Normal file
|
|
@ -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
|
||||
151
sdk/python/src/dograh_sdk/client.py
Normal file
151
sdk/python/src/dograh_sdk/client.py
Normal file
|
|
@ -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
|
||||
332
sdk/python/src/dograh_sdk/codegen.py
Normal file
332
sdk/python/src/dograh_sdk/codegen.py
Normal file
|
|
@ -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"(?<!^)(?=[A-Z])", "_", spec_name).lower()
|
||||
# Handle abbreviations (qa, webhook, trigger, etc.): no underscore needed.
|
||||
class_name = _spec_class_name(spec_name)
|
||||
lines.append(f"from dograh_sdk.typed.{module_name} import {class_name}")
|
||||
exports.append(class_name)
|
||||
|
||||
lines.append("from dograh_sdk.typed._base import TypedNode")
|
||||
exports.append("TypedNode")
|
||||
lines.append("")
|
||||
lines.append("__all__ = [")
|
||||
for name in sorted(exports):
|
||||
lines.append(f' "{name}",')
|
||||
lines.append("]")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _module_name_for(spec_name: str) -> str:
|
||||
"""startCall → start_call.py; agentNode → agent_node.py."""
|
||||
return re.sub(r"(?<!^)(?=[A-Z])", "_", spec_name).lower()
|
||||
|
||||
|
||||
# ── public entry points ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_all(specs: list[dict[str, Any]], out_dir: Path) -> None:
|
||||
"""Emit typed dataclasses for every spec into `out_dir`.
|
||||
|
||||
Preserves `out_dir/_base.py` if present (it's hand-written). Writes
|
||||
one `<spec>.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:])
|
||||
32
sdk/python/src/dograh_sdk/errors.py
Normal file
32
sdk/python/src/dograh_sdk/errors.py
Normal file
|
|
@ -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."""
|
||||
25
sdk/python/src/dograh_sdk/typed/__init__.py
Normal file
25
sdk/python/src/dograh_sdk/typed/__init__.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
49
sdk/python/src/dograh_sdk/typed/_base.py
Normal file
49
sdk/python/src/dograh_sdk/typed/_base.py
Normal file
|
|
@ -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 != []
|
||||
}
|
||||
97
sdk/python/src/dograh_sdk/typed/agent_node.py
Normal file
97
sdk/python/src/dograh_sdk/typed/agent_node.py
Normal file
|
|
@ -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.
|
||||
"""
|
||||
|
||||
82
sdk/python/src/dograh_sdk/typed/end_call.py
Normal file
82
sdk/python/src/dograh_sdk/typed/end_call.py
Normal file
|
|
@ -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.
|
||||
"""
|
||||
|
||||
38
sdk/python/src/dograh_sdk/typed/global_node.py
Normal file
38
sdk/python/src/dograh_sdk/typed/global_node.py
Normal file
|
|
@ -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}}.
|
||||
"""
|
||||
|
||||
87
sdk/python/src/dograh_sdk/typed/qa.py
Normal file
87
sdk/python/src/dograh_sdk/typed/qa.py
Normal file
|
|
@ -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.
|
||||
"""
|
||||
|
||||
142
sdk/python/src/dograh_sdk/typed/start_call.py
Normal file
142
sdk/python/src/dograh_sdk/typed/start_call.py
Normal file
|
|
@ -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.
|
||||
"""
|
||||
|
||||
42
sdk/python/src/dograh_sdk/typed/trigger.py
Normal file
42
sdk/python/src/dograh_sdk/typed/trigger.py
Normal file
|
|
@ -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.
|
||||
"""
|
||||
|
||||
84
sdk/python/src/dograh_sdk/typed/webhook.py
Normal file
84
sdk/python/src/dograh_sdk/typed/webhook.py
Normal file
|
|
@ -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).
|
||||
"""
|
||||
|
||||
247
sdk/python/src/dograh_sdk/workflow.py
Normal file
247
sdk/python/src/dograh_sdk/workflow.py
Normal file
|
|
@ -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
|
||||
2
sdk/typescript/.gitignore
vendored
Normal file
2
sdk/typescript/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
dist/
|
||||
*.tsbuildinfo
|
||||
24
sdk/typescript/LICENSE
Normal file
24
sdk/typescript/LICENSE
Normal file
|
|
@ -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.
|
||||
91
sdk/typescript/README.md
Normal file
91
sdk/typescript/README.md
Normal file
|
|
@ -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`.
|
||||
389
sdk/typescript/package-lock.json
generated
Normal file
389
sdk/typescript/package-lock.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
sdk/typescript/package.json
Normal file
52
sdk/typescript/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
258
sdk/typescript/scripts/codegen.mts
Normal file
258
sdk/typescript/scripts/codegen.mts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
// Typed SDK code generator (TypeScript).
|
||||
//
|
||||
// Reads NodeSpecs from the live backend or a local JSON file and emits
|
||||
// one `<kebab-case>.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> = {
|
||||
string: "string",
|
||||
number: "number",
|
||||
boolean: "boolean",
|
||||
json: "Record<string, unknown>",
|
||||
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<NodeSpec[]> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
96
sdk/typescript/src/_generated_client.ts
Normal file
96
sdk/typescript/src/_generated_client.ts
Normal file
|
|
@ -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<T = unknown>(
|
||||
method: string,
|
||||
path: string,
|
||||
opts?: { json?: unknown; params?: Record<string, unknown> },
|
||||
): Promise<T>;
|
||||
|
||||
/** Fetch a single node spec by name. */
|
||||
async getNodeType(name: string): Promise<NodeSpec> {
|
||||
return this.request<NodeSpec>("GET", `/node-types/${name}`);
|
||||
}
|
||||
|
||||
/** Get a single workflow by ID (returns draft if one exists, else published). */
|
||||
async getWorkflow(workflowId: number): Promise<WorkflowResponse> {
|
||||
return this.request<WorkflowResponse>("GET", `/workflow/fetch/${workflowId}`);
|
||||
}
|
||||
|
||||
/** List webhook credentials available to the authenticated organization. */
|
||||
async listCredentials(): Promise<CredentialResponse[]> {
|
||||
return this.request<CredentialResponse[]>("GET", "/credentials/");
|
||||
}
|
||||
|
||||
/** List knowledge base documents available to the authenticated organization. */
|
||||
async listDocuments(opts: { status?: string; limit?: number; offset?: number } = {}): Promise<DocumentListResponseSchema> {
|
||||
const params: Record<string, unknown> = {
|
||||
...(opts.status !== undefined ? { "status": opts.status } : {}),
|
||||
...(opts.limit !== undefined ? { "limit": opts.limit } : {}),
|
||||
...(opts.offset !== undefined ? { "offset": opts.offset } : {}),
|
||||
};
|
||||
return this.request<DocumentListResponseSchema>("GET", "/knowledge-base/documents", { params });
|
||||
}
|
||||
|
||||
/** List every registered node type with its spec. Pinned to spec_version. */
|
||||
async listNodeTypes(): Promise<NodeTypesResponse> {
|
||||
return this.request<NodeTypesResponse>("GET", "/node-types");
|
||||
}
|
||||
|
||||
/** List workflow recordings available to the authenticated organization. */
|
||||
async listRecordings(opts: { workflowId?: number; ttsProvider?: string; ttsModel?: string; ttsVoiceId?: string } = {}): Promise<RecordingListResponseSchema> {
|
||||
const params: Record<string, unknown> = {
|
||||
...(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<RecordingListResponseSchema>("GET", "/workflow-recordings/", { params });
|
||||
}
|
||||
|
||||
/** List tools available to the authenticated organization. */
|
||||
async listTools(opts: { status?: string; category?: string } = {}): Promise<ToolResponse[]> {
|
||||
const params: Record<string, unknown> = {
|
||||
...(opts.status !== undefined ? { "status": opts.status } : {}),
|
||||
...(opts.category !== undefined ? { "category": opts.category } : {}),
|
||||
};
|
||||
return this.request<ToolResponse[]>("GET", "/tools/", { params });
|
||||
}
|
||||
|
||||
/** List all workflows in the authenticated organization. */
|
||||
async listWorkflows(opts: { status?: string } = {}): Promise<WorkflowListResponse[]> {
|
||||
const params: Record<string, unknown> = {
|
||||
...(opts.status !== undefined ? { "status": opts.status } : {}),
|
||||
};
|
||||
return this.request<WorkflowListResponse[]>("GET", "/workflow/fetch", { params });
|
||||
}
|
||||
|
||||
/** Place a test call from a workflow to a phone number. */
|
||||
async testPhoneCall(opts: { body: InitiateCallRequest }): Promise<unknown> {
|
||||
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<WorkflowResponse> {
|
||||
return this.request<WorkflowResponse>("PUT", `/workflow/${workflowId}`, { json: opts.body });
|
||||
}
|
||||
}
|
||||
1165
sdk/typescript/src/_generated_models.ts
Normal file
1165
sdk/typescript/src/_generated_models.ts
Normal file
File diff suppressed because it is too large
Load diff
175
sdk/typescript/src/client.ts
Normal file
175
sdk/typescript/src/client.ts
Normal file
|
|
@ -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<string, string>;
|
||||
private readonly specCache = new Map<string, NodeSpec>();
|
||||
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<NodeTypesResponse> {
|
||||
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<NodeSpec> {
|
||||
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<Workflow> {
|
||||
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<typeof Workflow.fromJson>[0],
|
||||
{ client: this, name: resp.name ?? "" },
|
||||
);
|
||||
}
|
||||
|
||||
async saveWorkflow(workflowId: number, workflow: Workflow): Promise<WorkflowResponse> {
|
||||
const body: UpdateWorkflowRequest = {
|
||||
name: workflow.name,
|
||||
workflow_definition: workflow.toJson() as unknown as Record<string, unknown>,
|
||||
};
|
||||
return this.updateWorkflow(workflowId, { body });
|
||||
}
|
||||
|
||||
// ── low-level (overrides `_GeneratedClient.request`) ──────────────
|
||||
|
||||
protected async request<T = unknown>(
|
||||
method: string,
|
||||
path: string,
|
||||
opts?: { json?: unknown; params?: Record<string, unknown> },
|
||||
): Promise<T> {
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
sdk/typescript/src/errors.ts
Normal file
45
sdk/typescript/src/errors.ts
Normal file
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
59
sdk/typescript/src/index.ts
Normal file
59
sdk/typescript/src/index.ts
Normal file
|
|
@ -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";
|
||||
77
sdk/typescript/src/typed/agent-node.ts
Normal file
77
sdk/typescript/src/typed/agent-node.ts
Normal file
|
|
@ -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<AgentNodeExtraction_variablesRow>;
|
||||
/**
|
||||
* 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, "type">): AgentNode {
|
||||
return { type: "agentNode", ...input };
|
||||
}
|
||||
61
sdk/typescript/src/typed/end-call.ts
Normal file
61
sdk/typescript/src/typed/end-call.ts
Normal file
|
|
@ -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<EndCallExtraction_variablesRow>;
|
||||
}
|
||||
|
||||
/** Factory — sets `type` for you so you don't repeat the discriminator. */
|
||||
export function endCall(input: Omit<EndCall, "type">): EndCall {
|
||||
return { type: "endCall", ...input };
|
||||
}
|
||||
28
sdk/typescript/src/typed/global-node.ts
Normal file
28
sdk/typescript/src/typed/global-node.ts
Normal file
|
|
@ -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, "type">): GlobalNode {
|
||||
return { type: "globalNode", ...input };
|
||||
}
|
||||
25
sdk/typescript/src/typed/index.ts
Normal file
25
sdk/typescript/src/typed/index.ts
Normal file
|
|
@ -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;
|
||||
64
sdk/typescript/src/typed/qa.ts
Normal file
64
sdk/typescript/src/typed/qa.ts
Normal file
|
|
@ -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, "type">): Qa {
|
||||
return { type: "qa", ...input };
|
||||
}
|
||||
113
sdk/typescript/src/typed/start-call.ts
Normal file
113
sdk/typescript/src/typed/start-call.ts
Normal file
|
|
@ -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<StartCallExtraction_variablesRow>;
|
||||
/**
|
||||
* 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, "type">): StartCall {
|
||||
return { type: "startCall", ...input };
|
||||
}
|
||||
32
sdk/typescript/src/typed/trigger.ts
Normal file
32
sdk/typescript/src/typed/trigger.ts
Normal file
|
|
@ -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, "type">): Trigger {
|
||||
return { type: "trigger", ...input };
|
||||
}
|
||||
67
sdk/typescript/src/typed/webhook.ts
Normal file
67
sdk/typescript/src/typed/webhook.ts
Normal file
|
|
@ -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<WebhookCustom_headersRow>;
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
/**
|
||||
* Optional retry settings: `enabled` (bool), `max_retries` (int), `retry_delay_seconds` (int).
|
||||
*/
|
||||
retry_config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Factory — sets `type` for you so you don't repeat the discriminator. */
|
||||
export function webhook(input: Omit<Webhook, "type">): Webhook {
|
||||
return { type: "webhook", ...input };
|
||||
}
|
||||
101
sdk/typescript/src/types.ts
Normal file
101
sdk/typescript/src/types.ts
Normal file
|
|
@ -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<string, unknown[]> | null;
|
||||
hide?: Record<string, unknown[]> | 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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
}>;
|
||||
// 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<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WireEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WireWorkflow {
|
||||
nodes: WireNode[];
|
||||
edges: WireEdge[];
|
||||
viewport: { x: number; y: number; zoom: number };
|
||||
}
|
||||
157
sdk/typescript/src/validation.ts
Normal file
157
sdk/typescript/src/validation.ts
Normal file
|
|
@ -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<string, ReadonlyArray<string> | 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<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown>),
|
||||
);
|
||||
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;
|
||||
}
|
||||
194
sdk/typescript/src/workflow.ts
Normal file
194
sdk/typescript/src/workflow.ts
Normal file
|
|
@ -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<NodeSpec>;
|
||||
}
|
||||
|
||||
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<NodeRef> {
|
||||
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<T extends { type: string }>(
|
||||
node: T,
|
||||
opts?: { position?: [number, number] },
|
||||
): Promise<NodeRef> {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown>,
|
||||
opts: WorkflowOptions,
|
||||
): Promise<Workflow> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
423
sdk/typescript/tests/sdk.test.mts
Normal file
423
sdk/typescript/tests/sdk.test.mts
Normal file
|
|
@ -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<string, NodeSpec> = {
|
||||
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<NodeSpec> {
|
||||
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" });
|
||||
});
|
||||
});
|
||||
131
sdk/typescript/tests/typed.test.mts
Normal file
131
sdk/typescript/tests/typed.test.mts
Normal file
|
|
@ -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<string, NodeSpec> = {
|
||||
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<NodeSpec> {
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
20
sdk/typescript/tsconfig.json
Normal file
20
sdk/typescript/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue