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,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
6
|
+
|
|
7
|
+
PipelineMode = Literal["full", "speech-to-speech", "echo"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LLMLayerPatchModel(BaseModel):
|
|
11
|
+
model_config = ConfigDict(extra="forbid")
|
|
12
|
+
|
|
13
|
+
tools: list[dict[str, Any]] | None = None
|
|
14
|
+
headers: dict[str, Any] | None = None
|
|
15
|
+
extra_body: dict[str, Any] | None = None
|
|
16
|
+
default_query: dict[str, Any] | None = None
|
|
17
|
+
base_url: str | None = None
|
|
18
|
+
api_key: str | None = None
|
|
19
|
+
model: str | None = None
|
|
20
|
+
disable_vision: bool | None = None
|
|
21
|
+
speculative_inference: bool | None = None
|
|
22
|
+
enable_llm_proxy: bool | None = None
|
|
23
|
+
model_name: str | None = None
|
|
24
|
+
is_custom: bool | None = None
|
|
25
|
+
endpoint: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TTSLayerPatchModel(BaseModel):
|
|
29
|
+
model_config = ConfigDict(extra="forbid")
|
|
30
|
+
|
|
31
|
+
voice_settings: dict[str, Any] | None = None
|
|
32
|
+
external_voice_id: str | None = None
|
|
33
|
+
tts_engine: str | None = None
|
|
34
|
+
api_key: str | None = None
|
|
35
|
+
playht_user_id: str | None = None
|
|
36
|
+
tts_emotion_control: bool | None = None
|
|
37
|
+
tts_model_name: str | None = None
|
|
38
|
+
pronunciation_dictionary_id: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class STTLayerPatchModel(BaseModel):
|
|
42
|
+
model_config = ConfigDict(extra="forbid")
|
|
43
|
+
|
|
44
|
+
stt_engine: str | None = None
|
|
45
|
+
participant_pause_sensitivity: str | None = None
|
|
46
|
+
participant_interrupt_sensitivity: str | None = None
|
|
47
|
+
smart_turn_detection: bool | None = None
|
|
48
|
+
hotwords: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PerceptionLayerPatchModel(BaseModel):
|
|
52
|
+
model_config = ConfigDict(extra="forbid")
|
|
53
|
+
|
|
54
|
+
perception_model: str | None = None
|
|
55
|
+
visual_tools: list[dict[str, Any]] | None = None
|
|
56
|
+
visual_awareness_queries: list[str] | None = None
|
|
57
|
+
visual_tool_prompt: str | None = None
|
|
58
|
+
audio_tools: list[dict[str, Any]] | None = None
|
|
59
|
+
audio_awareness_queries: list[str] | None = None
|
|
60
|
+
audio_tool_prompt: str | None = None
|
|
61
|
+
perception_analysis_queries: list[str] | None = None
|
|
62
|
+
perception_tools: list[dict[str, Any]] | None = None
|
|
63
|
+
ambient_awareness_queries: list[str] | None = None
|
|
64
|
+
perception_tool_prompt: str | None = None
|
|
65
|
+
tool_prompt: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ConversationalFlowLayerPatchModel(BaseModel):
|
|
69
|
+
model_config = ConfigDict(extra="forbid")
|
|
70
|
+
|
|
71
|
+
turn_detection_model: str | None = None
|
|
72
|
+
turn_taking_patience: str | None = None
|
|
73
|
+
turn_commitment: str | None = None
|
|
74
|
+
replica_interruptibility: str | None = None
|
|
75
|
+
active_listening: str | None = None
|
|
76
|
+
voice_isolation: str | None = None
|
|
77
|
+
noise_cancellation: str | None = None
|
|
78
|
+
wake_phrase: str | None = None
|
|
79
|
+
idle_engagement: str | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CapabilitiesLayerPatchModel(BaseModel):
|
|
83
|
+
model_config = ConfigDict(extra="forbid")
|
|
84
|
+
|
|
85
|
+
mcp_enabled: bool | None = None
|
|
86
|
+
search_enabled: bool | None = None
|
|
87
|
+
activity_log_enabled: bool | None = None
|
|
88
|
+
search_results_prompt_injection_prevention_enabled: bool | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class KnowledgeBaseLayerPatchModel(BaseModel):
|
|
92
|
+
model_config = ConfigDict(extra="forbid")
|
|
93
|
+
|
|
94
|
+
rag_score_threshold: float | None = None
|
|
95
|
+
rag_n_chunks: int | None = None
|
|
96
|
+
rag_surrounding_chunk_radius: int | None = None
|
|
97
|
+
enable_rag_observability_app_messages: bool | None = None
|
|
98
|
+
use_procedure_goalchain: bool | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class LayersPatchModel(BaseModel):
|
|
102
|
+
model_config = ConfigDict(extra="forbid")
|
|
103
|
+
|
|
104
|
+
llm: LLMLayerPatchModel | None = None
|
|
105
|
+
tts: TTSLayerPatchModel | None = None
|
|
106
|
+
stt: STTLayerPatchModel | None = None
|
|
107
|
+
sts: dict[str, Any] | None = None
|
|
108
|
+
transport: dict[str, Any] | None = None
|
|
109
|
+
perception: PerceptionLayerPatchModel | None = None
|
|
110
|
+
conversational_flow: ConversationalFlowLayerPatchModel | None = None
|
|
111
|
+
capabilities: CapabilitiesLayerPatchModel | None = None
|
|
112
|
+
knowledge_base: KnowledgeBaseLayerPatchModel | None = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PersonaCreate(BaseModel):
|
|
116
|
+
model_config = ConfigDict(extra="forbid")
|
|
117
|
+
|
|
118
|
+
system_prompt: str | None = None
|
|
119
|
+
greeting: str | None = None
|
|
120
|
+
pipeline_mode: PipelineMode = "full"
|
|
121
|
+
context: str | None = None
|
|
122
|
+
persona_name: str | None = None
|
|
123
|
+
layers: LayersPatchModel | None = None
|
|
124
|
+
memories: list[str] | None = None
|
|
125
|
+
default_replica_id: str | None = None
|
|
126
|
+
objectives_id: str | None = None
|
|
127
|
+
guardrails_id: str | None = None
|
|
128
|
+
guardrail_ids: list[str] | None = None
|
|
129
|
+
guardrail_tags: list[str] | None = None
|
|
130
|
+
document_ids: list[str] | None = None
|
|
131
|
+
document_tags: list[str] | None = None
|
|
132
|
+
is_template: bool | None = None
|
|
133
|
+
|
|
134
|
+
@model_validator(mode="after")
|
|
135
|
+
def validate_pipeline_mode(self) -> PersonaCreate:
|
|
136
|
+
if self.pipeline_mode == "full" and not self.system_prompt:
|
|
137
|
+
raise ValueError("system_prompt is required for pipeline_mode='full'")
|
|
138
|
+
if self.pipeline_mode in {"speech-to-speech", "echo"}:
|
|
139
|
+
if self.system_prompt:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"system_prompt is not allowed for pipeline_mode={self.pipeline_mode!r}"
|
|
142
|
+
)
|
|
143
|
+
if self.context:
|
|
144
|
+
raise ValueError(f"context is not allowed for pipeline_mode={self.pipeline_mode!r}")
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class PersonaPatchModel(PersonaCreate):
|
|
149
|
+
model_config = ConfigDict(extra="forbid")
|
|
150
|
+
|
|
151
|
+
uuid: str | None = None
|
|
152
|
+
owner_id: int | None = None
|
|
153
|
+
created_at: str | None = None
|
|
154
|
+
updated_at: str | None = None
|
|
155
|
+
session_id: str | None = None
|
|
156
|
+
is_draft: bool | None = None
|
|
157
|
+
source_template_id: str | None = None
|
|
158
|
+
registration_id: int | None = None
|
|
159
|
+
persona_settings_id: int | None = None
|
|
160
|
+
|
|
161
|
+
@model_validator(mode="after")
|
|
162
|
+
def validate_pipeline_mode(self) -> PersonaPatchModel:
|
|
163
|
+
if self.pipeline_mode == "full" and self.system_prompt == "":
|
|
164
|
+
raise ValueError("system_prompt cannot be empty for pipeline_mode='full'")
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class JSONPatchOperation(BaseModel):
|
|
169
|
+
model_config = ConfigDict(extra="forbid")
|
|
170
|
+
|
|
171
|
+
op: Literal["add", "remove", "replace", "move", "copy", "test"]
|
|
172
|
+
path: str
|
|
173
|
+
value: Any = None
|
|
174
|
+
from_: str | None = Field(default=None, alias="from")
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
6
|
+
|
|
7
|
+
PronunciationType = Literal["alias", "ipa"]
|
|
8
|
+
|
|
9
|
+
MAX_RULES_PER_DICTIONARY = 10000
|
|
10
|
+
MAX_TEXT_LEN = 200
|
|
11
|
+
MAX_PRONUNCIATION_LEN = 500
|
|
12
|
+
MAX_NAME_LEN = 255
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PronunciationRule(BaseModel):
|
|
16
|
+
"""One substitution rule. ``type="alias"`` swaps ``text`` for the literal
|
|
17
|
+
``pronunciation`` string before TTS; ``type="ipa"`` interprets
|
|
18
|
+
``pronunciation`` as IPA and uses the SSML phoneme tag."""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(extra="forbid")
|
|
21
|
+
|
|
22
|
+
text: str = Field(..., min_length=1, max_length=MAX_TEXT_LEN)
|
|
23
|
+
pronunciation: str = Field(..., min_length=1, max_length=MAX_PRONUNCIATION_LEN)
|
|
24
|
+
type: PronunciationType
|
|
25
|
+
alphabet: Literal["ipa"] | None = None
|
|
26
|
+
case_sensitive: bool = False
|
|
27
|
+
word_boundaries: bool = True
|
|
28
|
+
|
|
29
|
+
@model_validator(mode="after")
|
|
30
|
+
def _set_default_alphabet(self) -> PronunciationRule:
|
|
31
|
+
if self.type == "ipa" and self.alphabet is None:
|
|
32
|
+
self.alphabet = "ipa"
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PronunciationDictionaryCreate(BaseModel):
|
|
37
|
+
model_config = ConfigDict(extra="forbid")
|
|
38
|
+
|
|
39
|
+
name: str = Field(..., min_length=1, max_length=MAX_NAME_LEN)
|
|
40
|
+
rules: list[PronunciationRule] = Field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
@model_validator(mode="after")
|
|
43
|
+
def _check_rules(self) -> PronunciationDictionaryCreate:
|
|
44
|
+
if len(self.rules) > MAX_RULES_PER_DICTIONARY:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"dictionary can hold at most {MAX_RULES_PER_DICTIONARY} rules"
|
|
47
|
+
)
|
|
48
|
+
texts = [r.text for r in self.rules]
|
|
49
|
+
if len(texts) != len(set(texts)):
|
|
50
|
+
duplicates = sorted({t for t in texts if texts.count(t) > 1})
|
|
51
|
+
raise ValueError(f"duplicate 'text' entries are not allowed: {duplicates}")
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PronunciationDictionaryUpdate(BaseModel):
|
|
56
|
+
model_config = ConfigDict(extra="forbid")
|
|
57
|
+
|
|
58
|
+
name: str | None = Field(default=None, max_length=MAX_NAME_LEN)
|
|
59
|
+
rules: list[PronunciationRule] | None = None
|
|
60
|
+
|
|
61
|
+
@model_validator(mode="after")
|
|
62
|
+
def _check_rules(self) -> PronunciationDictionaryUpdate:
|
|
63
|
+
if self.rules is None:
|
|
64
|
+
return self
|
|
65
|
+
if len(self.rules) > MAX_RULES_PER_DICTIONARY:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"dictionary can hold at most {MAX_RULES_PER_DICTIONARY} rules"
|
|
68
|
+
)
|
|
69
|
+
texts = [r.text for r in self.rules]
|
|
70
|
+
if len(texts) != len(set(texts)):
|
|
71
|
+
duplicates = sorted({t for t in texts if texts.count(t) > 1})
|
|
72
|
+
raise ValueError(f"duplicate 'text' entries are not allowed: {duplicates}")
|
|
73
|
+
return self
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
8
|
+
|
|
9
|
+
MAX_TOOL_SIZE = 10000
|
|
10
|
+
MAX_PERSONA_TOOLS = 50
|
|
11
|
+
|
|
12
|
+
TOOL_AUTH_SCRUB_PLACEHOLDER = "*" * 8
|
|
13
|
+
_TOOL_NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
|
|
14
|
+
_PLACEHOLDER_RE = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
15
|
+
_BODYLESS_METHODS = {"GET", "HEAD", "DELETE"}
|
|
16
|
+
_RESERVED_PARAMETER_PREFIX = "tavus_"
|
|
17
|
+
|
|
18
|
+
Origin = Literal["llm", "vision", "audio"]
|
|
19
|
+
OnCall = Literal["generate_filler", "static_filler", "silent", "passthrough"]
|
|
20
|
+
OnResolve = Literal[
|
|
21
|
+
"generate_response", "response_in_result", "add_to_context", "fire_and_forget"
|
|
22
|
+
]
|
|
23
|
+
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]
|
|
24
|
+
ApiKeyLocation = Literal["header", "query"]
|
|
25
|
+
ToolListType = Literal["user", "system", "all"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthNone(BaseModel):
|
|
29
|
+
model_config = ConfigDict(extra="forbid")
|
|
30
|
+
type: Literal["none"] = "none"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthBearer(BaseModel):
|
|
34
|
+
model_config = ConfigDict(extra="forbid")
|
|
35
|
+
type: Literal["bearer"] = "bearer"
|
|
36
|
+
token: str = Field(..., min_length=1)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AuthBasic(BaseModel):
|
|
40
|
+
model_config = ConfigDict(extra="forbid")
|
|
41
|
+
type: Literal["basic"] = "basic"
|
|
42
|
+
username: str = Field(..., min_length=1)
|
|
43
|
+
password: str = Field(..., min_length=1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AuthApiKey(BaseModel):
|
|
47
|
+
model_config = ConfigDict(extra="forbid")
|
|
48
|
+
type: Literal["api_key"] = "api_key"
|
|
49
|
+
name: str = Field(..., min_length=1)
|
|
50
|
+
value: str = Field(..., min_length=1)
|
|
51
|
+
location: ApiKeyLocation = "header"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AuthHmac(BaseModel):
|
|
55
|
+
model_config = ConfigDict(extra="forbid")
|
|
56
|
+
type: Literal["hmac"] = "hmac"
|
|
57
|
+
secret: str = Field(..., min_length=1)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AuthOAuth2(BaseModel):
|
|
61
|
+
model_config = ConfigDict(extra="forbid")
|
|
62
|
+
type: Literal["oauth2_client_credentials"] = "oauth2_client_credentials"
|
|
63
|
+
token_url: str = Field(..., min_length=1)
|
|
64
|
+
client_id: str = Field(..., min_length=1)
|
|
65
|
+
client_secret: str = Field(..., min_length=1)
|
|
66
|
+
scope: str | None = None
|
|
67
|
+
|
|
68
|
+
@model_validator(mode="after")
|
|
69
|
+
def _check_token_url(self) -> AuthOAuth2:
|
|
70
|
+
if not self.token_url.lower().startswith("https://"):
|
|
71
|
+
raise ValueError("auth.token_url must be https://")
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
ToolAuth = AuthNone | AuthBearer | AuthBasic | AuthApiKey | AuthHmac | AuthOAuth2
|
|
76
|
+
|
|
77
|
+
_AUTH_SECRET_FIELD = {
|
|
78
|
+
"bearer": "token",
|
|
79
|
+
"basic": "password",
|
|
80
|
+
"api_key": "value",
|
|
81
|
+
"hmac": "secret",
|
|
82
|
+
"oauth2_client_credentials": "client_secret",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ApiDelivery(BaseModel):
|
|
87
|
+
"""HTTP-tool delivery channel. RQH calls the third-party endpoint at
|
|
88
|
+
``url`` when the tool fires; placeholders (``{ident}``) in url/body/query
|
|
89
|
+
are substituted from the LLM-supplied arguments declared in
|
|
90
|
+
``parameters.properties``."""
|
|
91
|
+
|
|
92
|
+
model_config = ConfigDict(extra="forbid")
|
|
93
|
+
|
|
94
|
+
url: str = Field(..., min_length=1)
|
|
95
|
+
method: HttpMethod = "POST"
|
|
96
|
+
timeout: float = 10.0
|
|
97
|
+
headers: dict[str, str] | None = None
|
|
98
|
+
auth: ToolAuth | None = Field(default=None, discriminator="type")
|
|
99
|
+
body_template: dict[str, Any] | None = None
|
|
100
|
+
query_params: dict[str, str] | None = None
|
|
101
|
+
content_type: str | None = None
|
|
102
|
+
|
|
103
|
+
@model_validator(mode="after")
|
|
104
|
+
def _check(self) -> ApiDelivery:
|
|
105
|
+
if not self.url.lower().startswith("https://"):
|
|
106
|
+
raise ValueError("delivery.api.url must be https://")
|
|
107
|
+
if not (0 < self.timeout <= 60):
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"delivery.api.timeout must be a number in (0, 60] seconds (default 10)"
|
|
110
|
+
)
|
|
111
|
+
if self.body_template is not None and self.method in _BODYLESS_METHODS:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"method {self.method} does not send a request body; "
|
|
114
|
+
"remove body_template or change method to POST/PUT/PATCH"
|
|
115
|
+
)
|
|
116
|
+
# Round-trip footgun: PATCHing a payload that still contains the
|
|
117
|
+
# scrubbed-secret placeholder from a prior GET silently re-encrypts
|
|
118
|
+
# asterisks. Reject so the caller has to omit the field instead.
|
|
119
|
+
if isinstance(self.auth, (AuthBearer, AuthBasic, AuthApiKey, AuthHmac, AuthOAuth2)):
|
|
120
|
+
field = _AUTH_SECRET_FIELD[self.auth.type]
|
|
121
|
+
value = getattr(self.auth, field, None)
|
|
122
|
+
if value == TOOL_AUTH_SCRUB_PLACEHOLDER:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"delivery.api.auth.{field} looks like the scrub placeholder "
|
|
125
|
+
"echoed from a GET response; omit it to keep the existing secret"
|
|
126
|
+
)
|
|
127
|
+
# HMAC mode sends a fixed Tavus-envelope body to a static URL — any
|
|
128
|
+
# body_template/query_params/url placeholders are silently ignored.
|
|
129
|
+
if isinstance(self.auth, AuthHmac):
|
|
130
|
+
conflicts: list[str] = []
|
|
131
|
+
if self.body_template:
|
|
132
|
+
conflicts.append("body_template")
|
|
133
|
+
if self.query_params:
|
|
134
|
+
conflicts.append("query_params")
|
|
135
|
+
if _PLACEHOLDER_RE.search(self.url):
|
|
136
|
+
conflicts.append("url placeholders")
|
|
137
|
+
if conflicts:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"auth.type=hmac ignores {conflicts}; remove these fields or switch auth type"
|
|
140
|
+
)
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ToolDelivery(BaseModel):
|
|
145
|
+
"""How a tool fires. Exactly one channel must be enabled:
|
|
146
|
+
``app_message=True`` (delivered over the Daily data channel) OR
|
|
147
|
+
``api`` set (delivered over HTTPS to a third-party endpoint)."""
|
|
148
|
+
|
|
149
|
+
model_config = ConfigDict(extra="forbid")
|
|
150
|
+
|
|
151
|
+
app_message: bool | None = None
|
|
152
|
+
api: ApiDelivery | None = None
|
|
153
|
+
|
|
154
|
+
@model_validator(mode="after")
|
|
155
|
+
def _exactly_one_channel(self) -> ToolDelivery:
|
|
156
|
+
has_app = self.app_message is True
|
|
157
|
+
has_api = self.api is not None
|
|
158
|
+
if has_app and has_api:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
"delivery must enable exactly one channel: set delivery.app_message=true "
|
|
161
|
+
"OR include delivery.api, not both"
|
|
162
|
+
)
|
|
163
|
+
if not has_app and not has_api:
|
|
164
|
+
raise ValueError(
|
|
165
|
+
"delivery must enable a channel: set delivery.app_message=true "
|
|
166
|
+
"or include delivery.api"
|
|
167
|
+
)
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class ToolCreate(BaseModel):
|
|
172
|
+
"""Body for `POST /v2/tools`."""
|
|
173
|
+
|
|
174
|
+
model_config = ConfigDict(extra="forbid")
|
|
175
|
+
|
|
176
|
+
name: str
|
|
177
|
+
description: str = Field(..., min_length=1)
|
|
178
|
+
parameters: dict[str, Any] = Field(default_factory=dict)
|
|
179
|
+
delivery: ToolDelivery = Field(default_factory=lambda: ToolDelivery(app_message=True))
|
|
180
|
+
origin: Origin = "llm"
|
|
181
|
+
on_call: OnCall | None = None
|
|
182
|
+
on_resolve: OnResolve | None = "fire_and_forget"
|
|
183
|
+
static_filler: str | None = None
|
|
184
|
+
tags: list[str] = Field(default_factory=list)
|
|
185
|
+
|
|
186
|
+
@model_validator(mode="after")
|
|
187
|
+
def _check(self) -> ToolCreate:
|
|
188
|
+
_validate_tool_name(self.name)
|
|
189
|
+
_validate_tool_size(self.description, self.parameters)
|
|
190
|
+
_validate_parameter_names(self.parameters)
|
|
191
|
+
_validate_placeholders(self.delivery, self.parameters)
|
|
192
|
+
_validate_origin_fields(
|
|
193
|
+
self.origin, self.on_call, self.on_resolve, self.static_filler
|
|
194
|
+
)
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class ToolUpdate(BaseModel):
|
|
199
|
+
"""Body for `PATCH /v2/tools/{tool_id}`. Every field optional; provided
|
|
200
|
+
fields replace the current value. Omit a field to leave it unchanged."""
|
|
201
|
+
|
|
202
|
+
model_config = ConfigDict(extra="forbid")
|
|
203
|
+
|
|
204
|
+
name: str | None = None
|
|
205
|
+
description: str | None = None
|
|
206
|
+
parameters: dict[str, Any] | None = None
|
|
207
|
+
delivery: ToolDelivery | None = None
|
|
208
|
+
origin: Origin | None = None
|
|
209
|
+
on_call: OnCall | None = None
|
|
210
|
+
on_resolve: OnResolve | None = None
|
|
211
|
+
tags: list[str] | None = None
|
|
212
|
+
static_filler: str | None = None
|
|
213
|
+
|
|
214
|
+
@model_validator(mode="after")
|
|
215
|
+
def _check(self) -> ToolUpdate:
|
|
216
|
+
if self.name is not None:
|
|
217
|
+
_validate_tool_name(self.name)
|
|
218
|
+
if self.description is not None and self.parameters is not None:
|
|
219
|
+
_validate_tool_size(self.description, self.parameters)
|
|
220
|
+
if self.parameters is not None:
|
|
221
|
+
_validate_parameter_names(self.parameters)
|
|
222
|
+
if self.delivery is not None:
|
|
223
|
+
_validate_placeholders(self.delivery, self.parameters or {})
|
|
224
|
+
return self
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class AttachToolsBody(BaseModel):
|
|
228
|
+
"""Body for `POST /v2/personas/{id}/tools/` — attach by tool_id list."""
|
|
229
|
+
|
|
230
|
+
model_config = ConfigDict(extra="forbid")
|
|
231
|
+
|
|
232
|
+
tool_ids: list[str] = Field(..., min_length=1)
|
|
233
|
+
|
|
234
|
+
@model_validator(mode="after")
|
|
235
|
+
def _check(self) -> AttachToolsBody:
|
|
236
|
+
if len(self.tool_ids) > MAX_PERSONA_TOOLS:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
f"cannot attach more than {MAX_PERSONA_TOOLS} tools at once"
|
|
239
|
+
)
|
|
240
|
+
for tid in self.tool_ids:
|
|
241
|
+
if not isinstance(tid, str) or not tid:
|
|
242
|
+
raise ValueError("tool_ids must be non-empty strings")
|
|
243
|
+
return self
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class ToolListParams(BaseModel):
|
|
247
|
+
model_config = ConfigDict(extra="forbid")
|
|
248
|
+
|
|
249
|
+
limit: int = 25
|
|
250
|
+
page: int = 1
|
|
251
|
+
type: ToolListType = "user"
|
|
252
|
+
sort: Literal["ascending", "descending"] = "ascending"
|
|
253
|
+
name_or_uuid: str | None = None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _validate_tool_name(name: str) -> None:
|
|
257
|
+
if not _TOOL_NAME_RE.match(name):
|
|
258
|
+
raise ValueError(
|
|
259
|
+
"name must start with a letter or underscore and contain only "
|
|
260
|
+
"letters, digits, and underscores (max 64 chars)"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _validate_tool_size(description: str, parameters: dict[str, Any]) -> None:
|
|
265
|
+
total = len(description or "") + len(json.dumps(parameters or {}, sort_keys=True))
|
|
266
|
+
if total > MAX_TOOL_SIZE:
|
|
267
|
+
raise ValueError(
|
|
268
|
+
f"tool spec is too large: description + parameters serialize to "
|
|
269
|
+
f"{total} characters, limit is {MAX_TOOL_SIZE}"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _validate_parameter_names(parameters: dict[str, Any]) -> None:
|
|
274
|
+
properties = parameters.get("properties") if isinstance(parameters, dict) else None
|
|
275
|
+
if not isinstance(properties, dict):
|
|
276
|
+
return
|
|
277
|
+
offending = sorted(
|
|
278
|
+
name
|
|
279
|
+
for name in properties
|
|
280
|
+
if isinstance(name, str) and name.startswith(_RESERVED_PARAMETER_PREFIX)
|
|
281
|
+
)
|
|
282
|
+
if offending:
|
|
283
|
+
raise ValueError(
|
|
284
|
+
f"parameter names starting with '{_RESERVED_PARAMETER_PREFIX}' are "
|
|
285
|
+
f"reserved for Tavus system placeholders: {offending}"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _validate_placeholders(
|
|
290
|
+
delivery: ToolDelivery, parameters: dict[str, Any]
|
|
291
|
+
) -> None:
|
|
292
|
+
if delivery.api is None:
|
|
293
|
+
return
|
|
294
|
+
found: set[str] = set()
|
|
295
|
+
found |= set(_PLACEHOLDER_RE.findall(delivery.api.url))
|
|
296
|
+
if delivery.api.body_template is not None:
|
|
297
|
+
found |= _placeholders_in_object(delivery.api.body_template)
|
|
298
|
+
if delivery.api.query_params is not None:
|
|
299
|
+
found |= _placeholders_in_object(delivery.api.query_params)
|
|
300
|
+
found = {n for n in found if not n.startswith(_RESERVED_PARAMETER_PREFIX)}
|
|
301
|
+
declared: set[str] = set()
|
|
302
|
+
properties = parameters.get("properties") if isinstance(parameters, dict) else None
|
|
303
|
+
if isinstance(properties, dict):
|
|
304
|
+
declared = {k for k in properties if isinstance(k, str)}
|
|
305
|
+
missing = sorted(found - declared)
|
|
306
|
+
if missing:
|
|
307
|
+
raise ValueError(
|
|
308
|
+
f"delivery.api references placeholder(s) {missing} not declared in "
|
|
309
|
+
f"parameters.properties (declared: {sorted(declared)})"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _placeholders_in_object(obj: Any) -> set[str]:
|
|
314
|
+
if isinstance(obj, str):
|
|
315
|
+
return set(_PLACEHOLDER_RE.findall(obj))
|
|
316
|
+
if isinstance(obj, dict):
|
|
317
|
+
out: set[str] = set()
|
|
318
|
+
for v in obj.values():
|
|
319
|
+
out |= _placeholders_in_object(v)
|
|
320
|
+
return out
|
|
321
|
+
if isinstance(obj, list):
|
|
322
|
+
out = set()
|
|
323
|
+
for v in obj:
|
|
324
|
+
out |= _placeholders_in_object(v)
|
|
325
|
+
return out
|
|
326
|
+
return set()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _validate_origin_fields(
|
|
330
|
+
origin: Origin,
|
|
331
|
+
on_call: OnCall | None,
|
|
332
|
+
on_resolve: OnResolve | None,
|
|
333
|
+
static_filler: str | None,
|
|
334
|
+
) -> None:
|
|
335
|
+
if origin == "llm":
|
|
336
|
+
if on_call == "static_filler" and not static_filler:
|
|
337
|
+
raise ValueError(
|
|
338
|
+
"static_filler is required when on_call='static_filler'"
|
|
339
|
+
)
|
|
340
|
+
return
|
|
341
|
+
# vision / audio = perception tools, fire-and-forget by design
|
|
342
|
+
if on_call is not None:
|
|
343
|
+
raise ValueError(f"on_call must be null for {origin} tools")
|
|
344
|
+
if on_resolve not in (None, "fire_and_forget"):
|
|
345
|
+
raise ValueError(
|
|
346
|
+
f"on_resolve must be 'fire_and_forget' for {origin} tools"
|
|
347
|
+
)
|
|
348
|
+
if static_filler is not None:
|
|
349
|
+
raise ValueError(f"static_filler must be null for {origin} tools")
|