react-agent-harness 0.2.0__tar.gz → 0.3.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.
- {react_agent_harness-0.2.0/react_agent_harness.egg-info → react_agent_harness-0.3.0}/PKG-INFO +1 -1
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/README.md +97 -0
- react_agent_harness-0.3.0/harness/cli.py +137 -0
- react_agent_harness-0.3.0/harness/llm/__init__.py +19 -0
- react_agent_harness-0.3.0/harness/llm/_streaming.py +56 -0
- react_agent_harness-0.3.0/harness/llm/auth.py +610 -0
- react_agent_harness-0.3.0/harness/llm/claude_code.py +312 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/llm/openai.py +11 -5
- react_agent_harness-0.3.0/harness/llm/openai_codex.py +283 -0
- react_agent_harness-0.3.0/harness/utils.py +102 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/pyproject.toml +4 -1
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0/react_agent_harness.egg-info}/PKG-INFO +1 -1
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/react_agent_harness.egg-info/SOURCES.txt +11 -0
- react_agent_harness-0.3.0/react_agent_harness.egg-info/entry_points.txt +2 -0
- react_agent_harness-0.3.0/tests/test_claude_code_llm.py +265 -0
- react_agent_harness-0.3.0/tests/test_cli.py +69 -0
- react_agent_harness-0.3.0/tests/test_llm_auth.py +297 -0
- react_agent_harness-0.3.0/tests/test_openai_codex_llm.py +204 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_openai_llm.py +3 -2
- react_agent_harness-0.3.0/tests/test_utils.py +96 -0
- react_agent_harness-0.2.0/harness/utils.py +0 -46
- react_agent_harness-0.2.0/tools/builtin/__init__.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/LICENSE +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/agents/__init__.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/agents/base.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/__init__.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/annotation.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/checkpoint.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/events.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/executor_bridge.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/hitl.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/otel.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/runtime.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/harness/steering.py +0 -0
- {react_agent_harness-0.2.0/harness/llm → react_agent_harness-0.3.0/memory}/__init__.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/memory/episodic_lance.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/memory/manager.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/memory/redis_store.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/memory/stores.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/memory/working.py +0 -0
- {react_agent_harness-0.2.0/memory → react_agent_harness-0.3.0/orchestrator}/__init__.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/orchestrator/planner.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/react_agent_harness.egg-info/dependency_links.txt +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/react_agent_harness.egg-info/requires.txt +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/react_agent_harness.egg-info/top_level.txt +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/setup.cfg +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_agents_base.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_annotation.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_checkpoint_resume.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_executor_bridge.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_http_fetch.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_mcp_adapter.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_memory.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_orchestrator.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_otel.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_parse_action_json.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_redis_store.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_steering.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_streaming.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_vision.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tests/test_working_memory.py +0 -0
- {react_agent_harness-0.2.0/orchestrator → react_agent_harness-0.3.0/tools}/__init__.py +0 -0
- {react_agent_harness-0.2.0/tools → react_agent_harness-0.3.0/tools/builtin}/__init__.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tools/builtin/fetch_image.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tools/builtin/http_fetch.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tools/mcp/__init__.py +0 -0
- {react_agent_harness-0.2.0 → react_agent_harness-0.3.0}/tools/mcp/adapter.py +0 -0
|
@@ -74,6 +74,7 @@ explicit control.
|
|
|
74
74
|
| `examples/executor_bridge_demo.py` | `ExecutorBridge` backends side-by-side: allowlist, env scrubbing, Docker network/fs isolation, timeout, positional-arg tools. | `ah-executor` and/or Docker |
|
|
75
75
|
| `examples/durable_memory_demo.py` | Redis (semantic) + LanceDB (episodic) memory persistence across two related goals. | `OPENAI_API_KEY`, `[openai,redis,lance]`, Redis reachable |
|
|
76
76
|
| `examples/mcp_demo.py` | Connects to an MCP filesystem server and gives the agent its tools. | `OPENAI_API_KEY`, `[openai,mcp]`, `npx` |
|
|
77
|
+
| `examples/subscription_auth_demo.py` | Runs an agent through subscription-backed providers: direct `openai-codex` OAuth or direct `claude-code` OAuth. | `agent-harness login openai-codex` or `agent-harness login claude-code` |
|
|
77
78
|
|
|
78
79
|
## Adding a new domain (3 steps)
|
|
79
80
|
|
|
@@ -109,6 +110,102 @@ llm = OpenAILLM(model="gpt-4o-mini") # reads OPENAI_API_KEY from
|
|
|
109
110
|
runtime = AgentRuntime(..., llm=llm)
|
|
110
111
|
```
|
|
111
112
|
|
|
113
|
+
Credential-backed adapters can also plug into the same contract. This is the
|
|
114
|
+
shape used for provider-specific subscription or OAuth flows without teaching
|
|
115
|
+
agents about auth:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
agent-harness login openai-codex
|
|
119
|
+
agent-harness auth status openai-codex
|
|
120
|
+
agent-harness login claude-code
|
|
121
|
+
agent-harness auth status claude-code
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
> **⚠️ Subscription adapters are experimental — use the metered API in production.**
|
|
125
|
+
>
|
|
126
|
+
> `OpenAICodexLLM` and `ClaudeCodeLLM` bridge **ChatGPT / Claude
|
|
127
|
+
> subscription OAuth credentials** into the harness by talking to
|
|
128
|
+
> internal CLI endpoints with CLI-shaped User-Agent and billing headers.
|
|
129
|
+
> This route:
|
|
130
|
+
>
|
|
131
|
+
> - **May violate OpenAI's and Anthropic's Terms of Service.** Both
|
|
132
|
+
> providers prohibit using subscription accounts (ChatGPT Plus/Pro,
|
|
133
|
+
> Claude Pro/Max) for arbitrary programmatic access — subscriptions
|
|
134
|
+
> price for the official CLI's intended use only.
|
|
135
|
+
> - **May result in account suspension** if abuse detection classifies
|
|
136
|
+
> harness traffic as misuse.
|
|
137
|
+
> - **Depends on undocumented internal endpoints**
|
|
138
|
+
> (`/backend-api/codex/responses`, the Anthropic Messages API with
|
|
139
|
+
> `claude-code-*` beta flags) that providers can change or revoke at
|
|
140
|
+
> any time.
|
|
141
|
+
>
|
|
142
|
+
> **Use these adapters only for personal research on accounts you own.**
|
|
143
|
+
> Do not use them to serve other users. For anything else, prefer the
|
|
144
|
+
> metered API path:
|
|
145
|
+
>
|
|
146
|
+
> - `OpenAILLM` with `OPENAI_API_KEY` (optionally routed through a
|
|
147
|
+
> gateway like LiteLLM/Helicone for cost headers).
|
|
148
|
+
> - The standard Anthropic Messages API with an Anthropic API key.
|
|
149
|
+
|
|
150
|
+
Direct `openai-codex` OAuth follows the Codex/Pi-style ChatGPT
|
|
151
|
+
subscription route rather than the stable OpenAI Platform API. The
|
|
152
|
+
Codex OAuth client id can be overridden with
|
|
153
|
+
`AGENT_HARNESS_OPENAI_CODEX_CLIENT_ID`.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from harness.llm.openai_codex import OpenAICodexLLM
|
|
157
|
+
|
|
158
|
+
llm = OpenAICodexLLM(
|
|
159
|
+
model="gpt-5.5",
|
|
160
|
+
auth_file="~/.agent-harness/auth/auth.json", # Pi-shaped openai-codex OAuth entry
|
|
161
|
+
)
|
|
162
|
+
runtime = AgentRuntime(..., llm=llm)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`OpenAICodexLLM` calls the Codex backend directly
|
|
166
|
+
(`https://chatgpt.com/backend-api/codex/responses`) with OAuth credentials.
|
|
167
|
+
The stable fallback remains `OpenAILLM` with `OPENAI_API_KEY`.
|
|
168
|
+
|
|
169
|
+
For Claude Code-style setups, use `ClaudeCodeLLM` with Claude Pro/Max OAuth
|
|
170
|
+
credentials stored in the same auth file. It calls the Anthropic Messages API
|
|
171
|
+
directly with Claude-Code-compatible OAuth headers:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
agent-harness login claude-code
|
|
175
|
+
python examples/subscription_auth_demo.py claude-code
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
from harness.llm.claude_code import ClaudeCodeLLM
|
|
180
|
+
|
|
181
|
+
llm = ClaudeCodeLLM(
|
|
182
|
+
model="claude-sonnet-4-6",
|
|
183
|
+
auth_file="~/.agent-harness/auth/auth.json",
|
|
184
|
+
)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`ClaudeCodeLLM` reads a `claude-code` OAuth entry, refreshes it automatically
|
|
188
|
+
when expired, and retries once after `401`/`403`. This mirrors Pi's Claude
|
|
189
|
+
Pro/Max extension approach rather than shelling out to the Claude CLI. The
|
|
190
|
+
default model is the current canonical Sonnet release ID, `claude-sonnet-4-6`;
|
|
191
|
+
set `CLAUDE_CODE_MODEL` or pass `model="claude-opus-4-7"` to choose another
|
|
192
|
+
model.
|
|
193
|
+
|
|
194
|
+
Both adapters stream incrementally — `stream_complete()` yields each
|
|
195
|
+
SSE delta token as it arrives, and `complete()` consumes the same
|
|
196
|
+
stream and returns the concatenated text once finished. Cost / token
|
|
197
|
+
usage is captured from the final stream event into `last_usage`.
|
|
198
|
+
|
|
199
|
+
The Claude billing header's `cc_version` is read from
|
|
200
|
+
`CLAUDE_CODE_VERSION` (env) or from `claude --version` if the CLI is
|
|
201
|
+
installed; falls back to `unknown` otherwise. Pinning a specific
|
|
202
|
+
version with `CLAUDE_CODE_VERSION=2.1.150` is recommended if you want
|
|
203
|
+
stable behavior across CLI upgrades.
|
|
204
|
+
|
|
205
|
+
Do not copy browser/app refresh tokens into repo files. Store OAuth auth files
|
|
206
|
+
under `~/.agent-harness/auth` or reuse an existing Pi auth file with private
|
|
207
|
+
file permissions (`0600`).
|
|
208
|
+
|
|
112
209
|
To use Anthropic / Gemini / Ollama / a local SGLang or vLLM server / anything
|
|
113
210
|
else — write a 30-line adapter implementing those two methods. See
|
|
114
211
|
`harness/llm/openai.py` for the reference shape; the harness never imports a
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from harness.llm.auth import (
|
|
11
|
+
AnthropicClaudeCodeOAuthClient,
|
|
12
|
+
AuthFileOAuthProvider,
|
|
13
|
+
OAuthCredential,
|
|
14
|
+
OpenAICodexOAuthClient,
|
|
15
|
+
default_auth_file,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
PROVIDERS = ["openai-codex", "claude-code"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main() -> int:
|
|
22
|
+
parser = argparse.ArgumentParser(prog="agent-harness", description="agent-harness utilities")
|
|
23
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
24
|
+
|
|
25
|
+
login = sub.add_parser("login", help="log in to a provider")
|
|
26
|
+
login.add_argument("provider", choices=PROVIDERS)
|
|
27
|
+
login.add_argument("--auth-file", default=str(default_auth_file()))
|
|
28
|
+
|
|
29
|
+
status = sub.add_parser("auth", help="inspect or clear provider auth")
|
|
30
|
+
status_sub = status.add_subparsers(dest="auth_command", required=True)
|
|
31
|
+
status_cmd = status_sub.add_parser("status", help="show auth status")
|
|
32
|
+
status_cmd.add_argument("provider", choices=PROVIDERS)
|
|
33
|
+
status_cmd.add_argument("--auth-file", default=str(default_auth_file()))
|
|
34
|
+
logout_cmd = status_sub.add_parser("logout", help="remove auth credentials")
|
|
35
|
+
logout_cmd.add_argument("provider", choices=PROVIDERS)
|
|
36
|
+
logout_cmd.add_argument("--auth-file", default=str(default_auth_file()))
|
|
37
|
+
|
|
38
|
+
args = parser.parse_args()
|
|
39
|
+
try:
|
|
40
|
+
if args.command == "login":
|
|
41
|
+
if args.provider == "openai-codex":
|
|
42
|
+
return asyncio.run(_login_openai_codex(Path(args.auth_file).expanduser()))
|
|
43
|
+
if args.provider == "claude-code":
|
|
44
|
+
return asyncio.run(_login_claude_code(Path(args.auth_file).expanduser()))
|
|
45
|
+
if args.command == "auth" and args.auth_command == "status":
|
|
46
|
+
if args.provider == "openai-codex":
|
|
47
|
+
return _status_oauth_provider(Path(args.auth_file).expanduser(), "openai-codex")
|
|
48
|
+
if args.provider == "claude-code":
|
|
49
|
+
return _status_oauth_provider(Path(args.auth_file).expanduser(), "claude-code")
|
|
50
|
+
if args.command == "auth" and args.auth_command == "logout":
|
|
51
|
+
if args.provider == "openai-codex":
|
|
52
|
+
return _logout_oauth_provider(Path(args.auth_file).expanduser(), "openai-codex")
|
|
53
|
+
if args.provider == "claude-code":
|
|
54
|
+
return _logout_oauth_provider(Path(args.auth_file).expanduser(), "claude-code")
|
|
55
|
+
except Exception as e:
|
|
56
|
+
print(f"agent-harness: {e}", file=sys.stderr)
|
|
57
|
+
return 1
|
|
58
|
+
parser.error("unsupported command")
|
|
59
|
+
return 2
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _login_openai_codex(path: Path) -> int:
|
|
63
|
+
client = OpenAICodexOAuthClient()
|
|
64
|
+
try:
|
|
65
|
+
device = await client.request_device_code()
|
|
66
|
+
print("OpenAI Codex login")
|
|
67
|
+
print(f"Open: {device.verification_uri}")
|
|
68
|
+
print(f"Code: {device.user_code}")
|
|
69
|
+
print("Waiting for authorization...")
|
|
70
|
+
cred = await client.poll_device_code(device)
|
|
71
|
+
finally:
|
|
72
|
+
await client.aclose()
|
|
73
|
+
_write_oauth_credential(path, cred)
|
|
74
|
+
print(f"Logged in to openai-codex. Credentials saved to {path}")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def _login_claude_code(path: Path) -> int:
|
|
79
|
+
client = AnthropicClaudeCodeOAuthClient()
|
|
80
|
+
try:
|
|
81
|
+
login = client.begin_login()
|
|
82
|
+
print("Claude Code login")
|
|
83
|
+
print(f"Open: {login.url}")
|
|
84
|
+
print("Paste the final callback URL, or the code#state value.")
|
|
85
|
+
callback_input = input("Callback: ")
|
|
86
|
+
cred = await client.finish_login(login, callback_input)
|
|
87
|
+
finally:
|
|
88
|
+
await client.aclose()
|
|
89
|
+
_write_oauth_credential(path, cred)
|
|
90
|
+
print(f"Logged in to claude-code. Credentials saved to {path}")
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _status_oauth_provider(path: Path, provider_name: str) -> int:
|
|
95
|
+
provider = AuthFileOAuthProvider(path, provider=provider_name)
|
|
96
|
+
try:
|
|
97
|
+
cred = provider._read_credential()
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
print(f"Not logged in: {path} does not exist")
|
|
100
|
+
return 1
|
|
101
|
+
except Exception as e:
|
|
102
|
+
print(f"Not logged in: {e}")
|
|
103
|
+
return 1
|
|
104
|
+
status = {
|
|
105
|
+
"provider": provider_name,
|
|
106
|
+
"auth_file": str(path),
|
|
107
|
+
"account_id": cred.account_id,
|
|
108
|
+
"expires_at": cred.expires_at.isoformat() if cred.expires_at else None,
|
|
109
|
+
"expired": cred.is_expired(),
|
|
110
|
+
}
|
|
111
|
+
print(json.dumps(status, indent=2))
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _logout_oauth_provider(path: Path, provider_name: str) -> int:
|
|
116
|
+
provider = AuthFileOAuthProvider(
|
|
117
|
+
path, provider=provider_name, require_private_permissions=False
|
|
118
|
+
)
|
|
119
|
+
provider.clear()
|
|
120
|
+
print(f"Removed {provider_name} credentials from {path}")
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _write_oauth_credential(path: Path, cred: OAuthCredential) -> None:
|
|
125
|
+
provider = AuthFileOAuthProvider(
|
|
126
|
+
path, provider=cred.provider, require_private_permissions=False
|
|
127
|
+
)
|
|
128
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
if not path.exists():
|
|
130
|
+
path.write_text("{}")
|
|
131
|
+
if os.name != "nt":
|
|
132
|
+
path.chmod(0o600)
|
|
133
|
+
provider._write_credential(cred)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""LLM adapter helpers."""
|
|
2
|
+
|
|
3
|
+
from harness.llm.auth import (
|
|
4
|
+
AnthropicClaudeCodeOAuthClient,
|
|
5
|
+
AuthFileOAuthProvider,
|
|
6
|
+
OAuthCredential,
|
|
7
|
+
OpenAICodexOAuthClient,
|
|
8
|
+
)
|
|
9
|
+
from harness.llm.claude_code import ClaudeCodeLLM
|
|
10
|
+
from harness.llm.openai_codex import OpenAICodexLLM
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AnthropicClaudeCodeOAuthClient",
|
|
14
|
+
"AuthFileOAuthProvider",
|
|
15
|
+
"ClaudeCodeLLM",
|
|
16
|
+
"OAuthCredential",
|
|
17
|
+
"OpenAICodexLLM",
|
|
18
|
+
"OpenAICodexOAuthClient",
|
|
19
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shared SSE helpers for streaming-capable LLM adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def aiter_sse_events(response: Any) -> AsyncGenerator[tuple[str, str], None]:
|
|
10
|
+
"""Yield (event_type, data) pairs from an SSE response.
|
|
11
|
+
|
|
12
|
+
Parses the standard `event:` / `data:` line format. Blank lines
|
|
13
|
+
terminate events. The default event type for unlabelled events is
|
|
14
|
+
`"message"`. Trailing buffered data (no terminating blank line) is
|
|
15
|
+
flushed when the stream ends.
|
|
16
|
+
"""
|
|
17
|
+
current_event = "message"
|
|
18
|
+
data_lines: list[str] = []
|
|
19
|
+
async for raw_line in response.aiter_lines():
|
|
20
|
+
line = raw_line.rstrip("\r")
|
|
21
|
+
if not line:
|
|
22
|
+
if data_lines:
|
|
23
|
+
yield current_event, "\n".join(data_lines)
|
|
24
|
+
current_event = "message"
|
|
25
|
+
data_lines = []
|
|
26
|
+
continue
|
|
27
|
+
if line.startswith("event:"):
|
|
28
|
+
current_event = line[len("event:") :].strip()
|
|
29
|
+
elif line.startswith("data:"):
|
|
30
|
+
data_lines.append(line[len("data:") :].strip())
|
|
31
|
+
if data_lines:
|
|
32
|
+
yield current_event, "\n".join(data_lines)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def read_error_body(response: Any) -> bytes:
|
|
36
|
+
"""Drain the body of an error response, returning at most 4 KiB."""
|
|
37
|
+
out: list[bytes] = []
|
|
38
|
+
total = 0
|
|
39
|
+
async for chunk in response.aiter_bytes():
|
|
40
|
+
if total >= 4096:
|
|
41
|
+
break
|
|
42
|
+
out.append(chunk)
|
|
43
|
+
total += len(chunk)
|
|
44
|
+
return b"".join(out)[:4096]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def format_streaming_error(status_code: int, body: bytes, *, provider: str) -> str:
|
|
48
|
+
"""Build a user-facing error message from an error response body.
|
|
49
|
+
|
|
50
|
+
Truncates aggressively because error bodies sometimes echo request
|
|
51
|
+
payloads — we don't want bearer tokens or full prompts in tracebacks.
|
|
52
|
+
"""
|
|
53
|
+
text = body.decode(errors="replace").strip()
|
|
54
|
+
if not text:
|
|
55
|
+
return f"{provider} backend returned HTTP {status_code}"
|
|
56
|
+
return f"{provider} backend returned {status_code}: {text[:500]}"
|