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,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,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}
|