zihin 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.
zihin-0.1.0/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .env
9
+ .env.*
10
+ .env-*
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-05)
4
+
5
+ First release: `Zihin` client with `list_agents`, `invoke_agent` and `stream_agent`, a single
6
+ `ZihinError` with a `kind` to branch on, and an offline test suite.
zihin-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zihin.ai
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.
zihin-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.5
2
+ Name: zihin
3
+ Version: 0.1.0
4
+ Summary: Official Python client for invoking Zihin.ai hosted AI agents (buffered and streaming calls).
5
+ Project-URL: Homepage, https://zihin.ai
6
+ Project-URL: Documentation, https://docs.zihin.ai
7
+ Project-URL: Repository, https://github.com/zihin-ai/zihin-python
8
+ Project-URL: Issues, https://github.com/zihin-ai/zihin-python/issues
9
+ Author: Zihin
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agent-sdk,ai-agents,llm,sse,streaming,zihin
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: httpx<1,>=0.25
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # zihin
30
+
31
+ Official Python client for [Zihin.ai](https://zihin.ai), a platform for building and running hosted
32
+ AI agents. Use it to invoke an agent from your backend and get its answer, buffered or as a stream.
33
+
34
+ This is an early release (0.1.x). It covers the direct invocation path. Structured context,
35
+ attachments, async triggers and tasks, which the Node.js client
36
+ [`@zihin/agent-client`](https://www.npmjs.com/package/@zihin/agent-client) already has, are not
37
+ implemented here yet.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install zihin
43
+ ```
44
+
45
+ Requires Python 3.9 or later.
46
+
47
+ ## Usage
48
+
49
+ Create an API key in the Zihin console. Keys start with `zhn_live_`, `zhn_test_` or `zhn_dev_`.
50
+ The client reads `ZIHIN_API_KEY` from the environment when no key is passed.
51
+
52
+ ```python
53
+ from zihin import Zihin
54
+
55
+ with Zihin() as zihin:
56
+ for agent in zihin.list_agents():
57
+ print(agent.id, agent.name)
58
+
59
+ result = zihin.invoke_agent("AGENT_ID", "What is the status of order 1234?")
60
+ print(result.content)
61
+
62
+ # Continue the same conversation
63
+ follow_up = zihin.invoke_agent("AGENT_ID", "And when does it ship?", session_id=result.session_id)
64
+ ```
65
+
66
+ ### Streaming
67
+
68
+ ```python
69
+ for event in zihin.stream_agent("AGENT_ID", "Hello"):
70
+ if event.type == "token":
71
+ print(event.content, end="", flush=True)
72
+ elif event.type == "tool":
73
+ print("\n[tool]", event.data)
74
+ ```
75
+
76
+ `token` events are the model's working text across iterations. The answer the agent delivers is the
77
+ `response` event, which is what `invoke_agent` returns. Stopping the iteration early closes the
78
+ connection and cancels the turn on the server.
79
+
80
+ ### Errors
81
+
82
+ Every failure raises `ZihinError`, with a `kind` you can branch on:
83
+
84
+ ```python
85
+ from zihin import Zihin, ZihinError
86
+
87
+ try:
88
+ Zihin().invoke_agent("AGENT_ID", "Hi")
89
+ except ZihinError as err:
90
+ print(err.kind, err.status, err.code, err.message)
91
+ ```
92
+
93
+ | `kind` | Meaning |
94
+ |---|---|
95
+ | `config` | Missing or malformed API key, or missing arguments |
96
+ | `auth` | The key was rejected, or its role does not allow the action |
97
+ | `not_found` | The agent does not exist or is not accessible with this key |
98
+ | `rate_limit` | Too many requests |
99
+ | `timeout` | The request or the agent turn timed out |
100
+ | `agent` | The agent turn ended with an error reported by the server |
101
+ | `server` | The Zihin server failed (5xx) |
102
+ | `network` | The connection failed |
103
+ | `protocol` | The response did not match the API contract |
104
+
105
+ `request_id`, `execution_id` and `session_id` are set on the error when the server provides them;
106
+ include them when contacting support.
107
+
108
+ ## Configuration
109
+
110
+ ```python
111
+ Zihin(
112
+ api_key="zhn_live_...", # default: ZIHIN_API_KEY
113
+ base_url="https://llm.zihin.ai", # default
114
+ timeout=35.0, # seconds, for short requests
115
+ stream_timeout=200.0, # seconds, for an agent turn
116
+ )
117
+ ```
118
+
119
+ The stream timeout is above the server's own turn deadline on purpose, so the client receives the
120
+ server's structured outcome instead of cutting the connection first.
121
+
122
+ An agent turn costs LLM tokens on your Zihin workspace.
123
+
124
+ ## Development
125
+
126
+ ```bash
127
+ pip install -e ".[dev]"
128
+ pytest
129
+ ```
130
+
131
+ The tests are offline: every request is answered by a mock transport.
132
+
133
+ ## License
134
+
135
+ MIT
zihin-0.1.0/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # zihin
2
+
3
+ Official Python client for [Zihin.ai](https://zihin.ai), a platform for building and running hosted
4
+ AI agents. Use it to invoke an agent from your backend and get its answer, buffered or as a stream.
5
+
6
+ This is an early release (0.1.x). It covers the direct invocation path. Structured context,
7
+ attachments, async triggers and tasks, which the Node.js client
8
+ [`@zihin/agent-client`](https://www.npmjs.com/package/@zihin/agent-client) already has, are not
9
+ implemented here yet.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install zihin
15
+ ```
16
+
17
+ Requires Python 3.9 or later.
18
+
19
+ ## Usage
20
+
21
+ Create an API key in the Zihin console. Keys start with `zhn_live_`, `zhn_test_` or `zhn_dev_`.
22
+ The client reads `ZIHIN_API_KEY` from the environment when no key is passed.
23
+
24
+ ```python
25
+ from zihin import Zihin
26
+
27
+ with Zihin() as zihin:
28
+ for agent in zihin.list_agents():
29
+ print(agent.id, agent.name)
30
+
31
+ result = zihin.invoke_agent("AGENT_ID", "What is the status of order 1234?")
32
+ print(result.content)
33
+
34
+ # Continue the same conversation
35
+ follow_up = zihin.invoke_agent("AGENT_ID", "And when does it ship?", session_id=result.session_id)
36
+ ```
37
+
38
+ ### Streaming
39
+
40
+ ```python
41
+ for event in zihin.stream_agent("AGENT_ID", "Hello"):
42
+ if event.type == "token":
43
+ print(event.content, end="", flush=True)
44
+ elif event.type == "tool":
45
+ print("\n[tool]", event.data)
46
+ ```
47
+
48
+ `token` events are the model's working text across iterations. The answer the agent delivers is the
49
+ `response` event, which is what `invoke_agent` returns. Stopping the iteration early closes the
50
+ connection and cancels the turn on the server.
51
+
52
+ ### Errors
53
+
54
+ Every failure raises `ZihinError`, with a `kind` you can branch on:
55
+
56
+ ```python
57
+ from zihin import Zihin, ZihinError
58
+
59
+ try:
60
+ Zihin().invoke_agent("AGENT_ID", "Hi")
61
+ except ZihinError as err:
62
+ print(err.kind, err.status, err.code, err.message)
63
+ ```
64
+
65
+ | `kind` | Meaning |
66
+ |---|---|
67
+ | `config` | Missing or malformed API key, or missing arguments |
68
+ | `auth` | The key was rejected, or its role does not allow the action |
69
+ | `not_found` | The agent does not exist or is not accessible with this key |
70
+ | `rate_limit` | Too many requests |
71
+ | `timeout` | The request or the agent turn timed out |
72
+ | `agent` | The agent turn ended with an error reported by the server |
73
+ | `server` | The Zihin server failed (5xx) |
74
+ | `network` | The connection failed |
75
+ | `protocol` | The response did not match the API contract |
76
+
77
+ `request_id`, `execution_id` and `session_id` are set on the error when the server provides them;
78
+ include them when contacting support.
79
+
80
+ ## Configuration
81
+
82
+ ```python
83
+ Zihin(
84
+ api_key="zhn_live_...", # default: ZIHIN_API_KEY
85
+ base_url="https://llm.zihin.ai", # default
86
+ timeout=35.0, # seconds, for short requests
87
+ stream_timeout=200.0, # seconds, for an agent turn
88
+ )
89
+ ```
90
+
91
+ The stream timeout is above the server's own turn deadline on purpose, so the client receives the
92
+ server's structured outcome instead of cutting the connection first.
93
+
94
+ An agent turn costs LLM tokens on your Zihin workspace.
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ pip install -e ".[dev]"
100
+ pytest
101
+ ```
102
+
103
+ The tests are offline: every request is answered by a mock transport.
104
+
105
+ ## License
106
+
107
+ MIT
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "zihin"
7
+ version = "0.1.0"
8
+ description = "Official Python client for invoking Zihin.ai hosted AI agents (buffered and streaming calls)."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "Zihin" }]
14
+ keywords = ["zihin", "ai-agents", "agent-sdk", "llm", "sse", "streaming"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Libraries",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = ["httpx>=0.25,<1"]
28
+
29
+ [project.optional-dependencies]
30
+ dev = ["pytest>=8"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://zihin.ai"
34
+ Documentation = "https://docs.zihin.ai"
35
+ Repository = "https://github.com/zihin-ai/zihin-python"
36
+ Issues = "https://github.com/zihin-ai/zihin-python/issues"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/zihin"]
40
+
41
+ [tool.hatch.build.targets.sdist]
42
+ include = ["src", "tests", "README.md", "LICENSE", "CHANGELOG.md"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
@@ -0,0 +1,8 @@
1
+ """Official Python client for the Zihin.ai agent platform."""
2
+
3
+ from ._client import Agent, AgentResult, StreamEvent, Usage, Zihin
4
+ from ._errors import ZihinError
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = ["Agent", "AgentResult", "StreamEvent", "Usage", "Zihin", "ZihinError", "__version__"]
@@ -0,0 +1,280 @@
1
+ """Zihin client: list agents, invoke an agent, stream an agent turn."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Dict, Iterator, List, Optional
8
+ from urllib.parse import quote
9
+
10
+ import httpx
11
+
12
+ from ._errors import ZihinError, error_from_response
13
+ from ._sse import parse_sse
14
+
15
+ DEFAULT_BASE_URL = "https://llm.zihin.ai"
16
+ # The server has its own turn deadline; the stream timeout stays above it so the client
17
+ # receives the server's structured outcome instead of cutting the connection first.
18
+ DEFAULT_STREAM_TIMEOUT = 200.0
19
+ DEFAULT_TIMEOUT = 35.0
20
+ _KEY_PREFIXES = ("zhn_live_", "zhn_test_", "zhn_dev_")
21
+
22
+
23
+ @dataclass
24
+ class Agent:
25
+ """An agent visible to the API key."""
26
+
27
+ id: str
28
+ name: Optional[str] = None
29
+ commercial_name: Optional[str] = None
30
+ bio: Optional[str] = None
31
+ type: Optional[str] = None
32
+ status: Optional[str] = None
33
+ tags: List[str] = field(default_factory=list)
34
+
35
+
36
+ @dataclass
37
+ class Usage:
38
+ input_tokens: Optional[int] = None
39
+ output_tokens: Optional[int] = None
40
+ total_tokens: Optional[int] = None
41
+ cost_usd: Optional[float] = None
42
+
43
+
44
+ @dataclass
45
+ class StreamEvent:
46
+ """One event of an agent turn.
47
+
48
+ ``type`` is the server event name (``metadata``, ``status``, ``thinking``, ``tool``,
49
+ ``token``, ``response``, ``metrics``, ``done``, ``error`` and others). ``data`` is the
50
+ decoded payload. ``content`` is filled for ``token`` and ``response`` events.
51
+ """
52
+
53
+ type: str
54
+ data: Any
55
+ content: Optional[str] = None
56
+
57
+
58
+ @dataclass
59
+ class AgentResult:
60
+ """The consolidated result of an agent turn."""
61
+
62
+ content: str
63
+ session_id: Optional[str] = None
64
+ model: Optional[str] = None
65
+ usage: Optional[Usage] = None
66
+ execution_id: Optional[str] = None
67
+ outcome: Optional[str] = None
68
+ sources: Optional[List[Any]] = None
69
+
70
+
71
+ def _d(value: Any) -> dict:
72
+ return value if isinstance(value, dict) else {}
73
+
74
+
75
+ def _s(value: Any) -> Optional[str]:
76
+ return value if isinstance(value, str) and value else None
77
+
78
+
79
+ def _n(value: Any) -> Any:
80
+ return value if isinstance(value, (int, float)) and not isinstance(value, bool) else None
81
+
82
+
83
+ def _usage(value: Any) -> Optional[Usage]:
84
+ u = _d(value)
85
+ if not u:
86
+ return None
87
+ return Usage(
88
+ input_tokens=_n(u.get("input_tokens")) if _n(u.get("input_tokens")) is not None else _n(u.get("prompt_tokens")),
89
+ output_tokens=_n(u.get("output_tokens"))
90
+ if _n(u.get("output_tokens")) is not None
91
+ else _n(u.get("completion_tokens")),
92
+ total_tokens=_n(u.get("total_tokens")),
93
+ cost_usd=_n(u.get("cost_usd")),
94
+ )
95
+
96
+
97
+ class Zihin:
98
+ """Client for the Zihin.ai agent API.
99
+
100
+ ``api_key`` defaults to the ``ZIHIN_API_KEY`` environment variable. Use the client as a
101
+ context manager, or call ``close()`` when done.
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ api_key: Optional[str] = None,
107
+ *,
108
+ base_url: str = DEFAULT_BASE_URL,
109
+ timeout: float = DEFAULT_TIMEOUT,
110
+ stream_timeout: float = DEFAULT_STREAM_TIMEOUT,
111
+ http_client: Optional[httpx.Client] = None,
112
+ ) -> None:
113
+ key = api_key if api_key is not None else os.environ.get("ZIHIN_API_KEY", "")
114
+ if not key:
115
+ raise ZihinError("config", "No API key. Pass api_key or set the ZIHIN_API_KEY environment variable.")
116
+ if not key.startswith(_KEY_PREFIXES):
117
+ raise ZihinError("config", "Invalid API key format. Zihin keys start with zhn_live_, zhn_test_ or zhn_dev_.")
118
+ self._base_url = base_url.rstrip("/")
119
+ self._timeout = timeout
120
+ self._stream_timeout = stream_timeout
121
+ self._headers = {"Content-Type": "application/json", "Accept": "application/json", "X-Api-Key": key}
122
+ self._owns_client = http_client is None
123
+ self._http = http_client or httpx.Client()
124
+
125
+ def __enter__(self) -> "Zihin":
126
+ return self
127
+
128
+ def __exit__(self, *exc: Any) -> None:
129
+ self.close()
130
+
131
+ def close(self) -> None:
132
+ if self._owns_client:
133
+ self._http.close()
134
+
135
+ def __repr__(self) -> str:
136
+ # The API key is deliberately left out.
137
+ return f"Zihin(base_url={self._base_url!r})"
138
+
139
+ # -- agents ---------------------------------------------------------------------------
140
+
141
+ def list_agents(self) -> List[Agent]:
142
+ """List the agents accessible with the API key."""
143
+ try:
144
+ res = self._http.get(f"{self._base_url}/api/v2/agents", headers=self._headers, timeout=self._timeout)
145
+ except httpx.TimeoutException as exc:
146
+ raise ZihinError("timeout", "The request timed out.") from exc
147
+ except httpx.HTTPError as exc:
148
+ raise ZihinError("network", f"Could not reach the Zihin server: {exc}") from exc
149
+ body = self._json_or_none(res)
150
+ if res.status_code >= 400:
151
+ raise error_from_response(res.status_code, body)
152
+ items = _d(body).get("data")
153
+ if not isinstance(items, list):
154
+ raise ZihinError("protocol", "Expected a list of agents from GET /api/v2/agents.", status=res.status_code)
155
+ agents = []
156
+ for item in items:
157
+ a = _d(item)
158
+ agents.append(
159
+ Agent(
160
+ id=_s(a.get("id")) or "",
161
+ name=_s(a.get("name")),
162
+ commercial_name=_s(a.get("commercial_name")),
163
+ bio=_s(a.get("bio")),
164
+ type=_s(a.get("type")),
165
+ status=_s(a.get("status")),
166
+ tags=[t for t in a.get("tags") or [] if isinstance(t, str)],
167
+ )
168
+ )
169
+ return agents
170
+
171
+ # -- invocation -----------------------------------------------------------------------
172
+
173
+ def stream_agent(self, agent_id: str, message: str, *, session_id: Optional[str] = None) -> Iterator[StreamEvent]:
174
+ """Run one agent turn and yield its events as they arrive.
175
+
176
+ Closing the iterator early closes the connection, which cancels the turn on the server.
177
+ An ``error`` event is yielded like any other; ``invoke_agent`` is the method that turns
178
+ it into an exception.
179
+ """
180
+ if not agent_id:
181
+ raise ZihinError("config", "agent_id is required.")
182
+ if not message:
183
+ raise ZihinError("config", "message is required.")
184
+ body: Dict[str, Any] = {"message": message}
185
+ if session_id:
186
+ body["session_id"] = session_id
187
+ url = f"{self._base_url}/api/v2/agents/{quote(agent_id, safe='')}/stream"
188
+ try:
189
+ with self._http.stream(
190
+ "POST", url, headers=self._headers, json=body, timeout=self._stream_timeout
191
+ ) as res:
192
+ if res.status_code >= 400:
193
+ res.read()
194
+ raise error_from_response(res.status_code, self._json_or_none(res))
195
+ for name, data in parse_sse(res.iter_lines()):
196
+ payload = _d(data)
197
+ content = _s(payload.get("content")) if name in ("token", "response") else None
198
+ yield StreamEvent(type=name, data=data, content=content)
199
+ except httpx.TimeoutException as exc:
200
+ raise ZihinError("timeout", "The agent stream timed out on the client side.") from exc
201
+ except httpx.HTTPError as exc:
202
+ raise ZihinError("network", f"The connection to the Zihin server failed: {exc}") from exc
203
+
204
+ def invoke_agent(self, agent_id: str, message: str, *, session_id: Optional[str] = None) -> AgentResult:
205
+ """Run one agent turn and return the final answer.
206
+
207
+ Pass the returned ``session_id`` to the next call to continue the conversation.
208
+ """
209
+ response: Optional[dict] = None
210
+ metrics: dict = {}
211
+ done: Optional[dict] = None
212
+ meta_session: Optional[str] = None
213
+ minted_session: Optional[str] = None
214
+
215
+ for event in self.stream_agent(agent_id, message, session_id=session_id):
216
+ payload = _d(event.data)
217
+ if event.type == "error":
218
+ err = _d(payload.get("error"))
219
+ timed_out = err.get("timed_out") is True
220
+ raise ZihinError(
221
+ "timeout" if timed_out else "agent",
222
+ _s(err.get("message")) or "The agent turn failed.",
223
+ code=_s(err.get("code")),
224
+ request_id=_s(err.get("request_id")),
225
+ )
226
+ if event.type == "response":
227
+ response = payload
228
+ elif event.type == "metrics":
229
+ metrics = payload
230
+ elif event.type == "metadata":
231
+ meta_session = _s(payload.get("session_id")) or meta_session
232
+ elif event.type == "session":
233
+ minted_session = _s(payload.get("session_id")) or minted_session
234
+ elif event.type == "done":
235
+ done = payload
236
+
237
+ resolved_session = (
238
+ minted_session or _s(_d(response).get("session_id")) or meta_session or session_id
239
+ )
240
+ execution_id = _s(metrics.get("execution_id"))
241
+
242
+ if done is not None and (done.get("outcome") == "TIMEOUT" or done.get("timed_out") is True):
243
+ # The response event of a timed-out turn carries the failure, not an answer.
244
+ raise ZihinError(
245
+ "timeout",
246
+ "The agent turn timed out on the server.",
247
+ code="TURN_TIMEOUT",
248
+ execution_id=execution_id,
249
+ session_id=resolved_session,
250
+ )
251
+ if response is None:
252
+ # Token events are the model's working text across iterations, not the answer the
253
+ # server chose to deliver, so they are never returned in its place.
254
+ raise ZihinError(
255
+ "protocol",
256
+ "The stream ended without a response event.",
257
+ execution_id=execution_id,
258
+ session_id=resolved_session,
259
+ )
260
+
261
+ model = _s(_d(metrics.get("model_config")).get("model")) or _s(metrics.get("model"))
262
+ sources = response.get("sources")
263
+ return AgentResult(
264
+ content=_s(response.get("content")) or "",
265
+ session_id=resolved_session,
266
+ model=model,
267
+ usage=_usage(metrics.get("usage")),
268
+ execution_id=execution_id,
269
+ outcome=_s(_d(done).get("outcome")),
270
+ sources=sources if isinstance(sources, list) else None,
271
+ )
272
+
273
+ # -- internals ------------------------------------------------------------------------
274
+
275
+ @staticmethod
276
+ def _json_or_none(res: httpx.Response) -> Any:
277
+ try:
278
+ return res.json()
279
+ except ValueError:
280
+ return None
@@ -0,0 +1,88 @@
1
+ """Error type raised by the Zihin client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+
8
+ class ZihinError(Exception):
9
+ """Raised for every failure of a Zihin API call.
10
+
11
+ ``kind`` says what went wrong, so callers can branch without parsing messages:
12
+
13
+ - ``config``: invalid client configuration or arguments
14
+ - ``auth``: the API key was rejected or its role does not allow the action
15
+ - ``not_found``: the agent does not exist or is not accessible with this key
16
+ - ``rate_limit``: too many requests
17
+ - ``timeout``: the request or the agent turn timed out
18
+ - ``server``: the Zihin server failed (5xx)
19
+ - ``network``: the connection failed
20
+ - ``protocol``: the response was not what the API contract describes
21
+ - ``agent``: the agent turn ended with an error reported by the server
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ kind: str,
27
+ message: str,
28
+ *,
29
+ status: Optional[int] = None,
30
+ code: Optional[str] = None,
31
+ request_id: Optional[str] = None,
32
+ execution_id: Optional[str] = None,
33
+ session_id: Optional[str] = None,
34
+ ) -> None:
35
+ super().__init__(message)
36
+ self.kind = kind
37
+ self.message = message
38
+ self.status = status
39
+ self.code = code
40
+ self.request_id = request_id
41
+ self.execution_id = execution_id
42
+ self.session_id = session_id
43
+
44
+ def __repr__(self) -> str:
45
+ return f"ZihinError(kind={self.kind!r}, message={self.message!r}, status={self.status!r}, code={self.code!r})"
46
+
47
+
48
+ def _as_dict(value: Any) -> dict:
49
+ return value if isinstance(value, dict) else {}
50
+
51
+
52
+ def _as_str(value: Any) -> Optional[str]:
53
+ return value if isinstance(value, str) and value else None
54
+
55
+
56
+ def error_from_response(status: int, body: Any) -> ZihinError:
57
+ """Map an HTTP error response to a ``ZihinError``."""
58
+ root = _as_dict(body)
59
+ err = _as_dict(root.get("error"))
60
+ message = _as_str(err.get("message")) or _as_str(root.get("message"))
61
+ code = _as_str(err.get("code")) or _as_str(root.get("code"))
62
+ meta = {
63
+ "status": status,
64
+ "code": code,
65
+ "request_id": _as_str(err.get("request_id")) or _as_str(root.get("request_id")),
66
+ "execution_id": _as_str(root.get("execution_id")),
67
+ }
68
+
69
+ if status == 401:
70
+ return ZihinError("auth", message or "Authentication failed. Check the API key.", **meta)
71
+ if status == 403:
72
+ # The tenant gate answers 403 for an agent that does not exist as well as for one that
73
+ # belongs to another workspace, so the useful advice is about the agent ID, not the key.
74
+ if _as_str(_as_dict(err.get("details")).get("reason")) == "AGENT_ACCESS_DENIED":
75
+ return ZihinError(
76
+ "not_found",
77
+ "Agent not found, inactive, or not accessible with this API key. "
78
+ "Check the agent ID and that the key belongs to the same Zihin workspace.",
79
+ **meta,
80
+ )
81
+ return ZihinError("auth", message or "This API key's role does not allow this action.", **meta)
82
+ if status == 404:
83
+ return ZihinError("not_found", message or "Not found.", **meta)
84
+ if status == 429:
85
+ return ZihinError("rate_limit", message or "Too many requests.", **meta)
86
+ if status >= 500:
87
+ return ZihinError("server", message or f"Zihin server error ({status}).", **meta)
88
+ return ZihinError("protocol", message or f"Unexpected HTTP status {status}.", **meta)
@@ -0,0 +1,46 @@
1
+ """Minimal Server-Sent Events parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Iterable, Iterator, List, Tuple
7
+
8
+
9
+ def parse_sse(lines: Iterable[str]) -> Iterator[Tuple[str, Any]]:
10
+ """Turn an iterable of text lines into ``(event, data)`` pairs.
11
+
12
+ ``data`` is decoded from JSON when possible and left as a string otherwise. Events with
13
+ no data are skipped; comment lines (keep-alives) are ignored.
14
+ """
15
+ event = ""
16
+ data: List[str] = []
17
+
18
+ def flush() -> Iterator[Tuple[str, Any]]:
19
+ nonlocal event, data
20
+ raw = "\n".join(data)
21
+ name = event or "message"
22
+ event, data = "", []
23
+ if raw == "":
24
+ return
25
+ try:
26
+ yield name, json.loads(raw)
27
+ except ValueError:
28
+ yield name, raw
29
+
30
+ for line in lines:
31
+ line = line.rstrip("\r\n")
32
+ if line == "":
33
+ yield from flush()
34
+ continue
35
+ if line.startswith(":"):
36
+ continue
37
+ field, sep, value = line.partition(":")
38
+ if sep and value.startswith(" "):
39
+ value = value[1:]
40
+ if field == "event":
41
+ event = value
42
+ elif field == "data":
43
+ data.append(value)
44
+ # `id` and `retry` are ignored: this client does not reconnect.
45
+
46
+ yield from flush()
File without changes
@@ -0,0 +1,136 @@
1
+ """Offline tests: every request is answered by an httpx MockTransport."""
2
+
3
+ import json
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from zihin import Zihin, ZihinError
9
+ from zihin._sse import parse_sse
10
+
11
+ KEY = "zhn_test_" + "x" * 16
12
+
13
+
14
+ def sse(*events):
15
+ out = []
16
+ for name, data in events:
17
+ out.append(f"event: {name}\ndata: {json.dumps(data)}\n\n")
18
+ return "".join(out).encode()
19
+
20
+
21
+ def client(handler):
22
+ return Zihin(KEY, http_client=httpx.Client(transport=httpx.MockTransport(handler)))
23
+
24
+
25
+ def test_requires_api_key(monkeypatch):
26
+ monkeypatch.delenv("ZIHIN_API_KEY", raising=False)
27
+ with pytest.raises(ZihinError) as exc:
28
+ Zihin()
29
+ assert exc.value.kind == "config"
30
+
31
+
32
+ def test_rejects_key_with_unknown_prefix():
33
+ with pytest.raises(ZihinError) as exc:
34
+ Zihin("sk-not-a-zihin-key")
35
+ assert exc.value.kind == "config"
36
+
37
+
38
+ def test_reads_key_from_environment(monkeypatch):
39
+ monkeypatch.setenv("ZIHIN_API_KEY", KEY)
40
+ assert "zhn_" not in repr(Zihin())
41
+
42
+
43
+ def test_list_agents_sends_key_and_maps_fields():
44
+ seen = {}
45
+
46
+ def handler(request):
47
+ seen["url"] = str(request.url)
48
+ seen["key"] = request.headers.get("x-api-key")
49
+ return httpx.Response(200, json={"data": [{"id": "a1", "name": "Support", "commercial_name": "Ana", "tags": ["x", 3]}]})
50
+
51
+ agents = client(handler).list_agents()
52
+ assert seen == {"url": "https://llm.zihin.ai/api/v2/agents", "key": KEY}
53
+ assert agents[0].id == "a1" and agents[0].commercial_name == "Ana" and agents[0].tags == ["x"]
54
+
55
+
56
+ def test_list_agents_maps_401_to_auth():
57
+ with pytest.raises(ZihinError) as exc:
58
+ client(lambda r: httpx.Response(401, json={"error": {"code": "UNAUTHORIZED", "message": "bad key"}})).list_agents()
59
+ assert (exc.value.kind, exc.value.status, exc.value.code) == ("auth", 401, "UNAUTHORIZED")
60
+
61
+
62
+ def test_invoke_agent_consolidates_response_and_metrics():
63
+ seen = {}
64
+
65
+ def handler(request):
66
+ seen["url"] = str(request.url)
67
+ seen["body"] = json.loads(request.content)
68
+ return httpx.Response(
69
+ 200,
70
+ content=sse(
71
+ ("metadata", {"session_id": "s-meta"}),
72
+ ("token", {"content": "thinking out loud"}),
73
+ ("response", {"content": "Hello!", "session_id": "s-1", "sources": []}),
74
+ ("metrics", {"usage": {"input_tokens": 10, "output_tokens": 2, "cost_usd": 0.001}, "model_config": {"model": "m-1"}, "execution_id": "e-1"}),
75
+ ("done", {"outcome": "COMPLETED"}),
76
+ ),
77
+ )
78
+
79
+ result = client(handler).invoke_agent("agent/1", "Hi", session_id="s-0")
80
+ assert seen["url"] == "https://llm.zihin.ai/api/v2/agents/agent%2F1/stream"
81
+ assert seen["body"] == {"message": "Hi", "session_id": "s-0"}
82
+ assert result.content == "Hello!"
83
+ assert result.session_id == "s-1"
84
+ assert (result.model, result.execution_id, result.outcome) == ("m-1", "e-1", "COMPLETED")
85
+ assert (result.usage.input_tokens, result.usage.output_tokens, result.usage.cost_usd) == (10, 2, 0.001)
86
+
87
+
88
+ def test_invoke_agent_raises_on_error_event():
89
+ body = sse(("token", {"content": "partial"}), ("error", {"error": {"code": "ENGINE_ERROR", "message": "boom", "request_id": "r-1"}}))
90
+ with pytest.raises(ZihinError) as exc:
91
+ client(lambda r: httpx.Response(200, content=body)).invoke_agent("a", "Hi")
92
+ assert (exc.value.kind, exc.value.code, exc.value.request_id) == ("agent", "ENGINE_ERROR", "r-1")
93
+
94
+
95
+ def test_invoke_agent_raises_timeout_even_with_a_response_event():
96
+ body = sse(("response", {"content": "the turn timed out", "session_id": "s-1"}), ("done", {"outcome": "TIMEOUT", "timed_out": True}))
97
+ with pytest.raises(ZihinError) as exc:
98
+ client(lambda r: httpx.Response(200, content=body)).invoke_agent("a", "Hi")
99
+ assert (exc.value.kind, exc.value.code, exc.value.session_id) == ("timeout", "TURN_TIMEOUT", "s-1")
100
+
101
+
102
+ def test_invoke_agent_does_not_return_tokens_as_the_answer():
103
+ body = sse(("token", {"content": "deliberation"}), ("done", {"outcome": "COMPLETED"}))
104
+ with pytest.raises(ZihinError) as exc:
105
+ client(lambda r: httpx.Response(200, content=body)).invoke_agent("a", "Hi")
106
+ assert exc.value.kind == "protocol"
107
+
108
+
109
+ def test_agent_access_denied_is_reported_as_not_found():
110
+ def handler(request):
111
+ return httpx.Response(403, json={"error": {"code": "FORBIDDEN", "details": {"reason": "AGENT_ACCESS_DENIED"}}})
112
+
113
+ with pytest.raises(ZihinError) as exc:
114
+ client(handler).invoke_agent("a", "Hi")
115
+ assert (exc.value.kind, exc.value.status) == ("not_found", 403)
116
+
117
+
118
+ def test_stream_agent_yields_events_in_order():
119
+ body = sse(("token", {"content": "He"}), ("token", {"content": "llo"}), ("response", {"content": "Hello"}))
120
+ events = list(client(lambda r: httpx.Response(200, content=body)).stream_agent("a", "Hi"))
121
+ assert [e.type for e in events] == ["token", "token", "response"]
122
+ assert "".join(e.content for e in events if e.type == "token") == "Hello"
123
+
124
+
125
+ def test_network_failure_is_reported_as_network():
126
+ def handler(request):
127
+ raise httpx.ConnectError("refused")
128
+
129
+ with pytest.raises(ZihinError) as exc:
130
+ client(handler).list_agents()
131
+ assert exc.value.kind == "network"
132
+
133
+
134
+ def test_parse_sse_handles_comments_multiline_data_and_plain_text():
135
+ lines = [": keep-alive", "event: a", 'data: {"x":', "data: 1}", "", "data: plain", "", "event: empty", ""]
136
+ assert list(parse_sse(lines)) == [("a", {"x": 1}), ("message", "plain")]