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.
@@ -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")