claude-subprocess 0.1.2__tar.gz → 0.1.4__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.
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/.gitignore +1 -0
- claude_subprocess-0.1.2/README.md → claude_subprocess-0.1.4/PKG-INFO +47 -13
- claude_subprocess-0.1.2/PKG-INFO → claude_subprocess-0.1.4/README.md +26 -28
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/pyproject.toml +16 -2
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/src/claude_cli/cache.py +16 -8
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/src/claude_cli/http_api.py +27 -11
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/src/claude_cli/parsing.py +14 -25
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/src/claude_cli/preflight.py +143 -69
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/src/claude_cli/runner.py +293 -132
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/tests/test_batch.py +1 -3
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/tests/test_cache.py +10 -5
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/tests/test_http_api.py +23 -10
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/tests/test_parsing.py +27 -23
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/tests/test_preflight.py +14 -12
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/tests/test_runner.py +81 -10
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/LICENSE +0 -0
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/src/claude_cli/__init__.py +0 -0
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/src/claude_cli/batch.py +0 -0
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/src/claude_cli/errors.py +0 -0
- {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/tests/conftest.py +0 -0
|
@@ -1,5 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: claude-subprocess
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: Hardened wrapper around the local `claude -p` CLI (subscription auth), + JSON extraction and response cache
|
|
5
|
+
Project-URL: Homepage, https://github.com/octrow/claude-cli
|
|
6
|
+
Project-URL: Issues, https://github.com/octrow/claude-cli/issues
|
|
7
|
+
Author: Daniil Petrov
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
18
|
+
Requires-Dist: ruff==0.17.3; extra == 'dev'
|
|
19
|
+
Requires-Dist: ty==0.0.95; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
1
22
|
<p align="center">
|
|
2
23
|
<picture>
|
|
24
|
+
<source media="(max-width: 600px) and (prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/hero-mobile-dark.svg">
|
|
25
|
+
<source media="(max-width: 600px)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/hero-mobile-light.svg">
|
|
3
26
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/hero-dark.svg">
|
|
4
27
|
<img src="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/hero-light.svg" width="100%" alt="claude-subprocess: call claude -p from Python on your Claude Code subscription, with the risky flags locked down. Beside the title, the real argv build_args(model='haiku') produces: omniroute run claude -- -p --model haiku --effort low --output-format json --no-session-persistence --tools '' --disable-slash-commands --setting-sources user --strict-mcp-config --mcp-config mcp-empty.json. --bare is never sent because it breaks OAuth.">
|
|
5
28
|
</picture>
|
|
@@ -28,17 +51,17 @@ uv add claude-subprocess # or: pip install claude-subprocess
|
|
|
28
51
|
The PyPI name is `claude-subprocess`, the import name is `claude_cli`. At runtime you need
|
|
29
52
|
`omniroute` and `claude` on `PATH`, with Claude Code logged in.
|
|
30
53
|
|
|
31
|
-
##
|
|
54
|
+
## Usage
|
|
32
55
|
|
|
33
56
|
```python
|
|
34
57
|
from claude_cli import run_claude, extract_json
|
|
35
58
|
|
|
36
59
|
res = run_claude('Reply with {"ok": true}', model="haiku", effort="low", timeout=60)
|
|
37
|
-
res.text
|
|
38
|
-
res.cost_usd
|
|
60
|
+
res.text # the model's answer
|
|
61
|
+
res.cost_usd # cost the envelope reported (may be None)
|
|
39
62
|
res.duration_ms
|
|
40
|
-
res.raw
|
|
41
|
-
extract_json(res.text)
|
|
63
|
+
res.raw # the full envelope dict
|
|
64
|
+
extract_json(res.text) # {'ok': True}
|
|
42
65
|
```
|
|
43
66
|
|
|
44
67
|
`extract_json` tolerates code fences and prose around the object:
|
|
@@ -51,6 +74,8 @@ extract_json(res.text) # {'ok': True}
|
|
|
51
74
|
## How a call is routed
|
|
52
75
|
|
|
53
76
|
<picture>
|
|
77
|
+
<source media="(max-width: 600px) and (prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/flow-mobile-dark.svg">
|
|
78
|
+
<source media="(max-width: 600px)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/flow-mobile-light.svg">
|
|
54
79
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/flow-dark.svg">
|
|
55
80
|
<img src="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/flow-light.svg" width="100%" alt="run_claude, arun_claude and stream_claude send tool-less calls to OmniRoute's HTTP API at /v1/chat/completions. Calls with tools, a project cwd or stream-json go through omniroute run claude -- -p with a throwaway cwd and an empty MCP config. An HTTP error falls back to the CLI and parks HTTP for 600 seconds. Both paths return a ClaudeResult. NotLoggedInError and UsageLimitError fail fast and derive from ClaudeCliError(RuntimeError).">
|
|
56
81
|
</picture>
|
|
@@ -128,9 +153,12 @@ with its `CLAUDE.md` and settings loaded, and lets it write files:
|
|
|
128
153
|
|
|
129
154
|
```python
|
|
130
155
|
res = stream_claude(
|
|
131
|
-
"/apply data/export/interested/acme.md",
|
|
132
|
-
|
|
133
|
-
|
|
156
|
+
"/apply data/export/interested/acme.md",
|
|
157
|
+
on_event,
|
|
158
|
+
cwd="~/projects/my-repo",
|
|
159
|
+
slash_commands=True,
|
|
160
|
+
setting_sources="user,project",
|
|
161
|
+
permission_mode="acceptEdits",
|
|
134
162
|
tools="Read,Write,Edit,Bash,WebFetch",
|
|
135
163
|
)
|
|
136
164
|
```
|
|
@@ -149,8 +177,7 @@ Cached:
|
|
|
149
177
|
from claude_cli import FileCacheStore, cached_run, run_claude
|
|
150
178
|
|
|
151
179
|
store = FileCacheStore("~/.cache/myapp/llm.db")
|
|
152
|
-
text = cached_run(run_claude, store, prompt, model="haiku",
|
|
153
|
-
purpose="classify", version="v3")
|
|
180
|
+
text = cached_run(run_claude, store, prompt, model="haiku", purpose="classify", version="v3")
|
|
154
181
|
```
|
|
155
182
|
|
|
156
183
|
`cache_key(model, prompt, schema=..., version=..., purpose=..., tools=...)` is a sha256 over
|
|
@@ -175,14 +202,21 @@ the gateway's model-to-combo mappings, or you pass a combo name directly. `tier=
|
|
|
175
202
|
res = run_claude(prompt, model="sub-first") # explicit combo
|
|
176
203
|
```
|
|
177
204
|
|
|
205
|
+
`direct=True` skips OmniRoute: `claude -p` runs on the account the local Claude Code is
|
|
206
|
+
logged in to, `model` is a plain Claude model id, and a usage limit ends the session.
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
res = run_claude(prompt, model="claude-sonnet-5-5", direct=True)
|
|
210
|
+
```
|
|
211
|
+
|
|
178
212
|
## Checks before you spend quota
|
|
179
213
|
|
|
180
214
|
```python
|
|
181
215
|
from claude_cli import health, doctor, quota_status, simulate, dry_run_plan
|
|
182
216
|
|
|
183
|
-
health()
|
|
184
|
-
simulate("hi", combo="free-
|
|
185
|
-
dry_run_plan()
|
|
217
|
+
health() # omniroute health
|
|
218
|
+
simulate("hi", combo="free-stack")
|
|
219
|
+
dry_run_plan() # planned command, args and env key *names* only
|
|
186
220
|
```
|
|
187
221
|
|
|
188
222
|
None of these raise or spend quota; each returns `(ok, text)`.
|
|
@@ -1,20 +1,7 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: claude-subprocess
|
|
3
|
-
Version: 0.1.2
|
|
4
|
-
Summary: Hardened wrapper around the local `claude -p` CLI (subscription auth), + JSON extraction and response cache
|
|
5
|
-
Project-URL: Homepage, https://github.com/octrow/claude-cli
|
|
6
|
-
Project-URL: Issues, https://github.com/octrow/claude-cli/issues
|
|
7
|
-
Author: Daniil Petrov
|
|
8
|
-
License-Expression: MIT
|
|
9
|
-
License-File: LICENSE
|
|
10
|
-
Requires-Python: >=3.12
|
|
11
|
-
Provides-Extra: dev
|
|
12
|
-
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
13
|
-
Requires-Dist: pytest>=8; extra == 'dev'
|
|
14
|
-
Description-Content-Type: text/markdown
|
|
15
|
-
|
|
16
1
|
<p align="center">
|
|
17
2
|
<picture>
|
|
3
|
+
<source media="(max-width: 600px) and (prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/hero-mobile-dark.svg">
|
|
4
|
+
<source media="(max-width: 600px)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/hero-mobile-light.svg">
|
|
18
5
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/hero-dark.svg">
|
|
19
6
|
<img src="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/hero-light.svg" width="100%" alt="claude-subprocess: call claude -p from Python on your Claude Code subscription, with the risky flags locked down. Beside the title, the real argv build_args(model='haiku') produces: omniroute run claude -- -p --model haiku --effort low --output-format json --no-session-persistence --tools '' --disable-slash-commands --setting-sources user --strict-mcp-config --mcp-config mcp-empty.json. --bare is never sent because it breaks OAuth.">
|
|
20
7
|
</picture>
|
|
@@ -43,17 +30,17 @@ uv add claude-subprocess # or: pip install claude-subprocess
|
|
|
43
30
|
The PyPI name is `claude-subprocess`, the import name is `claude_cli`. At runtime you need
|
|
44
31
|
`omniroute` and `claude` on `PATH`, with Claude Code logged in.
|
|
45
32
|
|
|
46
|
-
##
|
|
33
|
+
## Usage
|
|
47
34
|
|
|
48
35
|
```python
|
|
49
36
|
from claude_cli import run_claude, extract_json
|
|
50
37
|
|
|
51
38
|
res = run_claude('Reply with {"ok": true}', model="haiku", effort="low", timeout=60)
|
|
52
|
-
res.text
|
|
53
|
-
res.cost_usd
|
|
39
|
+
res.text # the model's answer
|
|
40
|
+
res.cost_usd # cost the envelope reported (may be None)
|
|
54
41
|
res.duration_ms
|
|
55
|
-
res.raw
|
|
56
|
-
extract_json(res.text)
|
|
42
|
+
res.raw # the full envelope dict
|
|
43
|
+
extract_json(res.text) # {'ok': True}
|
|
57
44
|
```
|
|
58
45
|
|
|
59
46
|
`extract_json` tolerates code fences and prose around the object:
|
|
@@ -66,6 +53,8 @@ extract_json(res.text) # {'ok': True}
|
|
|
66
53
|
## How a call is routed
|
|
67
54
|
|
|
68
55
|
<picture>
|
|
56
|
+
<source media="(max-width: 600px) and (prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/flow-mobile-dark.svg">
|
|
57
|
+
<source media="(max-width: 600px)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/flow-mobile-light.svg">
|
|
69
58
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/flow-dark.svg">
|
|
70
59
|
<img src="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/flow-light.svg" width="100%" alt="run_claude, arun_claude and stream_claude send tool-less calls to OmniRoute's HTTP API at /v1/chat/completions. Calls with tools, a project cwd or stream-json go through omniroute run claude -- -p with a throwaway cwd and an empty MCP config. An HTTP error falls back to the CLI and parks HTTP for 600 seconds. Both paths return a ClaudeResult. NotLoggedInError and UsageLimitError fail fast and derive from ClaudeCliError(RuntimeError).">
|
|
71
60
|
</picture>
|
|
@@ -143,9 +132,12 @@ with its `CLAUDE.md` and settings loaded, and lets it write files:
|
|
|
143
132
|
|
|
144
133
|
```python
|
|
145
134
|
res = stream_claude(
|
|
146
|
-
"/apply data/export/interested/acme.md",
|
|
147
|
-
|
|
148
|
-
|
|
135
|
+
"/apply data/export/interested/acme.md",
|
|
136
|
+
on_event,
|
|
137
|
+
cwd="~/projects/my-repo",
|
|
138
|
+
slash_commands=True,
|
|
139
|
+
setting_sources="user,project",
|
|
140
|
+
permission_mode="acceptEdits",
|
|
149
141
|
tools="Read,Write,Edit,Bash,WebFetch",
|
|
150
142
|
)
|
|
151
143
|
```
|
|
@@ -164,8 +156,7 @@ Cached:
|
|
|
164
156
|
from claude_cli import FileCacheStore, cached_run, run_claude
|
|
165
157
|
|
|
166
158
|
store = FileCacheStore("~/.cache/myapp/llm.db")
|
|
167
|
-
text = cached_run(run_claude, store, prompt, model="haiku",
|
|
168
|
-
purpose="classify", version="v3")
|
|
159
|
+
text = cached_run(run_claude, store, prompt, model="haiku", purpose="classify", version="v3")
|
|
169
160
|
```
|
|
170
161
|
|
|
171
162
|
`cache_key(model, prompt, schema=..., version=..., purpose=..., tools=...)` is a sha256 over
|
|
@@ -190,14 +181,21 @@ the gateway's model-to-combo mappings, or you pass a combo name directly. `tier=
|
|
|
190
181
|
res = run_claude(prompt, model="sub-first") # explicit combo
|
|
191
182
|
```
|
|
192
183
|
|
|
184
|
+
`direct=True` skips OmniRoute: `claude -p` runs on the account the local Claude Code is
|
|
185
|
+
logged in to, `model` is a plain Claude model id, and a usage limit ends the session.
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
res = run_claude(prompt, model="claude-sonnet-5-5", direct=True)
|
|
189
|
+
```
|
|
190
|
+
|
|
193
191
|
## Checks before you spend quota
|
|
194
192
|
|
|
195
193
|
```python
|
|
196
194
|
from claude_cli import health, doctor, quota_status, simulate, dry_run_plan
|
|
197
195
|
|
|
198
|
-
health()
|
|
199
|
-
simulate("hi", combo="free-
|
|
200
|
-
dry_run_plan()
|
|
196
|
+
health() # omniroute health
|
|
197
|
+
simulate("hi", combo="free-stack")
|
|
198
|
+
dry_run_plan() # planned command, args and env key *names* only
|
|
201
199
|
```
|
|
202
200
|
|
|
203
201
|
None of these raise or spend quota; each returns `(ok, text)`.
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "claude-subprocess"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.4"
|
|
4
4
|
license = "MIT"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "Daniil Petrov" }]
|
|
7
7
|
description = "Hardened wrapper around the local `claude -p` CLI (subscription auth), + JSON extraction and response cache"
|
|
8
8
|
requires-python = ">=3.12"
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Programming Language :: Python :: 3",
|
|
11
|
+
"Programming Language :: Python :: 3.12",
|
|
12
|
+
"Programming Language :: Python :: 3.13",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
]
|
|
9
15
|
dependencies = [] # stdlib only, on purpose
|
|
10
16
|
|
|
11
17
|
[project.urls]
|
|
@@ -13,7 +19,7 @@ Homepage = "https://github.com/octrow/claude-cli"
|
|
|
13
19
|
Issues = "https://github.com/octrow/claude-cli/issues"
|
|
14
20
|
|
|
15
21
|
[project.optional-dependencies]
|
|
16
|
-
dev = ["pytest>=8", "pytest-asyncio>=0.24"]
|
|
22
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff==0.17.3", "ty==0.0.95"]
|
|
17
23
|
|
|
18
24
|
[build-system]
|
|
19
25
|
requires = ["hatchling"]
|
|
@@ -28,3 +34,11 @@ include = ["src", "tests", "README.md", "LICENSE"]
|
|
|
28
34
|
[tool.pytest.ini_options]
|
|
29
35
|
testpaths = ["tests"]
|
|
30
36
|
asyncio_mode = "auto"
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
line-length = 100 # the code was already written to ~100; only 6 lines were longer
|
|
40
|
+
|
|
41
|
+
[tool.vulture]
|
|
42
|
+
paths = ["src", "tests"]
|
|
43
|
+
min_confidence = 80
|
|
44
|
+
ignore_names = ["auto"] # pytest fixture requested for its env side effect, never read
|
|
@@ -12,7 +12,7 @@ import hashlib
|
|
|
12
12
|
import logging
|
|
13
13
|
import sqlite3
|
|
14
14
|
from contextlib import closing
|
|
15
|
-
from datetime import datetime, timedelta
|
|
15
|
+
from datetime import UTC, datetime, timedelta
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from typing import Protocol
|
|
18
18
|
|
|
@@ -116,8 +116,8 @@ class FileCacheStore:
|
|
|
116
116
|
except ValueError: # unparseable timestamp: treat as expired, re-ask rather than trust
|
|
117
117
|
return False
|
|
118
118
|
if written.tzinfo is None: # naive row (hand-written/legacy): assume UTC, never TypeError
|
|
119
|
-
written = written.replace(tzinfo=
|
|
120
|
-
age = datetime.now(
|
|
119
|
+
written = written.replace(tzinfo=UTC)
|
|
120
|
+
age = datetime.now(UTC) - written
|
|
121
121
|
return age <= timedelta(days=self.max_age_days)
|
|
122
122
|
|
|
123
123
|
def put(self, key: str, text: str, **meta) -> None:
|
|
@@ -134,7 +134,7 @@ class FileCacheStore:
|
|
|
134
134
|
text,
|
|
135
135
|
meta.get("cost_usd"),
|
|
136
136
|
meta.get("duration_ms"),
|
|
137
|
-
datetime.now(
|
|
137
|
+
datetime.now(UTC).isoformat(),
|
|
138
138
|
),
|
|
139
139
|
)
|
|
140
140
|
conn.commit()
|
|
@@ -186,8 +186,12 @@ def cached_run(
|
|
|
186
186
|
# folded into the model part only when set, so existing rows stay valid.
|
|
187
187
|
routing = ",".join(f"{k}={kwargs[k]}" for k in _ROUTING_KWARGS if kwargs.get(k))
|
|
188
188
|
key = cache_key(
|
|
189
|
-
f"{model}|{routing}" if routing else model,
|
|
190
|
-
|
|
189
|
+
f"{model}|{routing}" if routing else model,
|
|
190
|
+
prompt,
|
|
191
|
+
schema=schema,
|
|
192
|
+
version=version,
|
|
193
|
+
purpose=purpose,
|
|
194
|
+
tools=kwargs.get("tools", ""),
|
|
191
195
|
)
|
|
192
196
|
if not refresh:
|
|
193
197
|
hit = store.get(key)
|
|
@@ -197,9 +201,13 @@ def cached_run(
|
|
|
197
201
|
result = runner(prompt, model=model, **kwargs)
|
|
198
202
|
try:
|
|
199
203
|
store.put(
|
|
200
|
-
key,
|
|
204
|
+
key,
|
|
205
|
+
result.text,
|
|
206
|
+
model=model,
|
|
207
|
+
purpose=purpose,
|
|
201
208
|
prompt=prompt if store_prompt else None,
|
|
202
|
-
cost_usd=result.cost_usd,
|
|
209
|
+
cost_usd=result.cost_usd,
|
|
210
|
+
duration_ms=result.duration_ms,
|
|
203
211
|
)
|
|
204
212
|
except (sqlite3.Error, OSError) as exc:
|
|
205
213
|
log.warning("llm cache write failed: %s", exc)
|
|
@@ -26,7 +26,9 @@ COMBOS = {"haiku": "free-stack", "sonnet": "free-stack", "opus": "hard"}
|
|
|
26
26
|
COOLDOWN_SECS = 600
|
|
27
27
|
ENV_FILE = Path.home() / ".omniroute" / ".env"
|
|
28
28
|
|
|
29
|
-
_down_until =
|
|
29
|
+
_down_until = (
|
|
30
|
+
0.0 # ponytail: per process, not per model; a dead gateway costs one timeout per window
|
|
31
|
+
)
|
|
30
32
|
|
|
31
33
|
|
|
32
34
|
class HttpLlmError(ClaudeCliError):
|
|
@@ -47,8 +49,14 @@ def api_key() -> str | None:
|
|
|
47
49
|
return None
|
|
48
50
|
|
|
49
51
|
|
|
50
|
-
def omniroute_chat(
|
|
51
|
-
|
|
52
|
+
def omniroute_chat(
|
|
53
|
+
prompt: str,
|
|
54
|
+
*,
|
|
55
|
+
model: str = "sonnet",
|
|
56
|
+
timeout: float = 300,
|
|
57
|
+
system_prompt: str | None = None,
|
|
58
|
+
**_ignored,
|
|
59
|
+
) -> ClaudeResult:
|
|
52
60
|
"""One chat completion, ClaudeResult-shaped. CLI-only knobs (effort, cwd...) are ignored."""
|
|
53
61
|
key = api_key()
|
|
54
62
|
if not key:
|
|
@@ -58,8 +66,9 @@ def omniroute_chat(prompt: str, *, model: str = "sonnet", timeout: float = 300,
|
|
|
58
66
|
if system_prompt:
|
|
59
67
|
messages.insert(0, {"role": "system", "content": system_prompt})
|
|
60
68
|
body = json.dumps({"model": COMBOS.get(model, model), "messages": messages}).encode()
|
|
61
|
-
req = urllib.request.Request(
|
|
62
|
-
"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
|
69
|
+
req = urllib.request.Request(
|
|
70
|
+
url, body, {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
|
71
|
+
)
|
|
63
72
|
t0 = time.monotonic()
|
|
64
73
|
try:
|
|
65
74
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
@@ -73,10 +82,16 @@ def omniroute_chat(prompt: str, *, model: str = "sonnet", timeout: float = 300,
|
|
|
73
82
|
raise HttpLlmError("empty completion")
|
|
74
83
|
# raw keeps the OpenAI body, but `usage` in the CLI envelope's keys so meters read it
|
|
75
84
|
usage = data.get("usage") or {}
|
|
76
|
-
raw = {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
raw = {
|
|
86
|
+
**data,
|
|
87
|
+
"usage": {
|
|
88
|
+
"input_tokens": usage.get("prompt_tokens"),
|
|
89
|
+
"output_tokens": usage.get("completion_tokens"),
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
return ClaudeResult(
|
|
93
|
+
text=text, cost_usd=0.0, raw=raw, duration_ms=int((time.monotonic() - t0) * 1000)
|
|
94
|
+
)
|
|
80
95
|
|
|
81
96
|
|
|
82
97
|
def try_http(prompt: str, *, backend: str, cli_only: bool, **kw) -> ClaudeResult | None:
|
|
@@ -97,6 +112,7 @@ def try_http(prompt: str, *, backend: str, cli_only: bool, **kw) -> ClaudeResult
|
|
|
97
112
|
return omniroute_chat(prompt, **kw)
|
|
98
113
|
except HttpLlmError as exc:
|
|
99
114
|
_down_until = time.monotonic() + COOLDOWN_SECS
|
|
100
|
-
log.warning(
|
|
101
|
-
|
|
115
|
+
log.warning(
|
|
116
|
+
"omniroute http failed (%s); CLI fallback, http off for %ds", exc, COOLDOWN_SECS
|
|
117
|
+
)
|
|
102
118
|
return None
|
|
@@ -13,8 +13,15 @@ from typing import Any
|
|
|
13
13
|
|
|
14
14
|
#: Metadata keys lifted from a stream-json ``result`` event (tlf/digest.py).
|
|
15
15
|
_META_KEYS = (
|
|
16
|
-
"usage",
|
|
17
|
-
"
|
|
16
|
+
"usage",
|
|
17
|
+
"total_cost_usd",
|
|
18
|
+
"duration_ms",
|
|
19
|
+
"duration_api_ms",
|
|
20
|
+
"num_turns",
|
|
21
|
+
"session_id",
|
|
22
|
+
"model",
|
|
23
|
+
"modelUsage",
|
|
24
|
+
"is_error",
|
|
18
25
|
)
|
|
19
26
|
|
|
20
27
|
_FENCED_JSON_RE = re.compile(r"```(?:json)?\s*(\{.*\})\s*```", re.DOTALL)
|
|
@@ -58,25 +65,8 @@ def extract_json(text: str) -> Any:
|
|
|
58
65
|
start = text.find("{")
|
|
59
66
|
if start == -1:
|
|
60
67
|
return json.loads(text)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
ch = text[i]
|
|
64
|
-
if in_str:
|
|
65
|
-
if esc:
|
|
66
|
-
esc = False
|
|
67
|
-
elif ch == "\\":
|
|
68
|
-
esc = True
|
|
69
|
-
elif ch == '"':
|
|
70
|
-
in_str = False
|
|
71
|
-
elif ch == '"':
|
|
72
|
-
in_str = True
|
|
73
|
-
elif ch == "{":
|
|
74
|
-
depth += 1
|
|
75
|
-
elif ch == "}":
|
|
76
|
-
depth -= 1
|
|
77
|
-
if depth == 0:
|
|
78
|
-
return json.loads(text[start : i + 1])
|
|
79
|
-
return json.loads(text[start:]) # unbalanced — let json raise a clear error
|
|
68
|
+
# raw_decode parses the first object at `start` and ignores what follows it
|
|
69
|
+
return json.JSONDecoder().raw_decode(text, start)[0]
|
|
80
70
|
|
|
81
71
|
|
|
82
72
|
def extract_json_or_none(text: str) -> Any | None:
|
|
@@ -110,9 +100,7 @@ def _is_omniroute_banner_line(line: str) -> bool:
|
|
|
110
100
|
return True
|
|
111
101
|
if " is ignored, " in line and "set it first" in line:
|
|
112
102
|
return True
|
|
113
|
-
|
|
114
|
-
return True
|
|
115
|
-
return False
|
|
103
|
+
return "connectors are disabled because ANTHROPIC_API_KEY" in line
|
|
116
104
|
|
|
117
105
|
|
|
118
106
|
def strip_omniroute_noise(text: str) -> str:
|
|
@@ -151,7 +139,8 @@ def parse_envelope(stdout: str) -> dict:
|
|
|
151
139
|
if env is None:
|
|
152
140
|
raise ValueError(f"claude returned non-JSON: {stdout[-300:]}") from exc
|
|
153
141
|
if not isinstance(env, dict):
|
|
154
|
-
|
|
142
|
+
# ValueError, not TypeError: runner._finish catches ValueError for every bad envelope
|
|
143
|
+
raise ValueError(f"unexpected claude envelope: {type(env).__name__}") # noqa: TRY004
|
|
155
144
|
return env
|
|
156
145
|
|
|
157
146
|
|