sveda-python-sdk 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sveda AI Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: sveda-python-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Sveda AI sidecar HTTP API
5
+ Author: Neresson
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/neresson/sveda-python-sdk
8
+ Project-URL: Repository, https://github.com/neresson/sveda-python-sdk
9
+ Keywords: sveda,ai,copilot,sdk,client
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: httpx>=0.27
14
+ Dynamic: license-file
15
+
16
+ # sveda-python-sdk
17
+
18
+ Python SDK for the Sveda AI sidecar HTTP API.
19
+
20
+ PyPI: `sveda-python-sdk` (import `sveda`)
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install sveda-python-sdk
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```python
31
+ from sveda import SvedaClient, start_host_session
32
+
33
+ client = SvedaClient(base_url="http://127.0.0.1:8787", host_api_key="...")
34
+ tok = client.embed.create_token(visitor_id="flask-playground")
35
+
36
+ client = SvedaClient(base_url="http://127.0.0.1:8787", embed_token=tok.token)
37
+ for event in client.chat.create_streamed(
38
+ messages=[{"role": "user", "content": "Hi"}],
39
+ chat_id="c1",
40
+ ):
41
+ print(event.type)
42
+
43
+ session = start_host_session(
44
+ "http://127.0.0.1:8787",
45
+ "host-api-key",
46
+ "flask-playground",
47
+ )
48
+ ```
49
+
50
+ ## License
51
+
52
+ MIT
@@ -0,0 +1,37 @@
1
+ # sveda-python-sdk
2
+
3
+ Python SDK for the Sveda AI sidecar HTTP API.
4
+
5
+ PyPI: `sveda-python-sdk` (import `sveda`)
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install sveda-python-sdk
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ from sveda import SvedaClient, start_host_session
17
+
18
+ client = SvedaClient(base_url="http://127.0.0.1:8787", host_api_key="...")
19
+ tok = client.embed.create_token(visitor_id="flask-playground")
20
+
21
+ client = SvedaClient(base_url="http://127.0.0.1:8787", embed_token=tok.token)
22
+ for event in client.chat.create_streamed(
23
+ messages=[{"role": "user", "content": "Hi"}],
24
+ chat_id="c1",
25
+ ):
26
+ print(event.type)
27
+
28
+ session = start_host_session(
29
+ "http://127.0.0.1:8787",
30
+ "host-api-key",
31
+ "flask-playground",
32
+ )
33
+ ```
34
+
35
+ ## License
36
+
37
+ MIT
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "sveda-python-sdk"
3
+ version = "0.1.0"
4
+ description = "Python SDK for the Sveda AI sidecar HTTP API"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.11"
8
+ authors = [
9
+ { name = "Neresson" },
10
+ ]
11
+ keywords = ["sveda", "ai", "copilot", "sdk", "client"]
12
+ dependencies = [
13
+ "httpx>=0.27",
14
+ ]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/neresson/sveda-python-sdk"
18
+ Repository = "https://github.com/neresson/sveda-python-sdk"
19
+
20
+ [build-system]
21
+ requires = ["setuptools>=68"]
22
+ build-backend = "setuptools.build_meta"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,26 @@
1
+ from sveda.client import SvedaClient
2
+ from sveda.exceptions import (
3
+ APIError,
4
+ AuthenticationError,
5
+ TransportError,
6
+ UnserializableResponse,
7
+ SvedaError,
8
+ )
9
+ from sveda.session import start_host_session
10
+ from sveda.streaming import StreamEvent
11
+ from sveda.types import EmbedToken, Message
12
+
13
+ __all__ = [
14
+ "APIError",
15
+ "AuthenticationError",
16
+ "EmbedToken",
17
+ "Message",
18
+ "StreamEvent",
19
+ "TransportError",
20
+ "UnserializableResponse",
21
+ "SvedaClient",
22
+ "SvedaError",
23
+ "start_host_session",
24
+ ]
25
+
26
+ __version__ = "0.1.0"
@@ -0,0 +1,245 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator, Mapping
4
+ from typing import Any
5
+ from urllib.parse import quote
6
+
7
+ import httpx
8
+
9
+ from sveda.exceptions import (
10
+ APIError,
11
+ AuthenticationError,
12
+ TransportError,
13
+ UnserializableResponse,
14
+ )
15
+ from sveda.streaming import StreamEvent, iter_sse_lines
16
+ from sveda.types import EmbedToken, Message
17
+
18
+ ACCEPT_JSON = "application/json"
19
+ ACCEPT_STREAM = "application/vnd.sveda.stream+json"
20
+
21
+
22
+ class SvedaClient:
23
+ def __init__(
24
+ self,
25
+ base_url: str,
26
+ *,
27
+ host_api_key: str | None = None,
28
+ embed_token: str | None = None,
29
+ timeout: float = 30.0,
30
+ connect_timeout: float = 5.0,
31
+ http_client: httpx.Client | None = None,
32
+ ) -> None:
33
+ self._base_url = base_url.rstrip("/")
34
+ self._host_api_key = host_api_key or None
35
+ self._embed_token = embed_token or None
36
+ self._owns_client = http_client is None
37
+ self._http = http_client or httpx.Client(
38
+ timeout=httpx.Timeout(timeout, connect=connect_timeout),
39
+ )
40
+ self.embed = EmbedResource(self)
41
+ self.chat = ChatResource(self)
42
+ self.histories = HistoriesResource(self)
43
+
44
+ def close(self) -> None:
45
+ if self._owns_client:
46
+ self._http.close()
47
+
48
+ def __enter__(self) -> SvedaClient:
49
+ return self
50
+
51
+ def __exit__(self, *exc: object) -> None:
52
+ self.close()
53
+
54
+ def _url(self, path: str) -> str:
55
+ return f"{self._base_url}/{path.lstrip('/')}"
56
+
57
+ def _auth_headers(self) -> dict[str, str]:
58
+ headers: dict[str, str] = {}
59
+ if self._host_api_key is not None:
60
+ headers["Authorization"] = f"Bearer {self._host_api_key}"
61
+ if self._embed_token is not None:
62
+ headers["X-Sveda-Embed-Token"] = self._embed_token
63
+ return headers
64
+
65
+ def request_json(
66
+ self,
67
+ method: str,
68
+ path: str,
69
+ payload: Mapping[str, Any] | None = None,
70
+ ) -> dict[str, Any]:
71
+ headers = {**self._auth_headers(), "Accept": ACCEPT_JSON}
72
+ json_payload: Mapping[str, Any] | None = dict(payload) if payload else None
73
+ if method.upper() in {"GET", "HEAD", "DELETE"}:
74
+ json_payload = None
75
+ elif json_payload is None:
76
+ json_payload = {}
77
+ try:
78
+ response = self._http.request(
79
+ method,
80
+ self._url(path),
81
+ json=json_payload,
82
+ headers=headers,
83
+ )
84
+ except httpx.RequestError as exc:
85
+ raise TransportError(str(exc)) from exc
86
+ return self._decode_json(response)
87
+
88
+ def request_stream(
89
+ self,
90
+ method: str,
91
+ path: str,
92
+ payload: Mapping[str, Any] | None = None,
93
+ ) -> Iterator[StreamEvent]:
94
+ headers = {
95
+ **self._auth_headers(),
96
+ "Accept": ACCEPT_STREAM,
97
+ "Content-Type": "application/json",
98
+ }
99
+ try:
100
+ with self._http.stream(
101
+ method,
102
+ self._url(path),
103
+ json=dict(payload or {}),
104
+ headers=headers,
105
+ ) as response:
106
+ if response.status_code < 200 or response.status_code >= 300:
107
+ response.read()
108
+ self._raise_for_status(response)
109
+ yield from iter_sse_lines(response.iter_lines())
110
+ except httpx.RequestError as exc:
111
+ raise TransportError(str(exc)) from exc
112
+
113
+ def _decode_json(self, response: httpx.Response) -> dict[str, Any]:
114
+ self._raise_for_status(response)
115
+ if response.content == b"":
116
+ return {}
117
+ try:
118
+ decoded = response.json()
119
+ except ValueError as exc:
120
+ raise UnserializableResponse(
121
+ "Unable to decode Sveda API response as JSON."
122
+ ) from exc
123
+ if not isinstance(decoded, dict):
124
+ raise UnserializableResponse("Unable to decode Sveda API response as JSON.")
125
+ return decoded
126
+
127
+ def _raise_for_status(self, response: httpx.Response) -> None:
128
+ status = response.status_code
129
+ if status in {401, 403}:
130
+ raise AuthenticationError(
131
+ f"Sveda API authentication failed with status {status}"
132
+ )
133
+ if status < 200 or status >= 300:
134
+ data: Any = None
135
+ try:
136
+ data = response.json()
137
+ except ValueError:
138
+ data = None
139
+ message = f"Sveda API request failed with status {status}"
140
+ if isinstance(data, dict) and isinstance(data.get("message"), str):
141
+ message = data["message"]
142
+ raise APIError(
143
+ message,
144
+ status_code=status,
145
+ response=data if isinstance(data, dict) else None,
146
+ )
147
+
148
+
149
+ class EmbedResource:
150
+ def __init__(self, client: SvedaClient) -> None:
151
+ self._client = client
152
+
153
+ def create_token(
154
+ self,
155
+ visitor_id: str | None = None,
156
+ *,
157
+ host_mcp_url: str | None = None,
158
+ host_mcp_token: str | None = None,
159
+ ) -> EmbedToken:
160
+ payload: dict[str, Any] = {}
161
+ if visitor_id:
162
+ payload["visitor_id"] = visitor_id
163
+ if host_mcp_url and host_mcp_token:
164
+ payload["host_mcp_url"] = host_mcp_url
165
+ payload["host_mcp_token"] = host_mcp_token
166
+ return EmbedToken.from_dict(
167
+ self._client.request_json("POST", "/sveda/embed/token", payload)
168
+ )
169
+
170
+ def config(self) -> dict[str, Any]:
171
+ return self._client.request_json("GET", "/sveda/embed/config")
172
+
173
+
174
+ class ChatResource:
175
+ def __init__(self, client: SvedaClient) -> None:
176
+ self._client = client
177
+
178
+ def create(
179
+ self,
180
+ messages: list[dict[str, Any]],
181
+ *,
182
+ chat_id: str | None = None,
183
+ **extra: Any,
184
+ ) -> Message:
185
+ return Message.from_dict(
186
+ self._client.request_json(
187
+ "POST",
188
+ "/sveda/message",
189
+ _chat_payload(messages, chat_id, extra),
190
+ )
191
+ )
192
+
193
+ def create_streamed(
194
+ self,
195
+ messages: list[dict[str, Any]],
196
+ *,
197
+ chat_id: str | None = None,
198
+ **extra: Any,
199
+ ) -> Iterator[StreamEvent]:
200
+ return self._client.request_stream(
201
+ "POST",
202
+ "/sveda/stream",
203
+ _chat_payload(messages, chat_id, extra),
204
+ )
205
+
206
+
207
+ class HistoriesResource:
208
+ def __init__(self, client: SvedaClient) -> None:
209
+ self._client = client
210
+
211
+ def list(self) -> dict[str, Any]:
212
+ return self._client.request_json("GET", "/sveda/chat-histories")
213
+
214
+ def get(self, chat_id: str) -> dict[str, Any]:
215
+ return self._client.request_json("GET", _history_path(chat_id))
216
+
217
+ def rename(self, chat_id: str, title: str) -> dict[str, Any]:
218
+ return self._client.request_json(
219
+ "PATCH",
220
+ _history_path(chat_id),
221
+ {"title": title},
222
+ )
223
+
224
+ def delete(self, chat_id: str) -> dict[str, Any]:
225
+ return self._client.request_json("DELETE", _history_path(chat_id))
226
+
227
+
228
+ def _history_path(chat_id: str) -> str:
229
+ return "/sveda/chat-histories/" + quote(chat_id, safe="")
230
+
231
+
232
+ def _chat_payload(
233
+ messages: list[dict[str, Any]],
234
+ chat_id: str | None,
235
+ extra: Mapping[str, Any],
236
+ ) -> dict[str, Any]:
237
+ payload = dict(extra)
238
+ payload["messages"] = messages
239
+ if "client_tools" in payload:
240
+ payload["clientTools"] = payload.pop("client_tools")
241
+ if "chat_id" in payload:
242
+ payload["chatId"] = payload.pop("chat_id")
243
+ if chat_id is not None:
244
+ payload["chatId"] = chat_id
245
+ return payload
@@ -0,0 +1,29 @@
1
+ from typing import Any
2
+
3
+
4
+ class SvedaError(Exception):
5
+ pass
6
+
7
+
8
+ class AuthenticationError(SvedaError):
9
+ pass
10
+
11
+
12
+ class APIError(SvedaError):
13
+ def __init__(
14
+ self,
15
+ message: str,
16
+ status_code: int = 0,
17
+ response: dict[str, Any] | None = None,
18
+ ) -> None:
19
+ super().__init__(message)
20
+ self.status_code = status_code
21
+ self.response = response
22
+
23
+
24
+ class TransportError(SvedaError):
25
+ pass
26
+
27
+
28
+ class UnserializableResponse(SvedaError):
29
+ pass
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from sveda.client import SvedaClient
8
+ from sveda.exceptions import APIError
9
+
10
+
11
+ def start_host_session(
12
+ base_url: str,
13
+ host_api_key: str,
14
+ visitor_id: str,
15
+ *,
16
+ host_mcp_url: str | None = None,
17
+ host_mcp_token: str | None = None,
18
+ http_client: httpx.Client | None = None,
19
+ ) -> dict[str, Any]:
20
+ origin = base_url.rstrip("/")
21
+ with SvedaClient(
22
+ origin,
23
+ host_api_key=host_api_key,
24
+ http_client=http_client,
25
+ ) as client:
26
+ token = client.embed.create_token(
27
+ visitor_id=visitor_id,
28
+ host_mcp_url=host_mcp_url,
29
+ host_mcp_token=host_mcp_token,
30
+ )
31
+ if token.token == "":
32
+ raise APIError("Sidecar returned an empty embed token.")
33
+ return {
34
+ "origin": origin,
35
+ "token": token.token,
36
+ "expires_in": token.expires_in,
37
+ "appearance": token.appearance,
38
+ }
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Iterable, Iterator
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ SSE_DONE_LINE = "data: [DONE]"
9
+
10
+ STREAM_EVENTS = frozenset(
11
+ {
12
+ "message.start",
13
+ "text.delta",
14
+ "reasoning.delta",
15
+ "tool.call",
16
+ "tool.result",
17
+ "tool.progress",
18
+ "context.usage",
19
+ "chat.title",
20
+ "max_steps",
21
+ "message.end",
22
+ "error",
23
+ }
24
+ )
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class StreamEvent:
29
+ type: str
30
+ payload: dict[str, Any]
31
+
32
+ def __getattr__(self, name: str) -> Any:
33
+ try:
34
+ return self.payload[name]
35
+ except KeyError as exc:
36
+ raise AttributeError(name) from exc
37
+
38
+ def to_dict(self) -> dict[str, Any]:
39
+ return self.payload
40
+
41
+
42
+ def parse_sse_line(line: str) -> StreamEvent | None:
43
+ trimmed = line.strip()
44
+ if not trimmed.startswith("data:"):
45
+ return None
46
+
47
+ payload = trimmed[5:].strip()
48
+ if payload == "" or payload == "[DONE]":
49
+ return None
50
+
51
+ try:
52
+ decoded = json.loads(payload)
53
+ except json.JSONDecodeError:
54
+ return None
55
+
56
+ if not isinstance(decoded, dict):
57
+ return None
58
+
59
+ event_type = decoded.get("type")
60
+ if not isinstance(event_type, str) or event_type not in STREAM_EVENTS:
61
+ return None
62
+
63
+ return StreamEvent(type=event_type, payload=decoded)
64
+
65
+
66
+ def iter_sse_lines(lines: Iterable[str]) -> Iterator[StreamEvent]:
67
+ for line in lines:
68
+ event = parse_sse_line(line)
69
+ if event is not None:
70
+ yield event
71
+
72
+
73
+ def iter_sse_text(content: str) -> Iterator[StreamEvent]:
74
+ return iter_sse_lines(content.splitlines())
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Mapping
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class EmbedToken:
9
+ token: str
10
+ visitor_id: str
11
+ expires_in: int
12
+ appearance: dict[str, Any] | None = None
13
+
14
+ @classmethod
15
+ def from_dict(cls, payload: Mapping[str, Any]) -> EmbedToken:
16
+ appearance = payload.get("appearance")
17
+ raw_expires = payload.get("expires_in", 3600)
18
+ if raw_expires is None:
19
+ raw_expires = 3600
20
+ return cls(
21
+ token=str(payload.get("token", "")),
22
+ visitor_id=str(payload.get("visitor_id", "")),
23
+ expires_in=max(60, int(raw_expires)),
24
+ appearance=appearance if isinstance(appearance, dict) else None,
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Message:
30
+ explanation: str
31
+ tokens_used: int
32
+ chat_id: str
33
+ payload: dict[str, Any]
34
+
35
+ @classmethod
36
+ def from_dict(cls, payload: Mapping[str, Any]) -> Message:
37
+ data = dict(payload)
38
+ return cls(
39
+ explanation=str(data.get("explanation", "")),
40
+ tokens_used=int(data.get("tokens_used") or 0),
41
+ chat_id=str(data.get("chat_id", "")),
42
+ payload=data,
43
+ )
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: sveda-python-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Sveda AI sidecar HTTP API
5
+ Author: Neresson
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/neresson/sveda-python-sdk
8
+ Project-URL: Repository, https://github.com/neresson/sveda-python-sdk
9
+ Keywords: sveda,ai,copilot,sdk,client
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: httpx>=0.27
14
+ Dynamic: license-file
15
+
16
+ # sveda-python-sdk
17
+
18
+ Python SDK for the Sveda AI sidecar HTTP API.
19
+
20
+ PyPI: `sveda-python-sdk` (import `sveda`)
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install sveda-python-sdk
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```python
31
+ from sveda import SvedaClient, start_host_session
32
+
33
+ client = SvedaClient(base_url="http://127.0.0.1:8787", host_api_key="...")
34
+ tok = client.embed.create_token(visitor_id="flask-playground")
35
+
36
+ client = SvedaClient(base_url="http://127.0.0.1:8787", embed_token=tok.token)
37
+ for event in client.chat.create_streamed(
38
+ messages=[{"role": "user", "content": "Hi"}],
39
+ chat_id="c1",
40
+ ):
41
+ print(event.type)
42
+
43
+ session = start_host_session(
44
+ "http://127.0.0.1:8787",
45
+ "host-api-key",
46
+ "flask-playground",
47
+ )
48
+ ```
49
+
50
+ ## License
51
+
52
+ MIT
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/sveda/__init__.py
5
+ src/sveda/client.py
6
+ src/sveda/exceptions.py
7
+ src/sveda/session.py
8
+ src/sveda/streaming.py
9
+ src/sveda/types.py
10
+ src/sveda_python_sdk.egg-info/PKG-INFO
11
+ src/sveda_python_sdk.egg-info/SOURCES.txt
12
+ src/sveda_python_sdk.egg-info/dependency_links.txt
13
+ src/sveda_python_sdk.egg-info/requires.txt
14
+ src/sveda_python_sdk.egg-info/top_level.txt
15
+ tests/test_client.py
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import unittest
5
+
6
+ import httpx
7
+
8
+ from sveda import SvedaClient, start_host_session
9
+ from sveda.exceptions import APIError, AuthenticationError
10
+
11
+
12
+ def _client(
13
+ handler: httpx.MockTransport | httpx.Client,
14
+ *,
15
+ host_api_key: str | None = "host-secret",
16
+ embed_token: str | None = None,
17
+ ) -> SvedaClient:
18
+ http_client = (
19
+ handler
20
+ if isinstance(handler, httpx.Client)
21
+ else httpx.Client(transport=handler)
22
+ )
23
+ return SvedaClient(
24
+ "https://sveda.test",
25
+ host_api_key=host_api_key,
26
+ embed_token=embed_token,
27
+ http_client=http_client,
28
+ )
29
+
30
+
31
+ class ClientTest(unittest.TestCase):
32
+ def test_create_token_sends_bearer_auth(self) -> None:
33
+ captured: dict[str, object] = {}
34
+
35
+ def handler(request: httpx.Request) -> httpx.Response:
36
+ captured["method"] = request.method
37
+ captured["path"] = request.url.path
38
+ captured["authorization"] = request.headers.get("Authorization")
39
+ captured["body"] = json.loads(request.content)
40
+ return httpx.Response(
41
+ 200,
42
+ json={
43
+ "token": "sveda_embed_test",
44
+ "visitor_id": "visitor-1",
45
+ "expires_in": 3600,
46
+ "appearance": {"accent": "#c45c26"},
47
+ },
48
+ )
49
+
50
+ client = _client(httpx.MockTransport(handler))
51
+ try:
52
+ token = client.embed.create_token(
53
+ visitor_id="visitor-1",
54
+ host_mcp_url="https://app.test/mcp/sveda",
55
+ host_mcp_token="mcp-token",
56
+ )
57
+ finally:
58
+ client.close()
59
+
60
+ self.assertEqual(token.token, "sveda_embed_test")
61
+ self.assertEqual(token.visitor_id, "visitor-1")
62
+ self.assertEqual(token.expires_in, 3600)
63
+ self.assertEqual(token.appearance, {"accent": "#c45c26"})
64
+ self.assertEqual(captured["method"], "POST")
65
+ self.assertEqual(captured["path"], "/sveda/embed/token")
66
+ self.assertEqual(captured["authorization"], "Bearer host-secret")
67
+ self.assertEqual(
68
+ captured["body"],
69
+ {
70
+ "visitor_id": "visitor-1",
71
+ "host_mcp_url": "https://app.test/mcp/sveda",
72
+ "host_mcp_token": "mcp-token",
73
+ },
74
+ )
75
+
76
+ def test_create_streamed_parses_events_and_skips_done(self) -> None:
77
+ captured: dict[str, object] = {}
78
+
79
+ def handler(request: httpx.Request) -> httpx.Response:
80
+ captured["method"] = request.method
81
+ captured["path"] = request.url.path
82
+ captured["accept"] = request.headers.get("Accept")
83
+ captured["embed"] = request.headers.get("X-Sveda-Embed-Token")
84
+ captured["body"] = json.loads(request.content)
85
+ return httpx.Response(
86
+ 200,
87
+ text=(
88
+ 'data: {"type":"message.start"}\n\n'
89
+ 'data: {"type":"text.delta","delta":"Hi"}\n\n'
90
+ "data: [DONE]\n\n"
91
+ ),
92
+ )
93
+
94
+ client = _client(
95
+ httpx.MockTransport(handler),
96
+ host_api_key=None,
97
+ embed_token="embed-token",
98
+ )
99
+ try:
100
+ events = list(
101
+ client.chat.create_streamed(
102
+ messages=[{"role": "user", "content": "Hi"}],
103
+ chat_id="c1",
104
+ )
105
+ )
106
+ finally:
107
+ client.close()
108
+
109
+ self.assertEqual(len(events), 2)
110
+ self.assertEqual(events[0].type, "message.start")
111
+ self.assertEqual(events[1].type, "text.delta")
112
+ self.assertEqual(events[1].delta, "Hi")
113
+ self.assertEqual(captured["method"], "POST")
114
+ self.assertEqual(captured["path"], "/sveda/stream")
115
+ self.assertEqual(captured["accept"], "application/vnd.sveda.stream+json")
116
+ self.assertEqual(captured["embed"], "embed-token")
117
+ self.assertEqual(
118
+ captured["body"],
119
+ {
120
+ "messages": [{"role": "user", "content": "Hi"}],
121
+ "chatId": "c1",
122
+ },
123
+ )
124
+
125
+ def test_401_raises_authentication_error(self) -> None:
126
+ def handler(request: httpx.Request) -> httpx.Response:
127
+ return httpx.Response(401, json={"message": "nope"})
128
+
129
+ client = _client(httpx.MockTransport(handler))
130
+ try:
131
+ with self.assertRaises(AuthenticationError) as ctx:
132
+ client.embed.create_token(visitor_id="visitor-1")
133
+ finally:
134
+ client.close()
135
+
136
+ self.assertIn("401", str(ctx.exception))
137
+
138
+ def test_non_auth_error_uses_json_message(self) -> None:
139
+ def handler(request: httpx.Request) -> httpx.Response:
140
+ return httpx.Response(422, json={"message": "visitor_id is too long"})
141
+
142
+ client = _client(httpx.MockTransport(handler))
143
+ try:
144
+ with self.assertRaises(APIError) as ctx:
145
+ client.embed.create_token(visitor_id="visitor-1")
146
+ finally:
147
+ client.close()
148
+
149
+ self.assertEqual(str(ctx.exception), "visitor_id is too long")
150
+ self.assertEqual(ctx.exception.status_code, 422)
151
+
152
+ def test_start_host_session(self) -> None:
153
+ captured: dict[str, object] = {}
154
+
155
+ def handler(request: httpx.Request) -> httpx.Response:
156
+ captured["authorization"] = request.headers.get("Authorization")
157
+ captured["body"] = json.loads(request.content)
158
+ captured["path"] = request.url.path
159
+ return httpx.Response(
160
+ 200,
161
+ json={
162
+ "token": "sveda_embed_host",
163
+ "visitor_id": "flask-playground",
164
+ "expires_in": 1200,
165
+ },
166
+ )
167
+
168
+ http_client = httpx.Client(transport=httpx.MockTransport(handler))
169
+ try:
170
+ session = start_host_session(
171
+ "https://sveda.test/",
172
+ "host-secret",
173
+ "flask-playground",
174
+ http_client=http_client,
175
+ )
176
+ finally:
177
+ http_client.close()
178
+
179
+ self.assertEqual(
180
+ session,
181
+ {
182
+ "origin": "https://sveda.test",
183
+ "token": "sveda_embed_host",
184
+ "expires_in": 1200,
185
+ "appearance": None,
186
+ },
187
+ )
188
+ self.assertEqual(captured["authorization"], "Bearer host-secret")
189
+ self.assertEqual(captured["path"], "/sveda/embed/token")
190
+ self.assertEqual(captured["body"], {"visitor_id": "flask-playground"})
191
+
192
+ def test_message_and_histories(self) -> None:
193
+ def handler(request: httpx.Request) -> httpx.Response:
194
+ if request.method == "POST" and request.url.path == "/sveda/message":
195
+ return httpx.Response(
196
+ 200,
197
+ json={
198
+ "explanation": "Hello",
199
+ "tokens_used": 12,
200
+ "chat_id": "chat-1",
201
+ },
202
+ )
203
+ if request.method == "GET" and request.url.path == "/sveda/chat-histories":
204
+ return httpx.Response(200, json={"histories": []})
205
+ return httpx.Response(404, json={"message": "unexpected"})
206
+
207
+ client = _client(
208
+ httpx.MockTransport(handler),
209
+ host_api_key=None,
210
+ embed_token="embed-token",
211
+ )
212
+ try:
213
+ message = client.chat.create(
214
+ messages=[{"role": "user", "content": "Hello"}],
215
+ chat_id="chat-1",
216
+ )
217
+ histories = client.histories.list()
218
+ finally:
219
+ client.close()
220
+
221
+ self.assertEqual(message.explanation, "Hello")
222
+ self.assertEqual(message.tokens_used, 12)
223
+ self.assertEqual(message.chat_id, "chat-1")
224
+ self.assertIn("histories", histories)
225
+
226
+
227
+ if __name__ == "__main__":
228
+ unittest.main()