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,6 @@
1
+ """Shared Tavus SDK internals used by the CLI and MCP server."""
2
+
3
+ from tavus_mcp.sdk.client.http import TavusClient
4
+ from tavus_mcp.sdk.env import TavusConfig, TavusEnvironment, load_config
5
+
6
+ __all__ = ["TavusClient", "TavusConfig", "TavusEnvironment", "load_config"]
@@ -0,0 +1 @@
1
+ """Auth helpers."""
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import keyring
4
+
5
+ from tavus_mcp.sdk.env import TavusConfig
6
+
7
+
8
+ def get_api_key(config: TavusConfig) -> str | None:
9
+ return keyring.get_password(config.keyring_service, config.keyring_username)
10
+
11
+
12
+ def set_api_key(config: TavusConfig, api_key: str) -> None:
13
+ keyring.set_password(config.keyring_service, config.keyring_username, api_key)
14
+
15
+
16
+ def delete_api_key(config: TavusConfig) -> None:
17
+ try:
18
+ keyring.delete_password(config.keyring_service, config.keyring_username)
19
+ except keyring.errors.PasswordDeleteError:
20
+ return
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import socket
6
+ import threading
7
+ import time
8
+ import webbrowser
9
+ from dataclasses import dataclass
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+ from secrets import token_urlsafe
12
+ from socketserver import BaseServer
13
+ from typing import Any
14
+ from urllib.parse import urlencode
15
+
16
+ from tavus_mcp.sdk.auth import keyring_store
17
+ from tavus_mcp.sdk.env import TavusConfig
18
+ from tavus_mcp.sdk.errors import TavusAuthError
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class LoginResult:
23
+ api_key: str
24
+ email: str | None = None
25
+
26
+
27
+ @dataclass
28
+ class _Captured:
29
+ api_key: str | None = None
30
+ email: str | None = None
31
+ error: str | None = None
32
+
33
+
34
+ def _free_port() -> int:
35
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
36
+ sock.bind(("127.0.0.1", 0))
37
+ return int(sock.getsockname()[1])
38
+
39
+
40
+ def _handler(state: str, captured: _Captured) -> type[BaseHTTPRequestHandler]:
41
+ class Handler(BaseHTTPRequestHandler):
42
+ def log_message(self, format: str, *args: Any) -> None:
43
+ return
44
+
45
+ def _cors_headers(self) -> None:
46
+ self.send_header("Access-Control-Allow-Origin", "*")
47
+ self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
48
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
49
+
50
+ def do_OPTIONS(self) -> None:
51
+ self.send_response(204)
52
+ self._cors_headers()
53
+ self.end_headers()
54
+
55
+ def do_POST(self) -> None:
56
+ if self.path.rstrip("/") != "/callback":
57
+ self.send_error(404)
58
+ return
59
+ length = int(self.headers.get("Content-Length", "0"))
60
+ try:
61
+ payload = json.loads(self.rfile.read(length).decode("utf-8"))
62
+ except Exception:
63
+ self.send_error(400, "Invalid JSON")
64
+ return
65
+ if payload.get("state") != state:
66
+ captured.error = "Invalid state"
67
+ self.send_error(400, captured.error)
68
+ return
69
+ api_key = payload.get("api_key")
70
+ if not isinstance(api_key, str) or not api_key:
71
+ captured.error = "Missing api_key"
72
+ self.send_error(400, captured.error)
73
+ return
74
+ captured.api_key = api_key
75
+ email = payload.get("email")
76
+ if isinstance(email, str):
77
+ captured.email = email
78
+ self.send_response(204)
79
+ self._cors_headers()
80
+ self.end_headers()
81
+ threading.Thread(target=self.server.shutdown, daemon=True).start()
82
+
83
+ return Handler
84
+
85
+
86
+ def _shutdown(server: BaseServer) -> None:
87
+ try:
88
+ server.shutdown()
89
+ except Exception:
90
+ return
91
+
92
+
93
+ def _authorize_url(config: TavusConfig, *, callback: str, state: str, key_name: str) -> str:
94
+ query = urlencode({"callback": callback, "state": state, "name": key_name})
95
+ return f"{str(config.dev_portal_url).rstrip('/')}/dev/cli-authorize?{query}"
96
+
97
+
98
+ def _run_login_flow(
99
+ config: TavusConfig, *, key_name: str, timeout_seconds: int = 300
100
+ ) -> _Captured:
101
+ state = token_urlsafe(32)
102
+ port = _free_port()
103
+ captured = _Captured()
104
+ server = ThreadingHTTPServer(("127.0.0.1", port), _handler(state, captured))
105
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
106
+ thread.start()
107
+ callback = f"http://127.0.0.1:{port}/callback"
108
+ url = _authorize_url(config, callback=callback, state=state, key_name=key_name)
109
+ webbrowser.open(url, new=1)
110
+
111
+ deadline = time.monotonic() + timeout_seconds
112
+ try:
113
+ while time.monotonic() < deadline and not captured.api_key and not captured.error:
114
+ time.sleep(0.1)
115
+ if captured.error:
116
+ raise TavusAuthError(captured.error)
117
+ if not captured.api_key:
118
+ raise TavusAuthError(
119
+ "Timed out waiting for the dev-portal to send the API key."
120
+ )
121
+ return captured
122
+ finally:
123
+ _shutdown(server)
124
+ thread.join(timeout=2)
125
+
126
+
127
+ async def login_with_browser(config: TavusConfig, *, key_name: str) -> LoginResult:
128
+ captured = await asyncio.to_thread(_run_login_flow, config, key_name=key_name)
129
+ assert captured.api_key is not None
130
+ keyring_store.set_api_key(config, captured.api_key)
131
+ return LoginResult(api_key=captured.api_key, email=captured.email)
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+ from tavus_mcp.sdk.auth import keyring_store
7
+ from tavus_mcp.sdk.env import TavusConfig
8
+ from tavus_mcp.sdk.errors import TavusAuthError
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class TavusSession:
13
+ api_key: str
14
+ source: str
15
+
16
+
17
+ def _env_api_key(config: TavusConfig) -> tuple[str, str] | None:
18
+ generic = os.getenv("TAVUS_API_KEY")
19
+ if generic:
20
+ return generic, "env:TAVUS_API_KEY"
21
+
22
+ env_specific_name = f"TAVUS_{config.env.value}_API_KEY"
23
+ env_specific = os.getenv(env_specific_name)
24
+ if env_specific:
25
+ return env_specific, f"env:{env_specific_name}"
26
+ return None
27
+
28
+
29
+ def get_session(config: TavusConfig, *, required: bool = True) -> TavusSession | None:
30
+ if env_key := _env_api_key(config):
31
+ api_key, source = env_key
32
+ return TavusSession(api_key=api_key, source=source)
33
+
34
+ stored = keyring_store.get_api_key(config)
35
+ if stored:
36
+ return TavusSession(
37
+ api_key=stored,
38
+ source=f"keyring:{config.keyring_service}/{config.keyring_username}",
39
+ )
40
+
41
+ if required:
42
+ raise TavusAuthError(
43
+ f"No Tavus API key found for {config.env.value}. Run `tavus auth login` "
44
+ "or set TAVUS_API_KEY/TAVUS_<ENV>_API_KEY."
45
+ )
46
+ return None
@@ -0,0 +1,3 @@
1
+ from tavus_mcp.sdk.client.http import TavusClient
2
+
3
+ __all__ = ["TavusClient"]
@@ -0,0 +1,451 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json as _json
5
+ from typing import Any, Self
6
+
7
+ import httpx
8
+
9
+ from tavus_mcp.sdk.auth.session import TavusSession, get_session
10
+ from tavus_mcp.sdk.env import TavusConfig, load_config
11
+ from tavus_mcp.sdk.errors import TavusApiError
12
+ from tavus_mcp.sdk.patch import validate_patch_operations
13
+ from tavus_mcp.sdk.schemas.guardrail import GuardrailCreate, GuardrailUpdate
14
+ from tavus_mcp.sdk.schemas.objective import ObjectivesCreate
15
+ from tavus_mcp.sdk.schemas.persona import JSONPatchOperation, PersonaCreate
16
+ from tavus_mcp.sdk.schemas.pronunciation import (
17
+ PronunciationDictionaryCreate,
18
+ PronunciationDictionaryUpdate,
19
+ )
20
+ from tavus_mcp.sdk.schemas.tool import AttachToolsBody, ToolCreate, ToolUpdate
21
+
22
+ BREAK_MARKER = "[BREAK]"
23
+ STOP_MARKER = "[STOP]"
24
+ BUILDER_LLM_TIMEOUT_S = 120.0
25
+
26
+
27
+ class TavusClient:
28
+ def __init__(
29
+ self,
30
+ config: TavusConfig,
31
+ session: TavusSession,
32
+ *,
33
+ timeout: float = 30,
34
+ retry: bool = False,
35
+ ) -> None:
36
+ self.config = config
37
+ self.session = session
38
+ self.retry = retry
39
+ headers = {"x-api-key": session.api_key, "Content-Type": "application/json"}
40
+ self._client = httpx.AsyncClient(
41
+ base_url=str(config.public_api_base_url),
42
+ timeout=timeout,
43
+ headers=headers,
44
+ )
45
+ self._portal_client = httpx.AsyncClient(
46
+ base_url=str(config.portal_api_base_url),
47
+ timeout=timeout,
48
+ headers=headers,
49
+ )
50
+ self.personas = PersonaResource(self)
51
+ self.replicas = Resource(self, "replicas")
52
+ self.conversations = ConversationResource(self)
53
+ self.guardrails = GuardrailResource(self)
54
+ self.objectives = ObjectiveResource(self)
55
+ self.documents = Resource(self, "documents")
56
+ self.voices = Resource(self, "voices")
57
+ self.tools = ToolResource(self)
58
+ self.pronunciation_dictionaries = PronunciationDictionaryResource(self)
59
+ self.builders = BuilderResource(self)
60
+
61
+ @classmethod
62
+ def from_env(cls, *, required: bool = True, timeout: float = 30, retry: bool = False) -> Self:
63
+ config = load_config()
64
+ session = get_session(config, required=required)
65
+ if session is None:
66
+ raise RuntimeError("Session was unexpectedly missing.")
67
+ return cls(config, session, timeout=timeout, retry=retry)
68
+
69
+ async def __aenter__(self) -> Self:
70
+ return self
71
+
72
+ async def __aexit__(self, *_: object) -> None:
73
+ await self.aclose()
74
+
75
+ async def aclose(self) -> None:
76
+ await self._client.aclose()
77
+ await self._portal_client.aclose()
78
+
79
+ async def request(self, method: str, path: str, **kwargs: Any) -> Any:
80
+ return await self._do_request(self._client, method, path, **kwargs)
81
+
82
+ async def portal_request(self, method: str, path: str, **kwargs: Any) -> Any:
83
+ return await self._do_request(self._portal_client, method, path, **kwargs)
84
+
85
+ async def _do_request(
86
+ self, client: httpx.AsyncClient, method: str, path: str, **kwargs: Any
87
+ ) -> Any:
88
+ response = await client.request(method, path, **kwargs)
89
+ if response.is_error:
90
+ try:
91
+ body: Any = response.json()
92
+ except ValueError:
93
+ body = response.text
94
+ raise TavusApiError(
95
+ f"Tavus API {method.upper()} {path} failed with {response.status_code}",
96
+ status_code=response.status_code,
97
+ body=body,
98
+ )
99
+ if response.status_code == 204 or not response.content:
100
+ return None
101
+ try:
102
+ return response.json()
103
+ except ValueError:
104
+ return response.text
105
+
106
+
107
+ class Resource:
108
+ def __init__(self, client: TavusClient, name: str) -> None:
109
+ self.client = client
110
+ self.name = name.strip("/")
111
+
112
+ async def list(self, **params: Any) -> Any:
113
+ return await self.client.request("GET", f"/{self.name}", params=_clean(params))
114
+
115
+ async def get(self, resource_id: str, **params: Any) -> Any:
116
+ return await self.client.request(
117
+ "GET",
118
+ f"/{self.name}/{resource_id}",
119
+ params=_clean(params),
120
+ )
121
+
122
+ async def create(self, payload: dict[str, Any]) -> Any:
123
+ return await self.client.request("POST", f"/{self.name}", json=payload)
124
+
125
+ async def patch(self, resource_id: str, payload: Any) -> Any:
126
+ return await self.client.request("PATCH", f"/{self.name}/{resource_id}", json=payload)
127
+
128
+ async def delete(self, resource_id: str) -> Any:
129
+ return await self.client.request("DELETE", f"/{self.name}/{resource_id}")
130
+
131
+
132
+ class PersonaResource(Resource):
133
+ def __init__(self, client: TavusClient) -> None:
134
+ super().__init__(client, "personas")
135
+
136
+ async def create(self, payload: dict[str, Any]) -> Any:
137
+ validated = PersonaCreate.model_validate(payload).model_dump(exclude_none=True)
138
+ return await super().create(validated)
139
+
140
+ async def patch(self, resource_id: str, payload: list[dict[str, Any]]) -> Any:
141
+ validated = validate_patch_operations(payload)
142
+ return await super().patch(resource_id, validated)
143
+
144
+ async def list_tools(self, persona_id: str) -> Any:
145
+ return await self.client.request("GET", f"/personas/{persona_id}/tools/")
146
+
147
+ async def attach_tools(self, persona_id: str, tool_ids: list[str]) -> Any:
148
+ body = AttachToolsBody(tool_ids=tool_ids).model_dump()
149
+ return await self.client.request(
150
+ "POST", f"/personas/{persona_id}/tools/", json=body
151
+ )
152
+
153
+ async def detach_tool(self, persona_id: str, tool_id: str) -> Any:
154
+ return await self.client.request(
155
+ "DELETE", f"/personas/{persona_id}/tools/{tool_id}"
156
+ )
157
+
158
+
159
+ class GuardrailResource(Resource):
160
+ """Flat-list guardrails (no more sets). Adds `validate` and `tags` to the
161
+ base CRUD surface."""
162
+
163
+ def __init__(self, client: TavusClient) -> None:
164
+ super().__init__(client, "guardrails")
165
+
166
+ async def create(self, payload: dict[str, Any]) -> Any:
167
+ validated = GuardrailCreate.model_validate(payload).model_dump(exclude_none=True)
168
+ return await self.client.request("POST", "/guardrails/", json=validated)
169
+
170
+ async def patch(self, resource_id: str, payload: dict[str, Any]) -> Any:
171
+ validated = GuardrailUpdate.model_validate(payload).model_dump(exclude_none=True)
172
+ return await self.client.request(
173
+ "PATCH", f"/guardrails/{resource_id}", json=validated
174
+ )
175
+
176
+ async def validate(self, payload: dict[str, Any]) -> Any:
177
+ """`POST /v2/guardrails/validate` — checks a legacy set-shape payload
178
+ without persisting. Useful before bulk-importing."""
179
+ return await self.client.request("POST", "/guardrails/validate", json=payload)
180
+
181
+ async def tags(self, **params: Any) -> Any:
182
+ """`GET /v2/guardrails/tags` — KB-shaped `{tags, total_count}`."""
183
+ return await self.client.request(
184
+ "GET", "/guardrails/tags", params=_clean(params)
185
+ )
186
+
187
+
188
+ class ObjectiveResource(Resource):
189
+ """Objective *sets* — still set-based (unlike guardrails). Each set
190
+ bundles ordered/conditional steps and lives at a single ``objectives_id``
191
+ that personas reference. PATCH takes JSON Patch ops against the set
192
+ document (matches the `PATCH /v2/objectives/{id}` server contract)."""
193
+
194
+ def __init__(self, client: TavusClient) -> None:
195
+ super().__init__(client, "objectives")
196
+
197
+ async def create(self, payload: dict[str, Any]) -> Any:
198
+ validated = ObjectivesCreate.model_validate(payload).model_dump(exclude_none=True)
199
+ return await self.client.request("POST", "/objectives/", json=validated)
200
+
201
+ async def patch(self, resource_id: str, payload: list[dict[str, Any]]) -> Any:
202
+ ops = [
203
+ JSONPatchOperation.model_validate(item).model_dump(
204
+ by_alias=True, exclude_none=True
205
+ )
206
+ for item in payload
207
+ ]
208
+ return await self.client.request(
209
+ "PATCH", f"/objectives/{resource_id}", json=ops
210
+ )
211
+
212
+ async def validate(self, payload: dict[str, Any]) -> Any:
213
+ """`POST /v2/objectives/validate` — check a set-shape payload
214
+ (cycles, single-root, references) without persisting."""
215
+ validated = ObjectivesCreate.model_validate(payload).model_dump(exclude_none=True)
216
+ return await self.client.request("POST", "/objectives/validate", json=validated)
217
+
218
+
219
+ class ToolResource(Resource):
220
+ """Tools resource with the new HTTP-delivery surface area."""
221
+
222
+ def __init__(self, client: TavusClient) -> None:
223
+ super().__init__(client, "tools")
224
+
225
+ async def create(self, payload: dict[str, Any]) -> Any:
226
+ validated = ToolCreate.model_validate(payload).model_dump(exclude_none=True)
227
+ return await super().create(validated)
228
+
229
+ async def patch(self, resource_id: str, payload: dict[str, Any]) -> Any:
230
+ validated = ToolUpdate.model_validate(payload).model_dump(exclude_none=True)
231
+ return await super().patch(resource_id, validated)
232
+
233
+
234
+ class PronunciationDictionaryResource(Resource):
235
+ """`/v2/pronunciation-dictionaries/*` — substitution maps that personas
236
+ reference via ``layers.tts.pronunciation_dictionary_id``."""
237
+
238
+ def __init__(self, client: TavusClient) -> None:
239
+ super().__init__(client, "pronunciation-dictionaries")
240
+
241
+ async def create(self, payload: dict[str, Any]) -> Any:
242
+ validated = PronunciationDictionaryCreate.model_validate(payload).model_dump(
243
+ exclude_none=True
244
+ )
245
+ return await self.client.request(
246
+ "POST", "/pronunciation-dictionaries/", json=validated
247
+ )
248
+
249
+ async def patch(self, resource_id: str, payload: dict[str, Any]) -> Any:
250
+ validated = PronunciationDictionaryUpdate.model_validate(payload).model_dump(
251
+ exclude_none=True
252
+ )
253
+ return await self.client.request(
254
+ "PATCH", f"/pronunciation-dictionaries/{resource_id}", json=validated
255
+ )
256
+
257
+
258
+ class ConversationResource(Resource):
259
+ def __init__(self, client: TavusClient) -> None:
260
+ super().__init__(client, "conversations")
261
+
262
+ async def end(self, conversation_id: str) -> Any:
263
+ return await self.client.request("POST", f"/conversations/{conversation_id}/end")
264
+
265
+ async def respond_send(
266
+ self, conversation_id: str, text: str, *, timeout_s: float | None = None
267
+ ) -> Any:
268
+ payload: dict[str, Any] = {"text": text}
269
+ kwargs: dict[str, Any] = {"json": payload}
270
+ if timeout_s is not None:
271
+ payload["timeout_s"] = timeout_s
272
+ kwargs["timeout"] = max(30.0, timeout_s + 20.0)
273
+ return await self.client.request(
274
+ "POST",
275
+ f"/conversations/{conversation_id}/respond",
276
+ **kwargs,
277
+ )
278
+
279
+ async def respond_poll(self, conversation_id: str) -> Any:
280
+ return await self.client.request("GET", f"/conversations/{conversation_id}/respond")
281
+
282
+ async def chat_turn(
283
+ self,
284
+ conversation_id: str,
285
+ text: str,
286
+ *,
287
+ timeout_s: float = 20.0,
288
+ poll_s: float = 0.4,
289
+ ) -> str:
290
+ sent = await self.respond_send(conversation_id, text, timeout_s=timeout_s)
291
+ if isinstance(sent, dict) and sent.get("status") == "ready":
292
+ return sent.get("text", "")
293
+ loop = asyncio.get_event_loop()
294
+ deadline = loop.time() + timeout_s
295
+ while loop.time() < deadline:
296
+ reply = await self.respond_poll(conversation_id)
297
+ if isinstance(reply, dict) and reply.get("status") == "ready":
298
+ return reply.get("text", "")
299
+ await asyncio.sleep(poll_s)
300
+ raise TimeoutError(
301
+ f"No reply within {timeout_s}s for conversation {conversation_id}"
302
+ )
303
+
304
+
305
+ class BuilderResource:
306
+ """Builder endpoints. Hits RQH /v2/builder/... directly with x-api-key."""
307
+
308
+ def __init__(self, client: TavusClient) -> None:
309
+ self.client = client
310
+
311
+ async def create(self, payload: dict[str, Any]) -> Any:
312
+ return await self.client.request("POST", "/builder", json=payload)
313
+
314
+ async def list(self, **params: Any) -> Any:
315
+ return await self.client.request("GET", "/builder", params=_clean(params))
316
+
317
+ async def get(self, builder_id: str) -> Any:
318
+ return await self.client.request("GET", f"/builder/{builder_id}")
319
+
320
+ async def delete(self, builder_id: str) -> Any:
321
+ return await self.client.request("DELETE", f"/builder/{builder_id}")
322
+
323
+ async def chat_history(self, builder_id: str, limit: int = 50) -> Any:
324
+ return await self.client.request(
325
+ "GET", f"/builder/{builder_id}/chat", params={"limit": limit}
326
+ )
327
+
328
+ async def append_messages(
329
+ self, builder_id: str, messages: list[dict[str, str]]
330
+ ) -> Any:
331
+ return await self.client.request(
332
+ "POST", f"/builder/{builder_id}/append-messages", json={"messages": messages}
333
+ )
334
+
335
+ async def update_objectives(self, builder_id: str, message: str) -> Any:
336
+ return await self.client.request(
337
+ "POST",
338
+ f"/builder/{builder_id}/update-objectives",
339
+ json={"message": message},
340
+ timeout=BUILDER_LLM_TIMEOUT_S,
341
+ )
342
+
343
+ async def update_guardrails(self, builder_id: str, message: str) -> Any:
344
+ return await self.client.request(
345
+ "POST",
346
+ f"/builder/{builder_id}/update-guardrails",
347
+ json={"message": message},
348
+ timeout=BUILDER_LLM_TIMEOUT_S,
349
+ )
350
+
351
+ async def update_greeting(self, builder_id: str, message: str) -> Any:
352
+ return await self.client.request(
353
+ "POST",
354
+ f"/builder/{builder_id}/update-greeting",
355
+ json={"message": message},
356
+ timeout=BUILDER_LLM_TIMEOUT_S,
357
+ )
358
+
359
+ async def update_personality(
360
+ self,
361
+ builder_id: str,
362
+ message: str,
363
+ *,
364
+ persona_name: bool = False,
365
+ system_prompt: bool = False,
366
+ ) -> Any:
367
+ return await self.client.request(
368
+ "POST",
369
+ f"/builder/{builder_id}/update-personality",
370
+ json={
371
+ "message": message,
372
+ "persona_name": persona_name,
373
+ "system_prompt": system_prompt,
374
+ },
375
+ timeout=BUILDER_LLM_TIMEOUT_S,
376
+ )
377
+
378
+ async def publish(self, builder_id: str) -> Any:
379
+ return await self.client.request(
380
+ "POST", f"/builder/{builder_id}/publish"
381
+ )
382
+
383
+ async def chat(self, builder_id: str, message: str) -> dict[str, Any]:
384
+ """Send a chat turn. Drains the streamed body (text + `[BREAK]` +
385
+ trailing JSON) into one structured response: ``{text, suggestions,
386
+ draft_ready, targets}``.
387
+ """
388
+ async with self.client._client.stream(
389
+ "POST",
390
+ f"/builder/{builder_id}/chat",
391
+ json={"message": message},
392
+ timeout=BUILDER_LLM_TIMEOUT_S,
393
+ ) as response:
394
+ if response.is_error:
395
+ body_bytes = await response.aread()
396
+ try:
397
+ body: Any = _json.loads(body_bytes)
398
+ except ValueError:
399
+ body = body_bytes.decode("utf-8", errors="replace")
400
+ raise TavusApiError(
401
+ f"Tavus API POST /builder/{builder_id}/chat failed with {response.status_code}",
402
+ status_code=response.status_code,
403
+ body=body,
404
+ )
405
+ chunks: list[str] = []
406
+ async for chunk in response.aiter_text():
407
+ chunks.append(chunk)
408
+ return _parse_builder_chat_stream("".join(chunks))
409
+
410
+
411
+ def _parse_builder_chat_stream(body: str) -> dict[str, Any]:
412
+ """Drain the builder chat stream into ``{text, suggestions,
413
+ draft_ready, targets}``.
414
+
415
+ Wire format (concatenated chunks):
416
+ <text chunks>...[STOP]<json>[BREAK]
417
+
418
+ The text frame ends at ``[STOP]``. The JSON payload sits between
419
+ ``[STOP]`` and the trailing ``[BREAK]``. Either marker may be
420
+ absent — a turn that fails before reaching autocomplete arrives
421
+ without ``[BREAK]``; a turn that emits no streamed text arrives
422
+ without ``[STOP]``.
423
+ """
424
+ text_part, stop_sep, after_stop = body.partition(STOP_MARKER)
425
+ if stop_sep:
426
+ json_segment = after_stop
427
+ else:
428
+ # No [STOP] — try to recover JSON before [BREAK] from the tail.
429
+ json_segment = ""
430
+ if BREAK_MARKER in text_part:
431
+ text_part, _, json_segment = text_part.rpartition(BREAK_MARKER)
432
+ json_segment, _, _ = json_segment.partition(BREAK_MARKER)
433
+ text_part = text_part.strip()
434
+ data: dict[str, Any] = {}
435
+ if json_segment.strip():
436
+ try:
437
+ parsed = _json.loads(json_segment.strip())
438
+ if isinstance(parsed, dict):
439
+ data = parsed
440
+ except ValueError:
441
+ pass
442
+ return {
443
+ "text": text_part,
444
+ "suggestions": list(data.get("suggestions", []) or []),
445
+ "draft_ready": bool(data.get("draft_ready", False)),
446
+ "targets": list(data.get("targets", []) or []),
447
+ }
448
+
449
+
450
+ def _clean(params: dict[str, Any]) -> dict[str, Any]:
451
+ return {key: value for key, value in params.items() if value is not None}