tavus-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tavus_cli-0.1.0.dist-info/METADATA +192 -0
- tavus_cli-0.1.0.dist-info/RECORD +32 -0
- tavus_cli-0.1.0.dist-info/WHEEL +4 -0
- tavus_cli-0.1.0.dist-info/entry_points.txt +3 -0
- tavus_mcp/__init__.py +4 -0
- tavus_mcp/cli/__init__.py +1 -0
- tavus_mcp/cli/main.py +1467 -0
- tavus_mcp/sdk/__init__.py +6 -0
- tavus_mcp/sdk/auth/__init__.py +1 -0
- tavus_mcp/sdk/auth/keyring_store.py +20 -0
- tavus_mcp/sdk/auth/oauth.py +131 -0
- tavus_mcp/sdk/auth/session.py +46 -0
- tavus_mcp/sdk/client/__init__.py +3 -0
- tavus_mcp/sdk/client/http.py +451 -0
- tavus_mcp/sdk/env.py +126 -0
- tavus_mcp/sdk/errors.py +46 -0
- tavus_mcp/sdk/patch.py +92 -0
- tavus_mcp/sdk/recipes/__init__.py +6 -0
- tavus_mcp/sdk/recipes/build_and_verify.py +618 -0
- tavus_mcp/sdk/recipes/options.py +67 -0
- tavus_mcp/sdk/recipes/quickstart.py +48 -0
- tavus_mcp/sdk/recipes/scaffold_embed.py +115 -0
- tavus_mcp/sdk/recipes/templates.py +58 -0
- tavus_mcp/sdk/recipes/tool_reuse.py +124 -0
- tavus_mcp/sdk/schemas/__init__.py +16 -0
- tavus_mcp/sdk/schemas/file_manifest.py +21 -0
- tavus_mcp/sdk/schemas/guardrail.py +84 -0
- tavus_mcp/sdk/schemas/objective.py +131 -0
- tavus_mcp/sdk/schemas/persona.py +174 -0
- tavus_mcp/sdk/schemas/pronunciation.py +73 -0
- tavus_mcp/sdk/schemas/tool.py +349 -0
- tavus_mcp/server.py +877 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from tavus_mcp.sdk.client.http import TavusClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def quickstart(
|
|
9
|
+
client: TavusClient,
|
|
10
|
+
*,
|
|
11
|
+
system_prompt: str,
|
|
12
|
+
persona_name: str = "Agentic Tavus Persona",
|
|
13
|
+
replica_id: str | None = None,
|
|
14
|
+
conversation_name: str | None = None,
|
|
15
|
+
) -> dict[str, Any]:
|
|
16
|
+
selected_replica_id = replica_id or await _first_stock_replica_id(client)
|
|
17
|
+
persona = await client.personas.create(
|
|
18
|
+
{
|
|
19
|
+
"persona_name": persona_name,
|
|
20
|
+
"system_prompt": system_prompt,
|
|
21
|
+
"default_replica_id": selected_replica_id,
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
persona_id = persona["persona_id"]
|
|
25
|
+
conversation = await client.conversations.create(
|
|
26
|
+
{
|
|
27
|
+
"persona_id": persona_id,
|
|
28
|
+
"replica_id": selected_replica_id,
|
|
29
|
+
"conversation_name": conversation_name or persona_name,
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
return {
|
|
33
|
+
"persona": persona,
|
|
34
|
+
"conversation": conversation,
|
|
35
|
+
"replica_id": selected_replica_id,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _first_stock_replica_id(client: TavusClient) -> str:
|
|
40
|
+
replicas = await client.replicas.list(limit=25, replica_type="system")
|
|
41
|
+
data = replicas.get("data", replicas) if isinstance(replicas, dict) else replicas
|
|
42
|
+
if not data:
|
|
43
|
+
raise ValueError("No stock replicas were returned by the selected Tavus environment.")
|
|
44
|
+
first = data[0]
|
|
45
|
+
replica_id = first.get("replica_id") or first.get("replica_uuid") or first.get("id")
|
|
46
|
+
if not replica_id:
|
|
47
|
+
raise ValueError("Stock replica response did not include a replica id.")
|
|
48
|
+
return str(replica_id)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from tavus_mcp.sdk.schemas.file_manifest import FileEntry, FileManifest
|
|
6
|
+
|
|
7
|
+
EmbedTarget = Literal["iframe", "cvi-ui", "vanilla"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
IFRAME_ALLOW = "camera; microphone; fullscreen; display-capture; autoplay"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def scaffold_embed(
|
|
14
|
+
*,
|
|
15
|
+
conversation_url: str,
|
|
16
|
+
target: EmbedTarget = "iframe",
|
|
17
|
+
component_name: str = "TavusConversation",
|
|
18
|
+
) -> FileManifest:
|
|
19
|
+
if target == "iframe":
|
|
20
|
+
return _iframe(conversation_url)
|
|
21
|
+
if target == "cvi-ui":
|
|
22
|
+
return _cvi_ui(conversation_url, component_name)
|
|
23
|
+
if target == "vanilla":
|
|
24
|
+
return _vanilla(conversation_url)
|
|
25
|
+
raise ValueError(f"Unsupported embed target: {target}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _iframe(conversation_url: str) -> FileManifest:
|
|
29
|
+
return FileManifest(
|
|
30
|
+
files=[
|
|
31
|
+
FileEntry(
|
|
32
|
+
path="tavus-embed.html",
|
|
33
|
+
description="Standalone Tavus iframe embed.",
|
|
34
|
+
content=f"""<iframe
|
|
35
|
+
src="{conversation_url}"
|
|
36
|
+
allow="{IFRAME_ALLOW}"
|
|
37
|
+
allowfullscreen
|
|
38
|
+
style="width: 100%; height: 720px; border: 0;"
|
|
39
|
+
></iframe>
|
|
40
|
+
""",
|
|
41
|
+
)
|
|
42
|
+
],
|
|
43
|
+
notes=["The iframe allow attribute includes display-capture for screen share support."],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _cvi_ui(conversation_url: str, component_name: str) -> FileManifest:
|
|
48
|
+
return FileManifest(
|
|
49
|
+
files=[
|
|
50
|
+
FileEntry(
|
|
51
|
+
path=f"src/components/{component_name}.tsx",
|
|
52
|
+
description=(
|
|
53
|
+
"@tavus/cvi-ui React component. Install and initialize "
|
|
54
|
+
"@tavus/cvi-ui first."
|
|
55
|
+
),
|
|
56
|
+
content=f"""import {{ useCallback }} from 'react';
|
|
57
|
+
|
|
58
|
+
const CONVERSATION_URL = {conversation_url!r};
|
|
59
|
+
|
|
60
|
+
export function {component_name}() {{
|
|
61
|
+
const start = useCallback(() => {{
|
|
62
|
+
window.open(CONVERSATION_URL, '_blank', 'noopener,noreferrer');
|
|
63
|
+
}}, []);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<button type="button" onClick={{start}}>
|
|
67
|
+
Start Tavus Conversation
|
|
68
|
+
</button>
|
|
69
|
+
);
|
|
70
|
+
}}
|
|
71
|
+
""",
|
|
72
|
+
)
|
|
73
|
+
],
|
|
74
|
+
notes=[
|
|
75
|
+
"This starter preserves the mobile Safari user-gesture chain by joining from a click.",
|
|
76
|
+
(
|
|
77
|
+
"For a full in-app experience, run `npx @tavus/cvi-ui@latest init` "
|
|
78
|
+
"and wire the copied components."
|
|
79
|
+
),
|
|
80
|
+
],
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _vanilla(conversation_url: str) -> FileManifest:
|
|
85
|
+
return FileManifest(
|
|
86
|
+
files=[
|
|
87
|
+
FileEntry(
|
|
88
|
+
path="tavus-vanilla.html",
|
|
89
|
+
description="Vanilla Daily iframe embed.",
|
|
90
|
+
content=f"""<!doctype html>
|
|
91
|
+
<html lang="en">
|
|
92
|
+
<head>
|
|
93
|
+
<meta charset="utf-8" />
|
|
94
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
95
|
+
<title>Tavus Conversation</title>
|
|
96
|
+
<script crossorigin src="https://unpkg.com/@daily-co/daily-js"></script>
|
|
97
|
+
</head>
|
|
98
|
+
<body>
|
|
99
|
+
<button id="start">Start</button>
|
|
100
|
+
<div id="call" style="width: 100%; height: 720px;"></div>
|
|
101
|
+
<script>
|
|
102
|
+
const frame = window.DailyIframe.createFrame(document.getElementById('call'), {{
|
|
103
|
+
iframeStyle: {{ width: '100%', height: '100%', border: '0' }}
|
|
104
|
+
}});
|
|
105
|
+
document.getElementById('start').addEventListener('click', () => {{
|
|
106
|
+
frame.join({{ url: {conversation_url!r} }});
|
|
107
|
+
}});
|
|
108
|
+
</script>
|
|
109
|
+
</body>
|
|
110
|
+
</html>
|
|
111
|
+
""",
|
|
112
|
+
)
|
|
113
|
+
],
|
|
114
|
+
notes=["Join is bound to a click to preserve browser media permissions."],
|
|
115
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from tavus_mcp.sdk.client.http import TavusClient
|
|
6
|
+
|
|
7
|
+
PersonaTemplate = Literal["customer-support", "interviewer", "sales", "tutor", "dev-rel"]
|
|
8
|
+
|
|
9
|
+
TEMPLATES: dict[str, str] = {
|
|
10
|
+
"customer-support": (
|
|
11
|
+
"You are a warm, concise customer support specialist. Resolve the user's issue, "
|
|
12
|
+
"ask focused follow-up questions when details are missing, and avoid overpromising."
|
|
13
|
+
),
|
|
14
|
+
"interviewer": (
|
|
15
|
+
"You are a structured interviewer. Ask one question at a time, listen carefully, "
|
|
16
|
+
"probe for concrete examples, and keep the conversation professional."
|
|
17
|
+
),
|
|
18
|
+
"sales": (
|
|
19
|
+
"You are a consultative sales assistant. Discover the user's needs, qualify fit, "
|
|
20
|
+
"explain value in practical terms, and never pressure the user."
|
|
21
|
+
),
|
|
22
|
+
"tutor": (
|
|
23
|
+
"You are a patient tutor. Explain concepts step by step, check understanding, "
|
|
24
|
+
"adapt to the learner's pace, and use examples before abstractions."
|
|
25
|
+
),
|
|
26
|
+
"dev-rel": (
|
|
27
|
+
"You are a developer relations guide. Help developers build successfully, give "
|
|
28
|
+
"precise technical answers, and include implementation tradeoffs when relevant."
|
|
29
|
+
),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def persona_from_template(
|
|
34
|
+
client: TavusClient,
|
|
35
|
+
*,
|
|
36
|
+
template: PersonaTemplate,
|
|
37
|
+
persona_name: str | None = None,
|
|
38
|
+
business_context: str | None = None,
|
|
39
|
+
default_replica_id: str | None = None,
|
|
40
|
+
layers: dict[str, Any] | None = None,
|
|
41
|
+
) -> Any:
|
|
42
|
+
prompt = TEMPLATES[template]
|
|
43
|
+
if business_context:
|
|
44
|
+
prompt = f"{prompt}\n\nBusiness context:\n{business_context.strip()}"
|
|
45
|
+
|
|
46
|
+
body: dict[str, Any] = {
|
|
47
|
+
"persona_name": persona_name or _default_name(template),
|
|
48
|
+
"system_prompt": prompt,
|
|
49
|
+
}
|
|
50
|
+
if default_replica_id:
|
|
51
|
+
body["default_replica_id"] = default_replica_id
|
|
52
|
+
if layers:
|
|
53
|
+
body["layers"] = layers
|
|
54
|
+
return await client.personas.create(body)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _default_name(template: str) -> str:
|
|
58
|
+
return template.replace("-", " ").title()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
# Leaf path segment -> tool origin, for inline tool arrays on the persona body.
|
|
7
|
+
_ARRAY_LEAF_ORIGIN = {
|
|
8
|
+
"visual_tools": "vision",
|
|
9
|
+
"perception_tools": "vision",
|
|
10
|
+
"audio_tools": "audio",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
_ADVISORY_FIELDS = ("tool_id", "name", "origin", "description", "parameters", "delivery")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def collect_inline_tools(ops: list[dict[str, Any]]) -> list[dict[str, str]]:
|
|
17
|
+
"""Detect inline tool definitions written by JSON-Patch ops.
|
|
18
|
+
|
|
19
|
+
Returns ``[{"name", "origin"}, ...]`` for every inline tool the ops add or
|
|
20
|
+
replace. Handles array writes (``.../visual_tools``, ``.../audio_tools``,
|
|
21
|
+
``.../perception_tools``, ``/layers/llm/tools``), single appends
|
|
22
|
+
(``.../-`` or ``.../<index>``), and object writes (``/layers``,
|
|
23
|
+
``/layers/perception``, ``/layers/llm``).
|
|
24
|
+
"""
|
|
25
|
+
found: list[dict[str, str]] = []
|
|
26
|
+
seen: set[tuple[str, str]] = set()
|
|
27
|
+
|
|
28
|
+
def add(origin: str, tool: Any) -> None:
|
|
29
|
+
if not isinstance(tool, dict):
|
|
30
|
+
return
|
|
31
|
+
fn = tool.get("function") if isinstance(tool.get("function"), dict) else tool
|
|
32
|
+
name = fn.get("name") if isinstance(fn, dict) else None
|
|
33
|
+
key = (origin, str(name))
|
|
34
|
+
if key in seen:
|
|
35
|
+
return
|
|
36
|
+
seen.add(key)
|
|
37
|
+
found.append({"name": str(name) if name else "", "origin": origin})
|
|
38
|
+
|
|
39
|
+
def add_many(origin: str, value: Any) -> None:
|
|
40
|
+
if isinstance(value, list):
|
|
41
|
+
for tool in value:
|
|
42
|
+
add(origin, tool)
|
|
43
|
+
elif isinstance(value, dict):
|
|
44
|
+
add(origin, value)
|
|
45
|
+
|
|
46
|
+
for op in ops:
|
|
47
|
+
if not isinstance(op, dict) or op.get("op") not in {"add", "replace"}:
|
|
48
|
+
continue
|
|
49
|
+
value = op.get("value")
|
|
50
|
+
segs = [s for s in str(op.get("path", "")).split("/") if s]
|
|
51
|
+
# Drop a trailing append marker / array index so the leaf is the field.
|
|
52
|
+
leaf_segs = [s for s in segs if not (s == "-" or s.isdigit())]
|
|
53
|
+
leaf = leaf_segs[-1] if leaf_segs else ""
|
|
54
|
+
|
|
55
|
+
if leaf in _ARRAY_LEAF_ORIGIN:
|
|
56
|
+
add_many(_ARRAY_LEAF_ORIGIN[leaf], value)
|
|
57
|
+
elif leaf == "tools" and "llm" in leaf_segs:
|
|
58
|
+
add_many("llm", value)
|
|
59
|
+
elif isinstance(value, dict):
|
|
60
|
+
_collect_from_object(leaf, value, add_many)
|
|
61
|
+
|
|
62
|
+
return found
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _collect_from_object(
|
|
66
|
+
leaf: str, value: dict[str, Any], add_many: Callable[[str, Any], None]
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Pull tool arrays out of an object-valued op (a perception layer, an llm
|
|
69
|
+
layer, or a whole ``/layers`` object)."""
|
|
70
|
+
perception = value if leaf == "perception" else value.get("perception")
|
|
71
|
+
if isinstance(perception, dict):
|
|
72
|
+
add_many("vision", perception.get("visual_tools"))
|
|
73
|
+
add_many("vision", perception.get("perception_tools"))
|
|
74
|
+
add_many("audio", perception.get("audio_tools"))
|
|
75
|
+
|
|
76
|
+
llm = value if leaf == "llm" else value.get("llm")
|
|
77
|
+
if isinstance(llm, dict):
|
|
78
|
+
add_many("llm", llm.get("tools"))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_inline_deprecation_notice(inline: list[dict[str, str]]) -> dict[str, Any]:
|
|
82
|
+
"""Advisory steering a persona patch that wrote inline tools toward the
|
|
83
|
+
first-class create + attach flow."""
|
|
84
|
+
return {
|
|
85
|
+
"inline_tools_written": inline,
|
|
86
|
+
"guidance": (
|
|
87
|
+
"Inline tools (layers.*.tools) are deprecated. Tools are first-class "
|
|
88
|
+
"objects now: create one with tavus_tool_create, then attach it with "
|
|
89
|
+
"tavus_persona_tools_attach (which also flips perception_model to "
|
|
90
|
+
"raven-1 for vision/audio tools). Check tavus_tool_list first so you "
|
|
91
|
+
"reuse an existing tool instead of duplicating it."
|
|
92
|
+
),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_library_match_advisory(
|
|
97
|
+
origin: str, name: str, saved_tools: list[dict[str, Any]]
|
|
98
|
+
) -> dict[str, Any] | None:
|
|
99
|
+
"""Surface saved tools of the same origin as a freshly created tool, so the
|
|
100
|
+
agent reuses instead of duplicating. Returns ``None`` when nothing of that
|
|
101
|
+
origin exists (no advisory noise)."""
|
|
102
|
+
same_origin = [
|
|
103
|
+
tool for tool in saved_tools if isinstance(tool, dict) and tool.get("origin") == origin
|
|
104
|
+
]
|
|
105
|
+
if not same_origin:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
collision = next((tool for tool in same_origin if tool.get("name") == name), None)
|
|
109
|
+
return {
|
|
110
|
+
"new_tool": {"name": name, "origin": origin},
|
|
111
|
+
"name_collision_tool_id": collision.get("tool_id") if collision else None,
|
|
112
|
+
"matching_saved_tools": [_slim(tool) for tool in same_origin],
|
|
113
|
+
"guidance": (
|
|
114
|
+
f"Your account already has {len(same_origin)} saved {origin} tool(s) "
|
|
115
|
+
"(see matching_saved_tools). If one fits this need, do NOT duplicate it "
|
|
116
|
+
"— attach it with tavus_persona_tools_attach instead. If you keep this "
|
|
117
|
+
"new tool, tell the user which existing tool you saw and why its shape "
|
|
118
|
+
"(parameters / origin / delivery) does not fit."
|
|
119
|
+
),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _slim(tool: dict[str, Any]) -> dict[str, Any]:
|
|
124
|
+
return {field: tool.get(field) for field in _ADVISORY_FIELDS if field in tool}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from tavus_mcp.sdk.schemas.file_manifest import FileEntry, FileManifest
|
|
2
|
+
from tavus_mcp.sdk.schemas.persona import (
|
|
3
|
+
JSONPatchOperation,
|
|
4
|
+
LayersPatchModel,
|
|
5
|
+
PersonaCreate,
|
|
6
|
+
PersonaPatchModel,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"FileEntry",
|
|
11
|
+
"FileManifest",
|
|
12
|
+
"JSONPatchOperation",
|
|
13
|
+
"LayersPatchModel",
|
|
14
|
+
"PersonaCreate",
|
|
15
|
+
"PersonaPatchModel",
|
|
16
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FileEntry(BaseModel):
|
|
9
|
+
model_config = ConfigDict(extra="forbid")
|
|
10
|
+
|
|
11
|
+
path: str
|
|
12
|
+
content: str
|
|
13
|
+
mode: Literal["create", "overwrite", "append"] = "create"
|
|
14
|
+
description: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FileManifest(BaseModel):
|
|
18
|
+
model_config = ConfigDict(extra="forbid")
|
|
19
|
+
|
|
20
|
+
files: list[FileEntry] = Field(default_factory=list)
|
|
21
|
+
notes: list[str] = Field(default_factory=list)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
7
|
+
|
|
8
|
+
GuardrailType = Literal["user", "system", "all"]
|
|
9
|
+
GuardrailModality = Literal["verbal", "visual", "audio"]
|
|
10
|
+
GuardrailListLegacy = Literal["true", "false"]
|
|
11
|
+
GuardrailSort = Literal["ascending", "descending"]
|
|
12
|
+
|
|
13
|
+
MAX_GUARDRAILS_NAME_LENGTH = 100
|
|
14
|
+
MAX_GUARDRAIL_TAGS_PER_ITEM = 32
|
|
15
|
+
|
|
16
|
+
_TAG_NAME_RE = re.compile(r"^[A-Za-z0-9_\- ]+$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GuardrailCreate(BaseModel):
|
|
20
|
+
"""Flat-list (new) guardrail shape — what `POST /v2/guardrails` accepts
|
|
21
|
+
when the payload has a top-level ``guardrail_name``. Legacy set creation
|
|
22
|
+
(``{name, data: [...]}``) is intentionally not exposed here; the user
|
|
23
|
+
explicitly removed sets in favor of flat guardrails."""
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(extra="forbid")
|
|
26
|
+
|
|
27
|
+
guardrail_name: str = Field(..., min_length=1)
|
|
28
|
+
guardrail_prompt: str = Field(..., min_length=1)
|
|
29
|
+
modality: GuardrailModality = "verbal"
|
|
30
|
+
callback_url: str = ""
|
|
31
|
+
tool_call: dict[str, Any] | None = None
|
|
32
|
+
app_message: bool = True
|
|
33
|
+
tags: list[str] = Field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
@model_validator(mode="after")
|
|
36
|
+
def _check_tags(self) -> GuardrailCreate:
|
|
37
|
+
if len(self.tags) > MAX_GUARDRAIL_TAGS_PER_ITEM:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"guardrail can carry at most {MAX_GUARDRAIL_TAGS_PER_ITEM} tags"
|
|
40
|
+
)
|
|
41
|
+
for tag in self.tags:
|
|
42
|
+
if not isinstance(tag, str) or not tag:
|
|
43
|
+
raise ValueError("tags must be non-empty strings")
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class GuardrailUpdate(BaseModel):
|
|
48
|
+
"""Patch shape — every field optional. RQH's PATCH validator round-trips
|
|
49
|
+
the persisted dict, so persisted fields (uuid, owner_id, timestamps) are
|
|
50
|
+
accepted as no-ops; we just don't expose them as inputs."""
|
|
51
|
+
|
|
52
|
+
model_config = ConfigDict(extra="forbid")
|
|
53
|
+
|
|
54
|
+
guardrail_name: str | None = None
|
|
55
|
+
guardrail_prompt: str | None = None
|
|
56
|
+
modality: GuardrailModality | None = None
|
|
57
|
+
callback_url: str | None = None
|
|
58
|
+
tool_call: dict[str, Any] | None = None
|
|
59
|
+
app_message: bool | None = None
|
|
60
|
+
tags: list[str] | None = None
|
|
61
|
+
|
|
62
|
+
@model_validator(mode="after")
|
|
63
|
+
def _check_tags(self) -> GuardrailUpdate:
|
|
64
|
+
if self.tags is not None and len(self.tags) > MAX_GUARDRAIL_TAGS_PER_ITEM:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"guardrail can carry at most {MAX_GUARDRAIL_TAGS_PER_ITEM} tags"
|
|
67
|
+
)
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class GuardrailListParams(BaseModel):
|
|
72
|
+
"""Query string for `GET /v2/guardrails`. Defaults mirror RQH so callers
|
|
73
|
+
that omit a param see what RQH would have defaulted them to."""
|
|
74
|
+
|
|
75
|
+
model_config = ConfigDict(extra="forbid")
|
|
76
|
+
|
|
77
|
+
limit: int = 10
|
|
78
|
+
page: int = 1
|
|
79
|
+
name_or_uuid: str = ""
|
|
80
|
+
type: GuardrailType = "user"
|
|
81
|
+
sort: GuardrailSort = "ascending"
|
|
82
|
+
legacy: GuardrailListLegacy = "false"
|
|
83
|
+
tags: str | None = None
|
|
84
|
+
verbose: bool | None = None
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
7
|
+
|
|
8
|
+
ObjectiveType = Literal["user", "system", "all"]
|
|
9
|
+
ConfirmationMode = Literal["manual", "auto"]
|
|
10
|
+
ObjectiveModality = Literal["verbal", "visual", "audio"]
|
|
11
|
+
|
|
12
|
+
MAX_OBJECTIVE_NAME_LEN = 100
|
|
13
|
+
MAX_OBJECTIVE_PROMPT_LEN = 10000
|
|
14
|
+
MAX_OBJECTIVE_CALLBACK_URL_LEN = 2048
|
|
15
|
+
MAX_OBJECTIVE_OUTPUT_VARIABLES = 50
|
|
16
|
+
MAX_OBJECTIVES_NAME_LEN = 100
|
|
17
|
+
MAX_OBJECTIVES_COUNT = 50
|
|
18
|
+
|
|
19
|
+
_OBJECTIVE_NAME_RE = re.compile(r"^[a-zA-Z0-9_]+$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ObjectiveItem(BaseModel):
|
|
23
|
+
"""One step in an objective chain. Names are referenced by other items
|
|
24
|
+
via ``next_required_objective`` / ``next_conditional_objectives``."""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(extra="forbid")
|
|
27
|
+
|
|
28
|
+
objective_name: str
|
|
29
|
+
objective_prompt: str = Field(..., max_length=MAX_OBJECTIVE_PROMPT_LEN)
|
|
30
|
+
confirmation_mode: ConfirmationMode = "auto"
|
|
31
|
+
output_variables: list[str] = Field(default_factory=list)
|
|
32
|
+
modality: ObjectiveModality = "verbal"
|
|
33
|
+
next_conditional_objectives: dict[str, str] | None = None
|
|
34
|
+
next_required_objective: str | None = None
|
|
35
|
+
callback_url: str = ""
|
|
36
|
+
tool_call: dict[str, Any] | None = None
|
|
37
|
+
|
|
38
|
+
@model_validator(mode="after")
|
|
39
|
+
def _check(self) -> ObjectiveItem:
|
|
40
|
+
if not _OBJECTIVE_NAME_RE.match(self.objective_name):
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"objective_name must contain only letters, numbers, and underscores"
|
|
43
|
+
)
|
|
44
|
+
if len(self.objective_name) > MAX_OBJECTIVE_NAME_LEN:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"objective_name must be at most {MAX_OBJECTIVE_NAME_LEN} characters"
|
|
47
|
+
)
|
|
48
|
+
if len(self.callback_url) > MAX_OBJECTIVE_CALLBACK_URL_LEN:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"callback_url must be at most {MAX_OBJECTIVE_CALLBACK_URL_LEN} characters"
|
|
51
|
+
)
|
|
52
|
+
if len(self.output_variables) > MAX_OBJECTIVE_OUTPUT_VARIABLES:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"output_variables must have at most {MAX_OBJECTIVE_OUTPUT_VARIABLES} items"
|
|
55
|
+
)
|
|
56
|
+
if (
|
|
57
|
+
self.next_required_objective
|
|
58
|
+
and self.next_required_objective == self.objective_name
|
|
59
|
+
):
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"objective '{self.objective_name}' cannot reference "
|
|
62
|
+
"itself via next_required_objective"
|
|
63
|
+
)
|
|
64
|
+
if (
|
|
65
|
+
self.next_conditional_objectives
|
|
66
|
+
and self.objective_name in self.next_conditional_objectives
|
|
67
|
+
):
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"objective '{self.objective_name}' cannot reference "
|
|
70
|
+
"itself via next_conditional_objectives"
|
|
71
|
+
)
|
|
72
|
+
if self.next_required_objective and self.next_conditional_objectives:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
"an objective may have either next_required_objective or "
|
|
75
|
+
"next_conditional_objectives, not both"
|
|
76
|
+
)
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ObjectivesCreate(BaseModel):
|
|
81
|
+
"""Set-shape body for `POST /v2/objectives/`. Objectives are still
|
|
82
|
+
grouped into a named set (unlike guardrails, which went flat)."""
|
|
83
|
+
|
|
84
|
+
model_config = ConfigDict(extra="forbid")
|
|
85
|
+
|
|
86
|
+
name: str = ""
|
|
87
|
+
data: list[ObjectiveItem] = Field(..., min_length=1)
|
|
88
|
+
allow_loops: bool = False
|
|
89
|
+
|
|
90
|
+
@model_validator(mode="after")
|
|
91
|
+
def _check(self) -> ObjectivesCreate:
|
|
92
|
+
if len(self.name) > MAX_OBJECTIVES_NAME_LEN:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"objectives set name must be at most {MAX_OBJECTIVES_NAME_LEN} characters"
|
|
95
|
+
)
|
|
96
|
+
if len(self.data) > MAX_OBJECTIVES_COUNT:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"objectives list must have at most {MAX_OBJECTIVES_COUNT} items"
|
|
99
|
+
)
|
|
100
|
+
names = [item.objective_name for item in self.data]
|
|
101
|
+
if len(names) != len(set(names)):
|
|
102
|
+
duplicates = sorted({n for n in names if names.count(n) > 1})
|
|
103
|
+
raise ValueError(f"duplicate objective_name(s) in set: {duplicates}")
|
|
104
|
+
# Cross-item reference check — every referenced name must exist.
|
|
105
|
+
known = set(names)
|
|
106
|
+
for item in self.data:
|
|
107
|
+
if (
|
|
108
|
+
item.next_required_objective
|
|
109
|
+
and item.next_required_objective not in known
|
|
110
|
+
):
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"objective '{item.objective_name}' references unknown "
|
|
113
|
+
f"next_required_objective '{item.next_required_objective}'"
|
|
114
|
+
)
|
|
115
|
+
for ref in (item.next_conditional_objectives or {}).keys():
|
|
116
|
+
if ref not in known:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"objective '{item.objective_name}' references unknown "
|
|
119
|
+
f"conditional outcome '{ref}'"
|
|
120
|
+
)
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ObjectivesListParams(BaseModel):
|
|
125
|
+
model_config = ConfigDict(extra="forbid")
|
|
126
|
+
|
|
127
|
+
limit: int = 25
|
|
128
|
+
page: int = 1
|
|
129
|
+
type: ObjectiveType = "user"
|
|
130
|
+
sort: Literal["ascending", "descending"] = "ascending"
|
|
131
|
+
name_or_uuid: str | None = None
|