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 +10 -0
- zihin-0.1.0/CHANGELOG.md +6 -0
- zihin-0.1.0/LICENSE +21 -0
- zihin-0.1.0/PKG-INFO +135 -0
- zihin-0.1.0/README.md +107 -0
- zihin-0.1.0/pyproject.toml +45 -0
- zihin-0.1.0/src/zihin/__init__.py +8 -0
- zihin-0.1.0/src/zihin/_client.py +280 -0
- zihin-0.1.0/src/zihin/_errors.py +88 -0
- zihin-0.1.0/src/zihin/_sse.py +46 -0
- zihin-0.1.0/src/zihin/py.typed +0 -0
- zihin-0.1.0/tests/test_client.py +136 -0
zihin-0.1.0/.gitignore
ADDED
zihin-0.1.0/CHANGELOG.md
ADDED
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")]
|