claude-subprocess 0.1.1__py3-none-any.whl
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_cli/__init__.py +89 -0
- claude_cli/batch.py +88 -0
- claude_cli/cache.py +206 -0
- claude_cli/errors.py +44 -0
- claude_cli/http_api.py +102 -0
- claude_cli/parsing.py +199 -0
- claude_cli/preflight.py +261 -0
- claude_cli/runner.py +716 -0
- claude_subprocess-0.1.1.dist-info/METADATA +202 -0
- claude_subprocess-0.1.1.dist-info/RECORD +12 -0
- claude_subprocess-0.1.1.dist-info/WHEEL +4 -0
- claude_subprocess-0.1.1.dist-info/licenses/LICENSE +21 -0
claude_cli/__init__.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""claude-cli — one hardened wrapper around the local ``claude -p`` CLI.
|
|
2
|
+
|
|
3
|
+
Subscription/OAuth auth (no ``ANTHROPIC_API_KEY``), a locked-down surface, both
|
|
4
|
+
output formats, JSON extraction from prose replies, and a sha256 response cache.
|
|
5
|
+
See :mod:`claude_cli.runner` for the invariants this library exists to protect.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .batch import pack_batches, run_chunks
|
|
9
|
+
from .cache import (
|
|
10
|
+
CacheStore,
|
|
11
|
+
DictCacheStore,
|
|
12
|
+
FileCacheStore,
|
|
13
|
+
cache_key,
|
|
14
|
+
cached_run,
|
|
15
|
+
normalize_prompt,
|
|
16
|
+
)
|
|
17
|
+
from .errors import ClaudeCliError, NotLoggedInError, UsageLimitError
|
|
18
|
+
from .parsing import (
|
|
19
|
+
extract_json,
|
|
20
|
+
extract_json_or_none,
|
|
21
|
+
parse_envelope,
|
|
22
|
+
parse_stream,
|
|
23
|
+
strip_fences,
|
|
24
|
+
)
|
|
25
|
+
from .preflight import (
|
|
26
|
+
BENCHMARKS,
|
|
27
|
+
CONSTRAINTS,
|
|
28
|
+
GUIDES,
|
|
29
|
+
MATRIX,
|
|
30
|
+
QUOTA_FREE_SUBCOMMANDS,
|
|
31
|
+
TIERS,
|
|
32
|
+
doctor,
|
|
33
|
+
dry_run_plan,
|
|
34
|
+
health,
|
|
35
|
+
quota_status,
|
|
36
|
+
simulate,
|
|
37
|
+
)
|
|
38
|
+
from .runner import (
|
|
39
|
+
MODELS,
|
|
40
|
+
ClaudeCliProvider,
|
|
41
|
+
ClaudeResult,
|
|
42
|
+
LLMProvider,
|
|
43
|
+
arun_claude,
|
|
44
|
+
build_args,
|
|
45
|
+
claude_available,
|
|
46
|
+
ping,
|
|
47
|
+
run_claude,
|
|
48
|
+
stream_claude,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"BENCHMARKS",
|
|
53
|
+
"CONSTRAINTS",
|
|
54
|
+
"GUIDES",
|
|
55
|
+
"MATRIX",
|
|
56
|
+
"MODELS",
|
|
57
|
+
"QUOTA_FREE_SUBCOMMANDS",
|
|
58
|
+
"TIERS",
|
|
59
|
+
"CacheStore",
|
|
60
|
+
"ClaudeCliError",
|
|
61
|
+
"ClaudeCliProvider",
|
|
62
|
+
"ClaudeResult",
|
|
63
|
+
"DictCacheStore",
|
|
64
|
+
"FileCacheStore",
|
|
65
|
+
"LLMProvider",
|
|
66
|
+
"NotLoggedInError",
|
|
67
|
+
"UsageLimitError",
|
|
68
|
+
"arun_claude",
|
|
69
|
+
"build_args",
|
|
70
|
+
"cache_key",
|
|
71
|
+
"cached_run",
|
|
72
|
+
"claude_available",
|
|
73
|
+
"doctor",
|
|
74
|
+
"dry_run_plan",
|
|
75
|
+
"extract_json",
|
|
76
|
+
"extract_json_or_none",
|
|
77
|
+
"health",
|
|
78
|
+
"normalize_prompt",
|
|
79
|
+
"pack_batches",
|
|
80
|
+
"parse_envelope",
|
|
81
|
+
"parse_stream",
|
|
82
|
+
"ping",
|
|
83
|
+
"quota_status",
|
|
84
|
+
"run_chunks",
|
|
85
|
+
"run_claude",
|
|
86
|
+
"simulate",
|
|
87
|
+
"stream_claude",
|
|
88
|
+
"strip_fences",
|
|
89
|
+
]
|
claude_cli/batch.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Batch helpers: pack items into prompt-sized chunks, and run a call per chunk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def pack_batches(
|
|
13
|
+
items: Iterable[Any],
|
|
14
|
+
budget: int,
|
|
15
|
+
*,
|
|
16
|
+
size_of: Callable[[Any], int],
|
|
17
|
+
max_items: int = 0,
|
|
18
|
+
) -> list[list[Any]]:
|
|
19
|
+
"""Greedily pack ``items`` into batches within a size budget, order preserved.
|
|
20
|
+
|
|
21
|
+
A batch is closed when adding the next item would push it over ``budget``
|
|
22
|
+
(chars, tokens — whatever ``size_of`` counts) or over ``max_items``. Both
|
|
23
|
+
caps are disabled when ``<= 0``. A batch always holds at least one item, so
|
|
24
|
+
an item larger than ``budget`` on its own rides alone.
|
|
25
|
+
"""
|
|
26
|
+
batches: list[list[Any]] = []
|
|
27
|
+
cur: list[Any] = []
|
|
28
|
+
cur_size = 0
|
|
29
|
+
for item in items:
|
|
30
|
+
size = size_of(item)
|
|
31
|
+
over_count = max_items > 0 and len(cur) + 1 > max_items
|
|
32
|
+
over_budget = budget > 0 and cur_size + size > budget
|
|
33
|
+
if cur and (over_count or over_budget):
|
|
34
|
+
batches.append(cur)
|
|
35
|
+
cur, cur_size = [], 0
|
|
36
|
+
cur.append(item)
|
|
37
|
+
cur_size += size
|
|
38
|
+
if cur:
|
|
39
|
+
batches.append(cur)
|
|
40
|
+
return batches
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run_chunks(
|
|
44
|
+
chunks: Iterable[Any],
|
|
45
|
+
fn: Callable[[Any], tuple[Any, float]],
|
|
46
|
+
*,
|
|
47
|
+
label: Callable[[Any], str],
|
|
48
|
+
workers: int = 1,
|
|
49
|
+
progress: Callable[..., Iterable] | None = None,
|
|
50
|
+
) -> tuple[list, float, list[str]]:
|
|
51
|
+
"""Apply ``fn(chunk) -> (data, cost)`` over ``chunks``, optionally in parallel.
|
|
52
|
+
|
|
53
|
+
Each call shells out to a ``claude`` subprocess, so threads give real
|
|
54
|
+
concurrency (the GIL is released during ``subprocess.run``).
|
|
55
|
+
|
|
56
|
+
Returns ``(results, total_cost, failed_labels)``. A chunk that raises is
|
|
57
|
+
recorded in ``failed_labels`` and skipped — one bad chunk never aborts the
|
|
58
|
+
batch.
|
|
59
|
+
"""
|
|
60
|
+
chunks = list(chunks)
|
|
61
|
+
results, spent, failed = [], 0.0, []
|
|
62
|
+
|
|
63
|
+
def _record(produce: Callable[[], tuple[Any, float]], chunk) -> None:
|
|
64
|
+
nonlocal spent
|
|
65
|
+
try:
|
|
66
|
+
data, cost = produce()
|
|
67
|
+
except Exception as exc: # noqa: BLE001 — keep the batch alive
|
|
68
|
+
failed.append(f"{label(chunk)}: {type(exc).__name__}: {str(exc)[:120]}")
|
|
69
|
+
return
|
|
70
|
+
spent += cost or 0.0
|
|
71
|
+
results.append(data)
|
|
72
|
+
|
|
73
|
+
if workers <= 1:
|
|
74
|
+
iterator = progress(chunks) if progress else chunks
|
|
75
|
+
for chunk in iterator:
|
|
76
|
+
_record(lambda c=chunk: fn(c), chunk)
|
|
77
|
+
return results, spent, failed
|
|
78
|
+
|
|
79
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
80
|
+
|
|
81
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
82
|
+
futs = {ex.submit(fn, c): c for c in chunks}
|
|
83
|
+
stream = as_completed(futs)
|
|
84
|
+
if progress:
|
|
85
|
+
stream = progress(stream, total=len(futs))
|
|
86
|
+
for fut in stream:
|
|
87
|
+
_record(fut.result, futs[fut])
|
|
88
|
+
return results, spent, failed
|
claude_cli/cache.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Response cache: a sha256 key plus a tiny pluggable store.
|
|
2
|
+
|
|
3
|
+
Every consumer wants the same thing — never pay for the same prompt twice — so
|
|
4
|
+
the key recipe and the sqlite table live here once. :class:`FileCacheStore` needs
|
|
5
|
+
no server (a single sqlite file, ``CREATE TABLE IF NOT EXISTS``, safe to point at
|
|
6
|
+
an existing db); :class:`DictCacheStore` is the in-memory one for tests.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import logging
|
|
13
|
+
import sqlite3
|
|
14
|
+
from contextlib import closing
|
|
15
|
+
from datetime import datetime, timedelta, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Protocol
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def normalize_prompt(prompt: str) -> str:
|
|
23
|
+
"""Collapse whitespace so trivially different prompts share a cache key."""
|
|
24
|
+
return " ".join(prompt.split())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cache_key(
|
|
28
|
+
model: str,
|
|
29
|
+
prompt: str,
|
|
30
|
+
*,
|
|
31
|
+
schema: str = "",
|
|
32
|
+
version: str = "",
|
|
33
|
+
purpose: str = "",
|
|
34
|
+
tools: str = "",
|
|
35
|
+
) -> str:
|
|
36
|
+
"""sha256 over model + normalized prompt + optional schema/version/purpose/tools.
|
|
37
|
+
|
|
38
|
+
Optional parts are folded in only when non-empty, so adding a dimension later
|
|
39
|
+
never invalidates rows that never used it. Each part is labeled so equal
|
|
40
|
+
values in different dimensions (schema="x" vs version="x") never collide.
|
|
41
|
+
``tools`` matters because a tools-on (web-search-grounded) call must not
|
|
42
|
+
share a row with a tools-off one.
|
|
43
|
+
"""
|
|
44
|
+
parts = [model, normalize_prompt(prompt)]
|
|
45
|
+
named = (("schema", schema), ("version", version), ("purpose", purpose), ("tools", tools))
|
|
46
|
+
parts += [f"{name}={value}" for name, value in named if value]
|
|
47
|
+
return hashlib.sha256("\x00".join(parts).encode("utf-8")).hexdigest()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CacheStore(Protocol):
|
|
51
|
+
"""What :func:`cached_run` needs from a backend."""
|
|
52
|
+
|
|
53
|
+
def get(self, key: str) -> str | None: ...
|
|
54
|
+
|
|
55
|
+
def put(self, key: str, text: str, **meta) -> None: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Namespaced on purpose: consumers point FileCacheStore at existing db files, and
|
|
59
|
+
# a generic name like `llm_cache` can collide with a project's own table of the
|
|
60
|
+
# same name but a different schema (CREATE IF NOT EXISTS would no-op, INSERT
|
|
61
|
+
# would then fail at runtime).
|
|
62
|
+
_SCHEMA = """
|
|
63
|
+
CREATE TABLE IF NOT EXISTS claude_cli_cache (
|
|
64
|
+
key TEXT PRIMARY KEY,
|
|
65
|
+
model TEXT,
|
|
66
|
+
purpose TEXT,
|
|
67
|
+
prompt TEXT,
|
|
68
|
+
response TEXT NOT NULL,
|
|
69
|
+
cost_usd REAL,
|
|
70
|
+
duration_ms INTEGER,
|
|
71
|
+
created_at TEXT NOT NULL
|
|
72
|
+
)
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FileCacheStore:
|
|
77
|
+
"""A local sqlite3 file — no server, so caching never silently no-ops.
|
|
78
|
+
|
|
79
|
+
sqlite3 calls are synchronous but cost microseconds against a local file, so
|
|
80
|
+
there is no thread pool or aiosqlite here.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, path: str | Path, *, max_age_days: int | None = None) -> None:
|
|
84
|
+
"""``max_age_days`` expires rows on read; None (default) keeps them forever.
|
|
85
|
+
|
|
86
|
+
A row is never deleted here — an expired hit is simply ignored, so the next
|
|
87
|
+
call re-asks and overwrites it. That keeps ``created_at`` history intact and
|
|
88
|
+
avoids a delete path that could lose a still-valid answer on a clock skew.
|
|
89
|
+
"""
|
|
90
|
+
self.path = Path(path).expanduser()
|
|
91
|
+
self.max_age_days = max_age_days
|
|
92
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
with closing(self._connect()) as conn:
|
|
94
|
+
conn.execute(_SCHEMA)
|
|
95
|
+
conn.commit()
|
|
96
|
+
|
|
97
|
+
def _connect(self) -> sqlite3.Connection:
|
|
98
|
+
# sqlite3's own context manager only manages the transaction — closing()
|
|
99
|
+
# is what actually releases the file handle after each call.
|
|
100
|
+
return sqlite3.connect(self.path)
|
|
101
|
+
|
|
102
|
+
def get(self, key: str) -> str | None:
|
|
103
|
+
with closing(self._connect()) as conn:
|
|
104
|
+
row = conn.execute(
|
|
105
|
+
"SELECT response, created_at FROM claude_cli_cache WHERE key = ?", (key,)
|
|
106
|
+
).fetchone()
|
|
107
|
+
if row is None:
|
|
108
|
+
return None
|
|
109
|
+
return row[0] if self._fresh(row[1]) else None
|
|
110
|
+
|
|
111
|
+
def _fresh(self, created_at: str | None) -> bool:
|
|
112
|
+
if self.max_age_days is None:
|
|
113
|
+
return True
|
|
114
|
+
try:
|
|
115
|
+
written = datetime.fromisoformat(created_at or "")
|
|
116
|
+
except ValueError: # unparseable timestamp: treat as expired, re-ask rather than trust
|
|
117
|
+
return False
|
|
118
|
+
if written.tzinfo is None: # naive row (hand-written/legacy): assume UTC, never TypeError
|
|
119
|
+
written = written.replace(tzinfo=timezone.utc)
|
|
120
|
+
age = datetime.now(timezone.utc) - written
|
|
121
|
+
return age <= timedelta(days=self.max_age_days)
|
|
122
|
+
|
|
123
|
+
def put(self, key: str, text: str, **meta) -> None:
|
|
124
|
+
with closing(self._connect()) as conn:
|
|
125
|
+
conn.execute(
|
|
126
|
+
"INSERT OR REPLACE INTO claude_cli_cache "
|
|
127
|
+
"(key, model, purpose, prompt, response, cost_usd, duration_ms, created_at) "
|
|
128
|
+
"VALUES (?,?,?,?,?,?,?,?)",
|
|
129
|
+
(
|
|
130
|
+
key,
|
|
131
|
+
meta.get("model"),
|
|
132
|
+
meta.get("purpose"),
|
|
133
|
+
meta.get("prompt"),
|
|
134
|
+
text,
|
|
135
|
+
meta.get("cost_usd"),
|
|
136
|
+
meta.get("duration_ms"),
|
|
137
|
+
datetime.now(timezone.utc).isoformat(),
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
conn.commit()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class DictCacheStore:
|
|
144
|
+
"""In-memory store for tests."""
|
|
145
|
+
|
|
146
|
+
def __init__(self) -> None:
|
|
147
|
+
self.rows: dict[str, str] = {}
|
|
148
|
+
self.meta: dict[str, dict] = {}
|
|
149
|
+
|
|
150
|
+
def get(self, key: str) -> str | None:
|
|
151
|
+
return self.rows.get(key)
|
|
152
|
+
|
|
153
|
+
def put(self, key: str, text: str, **meta) -> None:
|
|
154
|
+
self.rows[key] = text
|
|
155
|
+
self.meta[key] = meta
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
#: run_claude kwargs that change which upstream answers (profile/config dir, provider, remote...).
|
|
159
|
+
_ROUTING_KWARGS = ("tier", "provider", "profile", "remote", "base_url", "context")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cached_run(
|
|
163
|
+
runner,
|
|
164
|
+
store: CacheStore,
|
|
165
|
+
prompt: str,
|
|
166
|
+
*,
|
|
167
|
+
model: str = "sonnet",
|
|
168
|
+
schema: str = "",
|
|
169
|
+
version: str = "",
|
|
170
|
+
purpose: str = "",
|
|
171
|
+
refresh: bool = False,
|
|
172
|
+
store_prompt: bool = False,
|
|
173
|
+
**kwargs,
|
|
174
|
+
) -> str:
|
|
175
|
+
"""Call ``runner(prompt, model=..., **kwargs)`` behind ``store``; return the text.
|
|
176
|
+
|
|
177
|
+
``runner`` is :func:`claude_cli.runner.run_claude` (or any callable returning
|
|
178
|
+
a :class:`~claude_cli.runner.ClaudeResult`). A cache-write failure is logged,
|
|
179
|
+
never raised — a broken cache must not fail an answer we already have.
|
|
180
|
+
|
|
181
|
+
``store_prompt`` is opt-in: prompts often embed privacy-gated payloads, so by
|
|
182
|
+
default only the sha256 key touches disk and the ``prompt`` column stays NULL.
|
|
183
|
+
Callers needing hit/miss visibility use ``store.get``/``store.put`` directly.
|
|
184
|
+
"""
|
|
185
|
+
# Routing kwargs pick a different upstream model, so they must split the key;
|
|
186
|
+
# folded into the model part only when set, so existing rows stay valid.
|
|
187
|
+
routing = ",".join(f"{k}={kwargs[k]}" for k in _ROUTING_KWARGS if kwargs.get(k))
|
|
188
|
+
key = cache_key(
|
|
189
|
+
f"{model}|{routing}" if routing else model, prompt, schema=schema,
|
|
190
|
+
version=version, purpose=purpose, tools=kwargs.get("tools", ""),
|
|
191
|
+
)
|
|
192
|
+
if not refresh:
|
|
193
|
+
hit = store.get(key)
|
|
194
|
+
if hit is not None:
|
|
195
|
+
log.debug("llm cache hit %s", purpose or key[:8])
|
|
196
|
+
return hit
|
|
197
|
+
result = runner(prompt, model=model, **kwargs)
|
|
198
|
+
try:
|
|
199
|
+
store.put(
|
|
200
|
+
key, result.text, model=model, purpose=purpose,
|
|
201
|
+
prompt=prompt if store_prompt else None,
|
|
202
|
+
cost_usd=result.cost_usd, duration_ms=result.duration_ms,
|
|
203
|
+
)
|
|
204
|
+
except (sqlite3.Error, OSError) as exc:
|
|
205
|
+
log.warning("llm cache write failed: %s", exc)
|
|
206
|
+
return result.text
|
claude_cli/errors.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Exceptions raised by the runner.
|
|
2
|
+
|
|
3
|
+
All derive from :class:`ClaudeCliError`, itself a ``RuntimeError`` subclass — so
|
|
4
|
+
existing callers that ``except RuntimeError`` to degrade gracefully (the
|
|
5
|
+
finance-insights rules-only fallback, chat-watch's regex-only mode) keep working
|
|
6
|
+
unchanged.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClaudeCliError(RuntimeError):
|
|
13
|
+
"""The ``claude`` CLI exited non-zero, timed out, or reported an error.
|
|
14
|
+
|
|
15
|
+
``stdout`` carries the raw CLI output of the failed run (empty when there
|
|
16
|
+
was none, e.g. a timeout) — consumers that archive failing runs for
|
|
17
|
+
inspection read it instead of losing the stream.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, *, stdout: str = "") -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.stdout = stdout
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotLoggedInError(ClaudeCliError):
|
|
26
|
+
"""The CLI reported "Not logged in" — run ``claude`` once and ``/login``."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UsageLimitError(ClaudeCliError):
|
|
30
|
+
"""A usage/rate/session limit was hit.
|
|
31
|
+
|
|
32
|
+
``session_wide`` says whether the limit ends the whole run or just this call.
|
|
33
|
+
Behind an OmniRoute combo it is ``False``: the limit belongs to ONE upstream
|
|
34
|
+
(target #1 of the chain), the gateway still has other targets, and the next
|
|
35
|
+
call may well succeed — so a batch must skip this item, not abort. It is
|
|
36
|
+
``True`` only for a direct ``claude`` call, where the limited subscription is
|
|
37
|
+
the only route there is and every further call would hit the same wall.
|
|
38
|
+
|
|
39
|
+
The caller owns the abort policy; this class only reports which case it is.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message: str, *, stdout: str = "", session_wide: bool = True) -> None:
|
|
43
|
+
super().__init__(message, stdout=stdout)
|
|
44
|
+
self.session_wide = session_wide
|
claude_cli/http_api.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""OmniRoute HTTP route: tool-less calls skip the `claude` subprocess.
|
|
2
|
+
|
|
3
|
+
``POST /v1/chat/completions`` (OpenAI format) on the local gateway. No ``temperature``
|
|
4
|
+
is ever sent: that would switch on OmniRoute's own semantic cache, whose key ignores
|
|
5
|
+
system prompt and tools. No API key -> the route is silently off.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .errors import ClaudeCliError
|
|
19
|
+
from .runner import ClaudeResult
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# alias -> combo, the same mapping the gateway applies to `--model` for the CLI;
|
|
24
|
+
# any other value (a bare combo name) passes through unchanged
|
|
25
|
+
COMBOS = {"haiku": "free-stack", "sonnet": "free-stack", "opus": "hard"}
|
|
26
|
+
COOLDOWN_SECS = 600
|
|
27
|
+
ENV_FILE = Path.home() / ".omniroute" / ".env"
|
|
28
|
+
|
|
29
|
+
_down_until = 0.0 # ponytail: per process, not per model; a dead gateway costs one timeout per window
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HttpLlmError(ClaudeCliError):
|
|
33
|
+
"""The HTTP route failed (transport, status, body); run_claude falls back to the CLI."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def api_key() -> str | None:
|
|
37
|
+
"""OMNIROUTE_API_KEY from the env, else from ~/.omniroute/.env (never logged)."""
|
|
38
|
+
if key := os.environ.get("OMNIROUTE_API_KEY"):
|
|
39
|
+
return key
|
|
40
|
+
try:
|
|
41
|
+
for line in ENV_FILE.read_text().splitlines():
|
|
42
|
+
name, _, value = line.partition("=")
|
|
43
|
+
if name.strip() == "OMNIROUTE_API_KEY":
|
|
44
|
+
return value.strip().strip("\"'") or None
|
|
45
|
+
except OSError:
|
|
46
|
+
pass
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def omniroute_chat(prompt: str, *, model: str = "sonnet", timeout: float = 300,
|
|
51
|
+
system_prompt: str | None = None, **_ignored) -> ClaudeResult:
|
|
52
|
+
"""One chat completion, ClaudeResult-shaped. CLI-only knobs (effort, cwd...) are ignored."""
|
|
53
|
+
key = api_key()
|
|
54
|
+
if not key:
|
|
55
|
+
raise HttpLlmError("no OMNIROUTE_API_KEY")
|
|
56
|
+
url = os.environ.get("OMNIROUTE_URL", "http://localhost:20128") + "/v1/chat/completions"
|
|
57
|
+
messages = [{"role": "user", "content": prompt}]
|
|
58
|
+
if system_prompt:
|
|
59
|
+
messages.insert(0, {"role": "system", "content": system_prompt})
|
|
60
|
+
body = json.dumps({"model": COMBOS.get(model, model), "messages": messages}).encode()
|
|
61
|
+
req = urllib.request.Request(url, body, {
|
|
62
|
+
"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
|
|
63
|
+
t0 = time.monotonic()
|
|
64
|
+
try:
|
|
65
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
66
|
+
data = json.load(resp)
|
|
67
|
+
text = data["choices"][0]["message"]["content"]
|
|
68
|
+
except urllib.error.HTTPError as exc: # 401 key, 422 body, 429/503 busy
|
|
69
|
+
raise HttpLlmError(f"HTTP {exc.code} from {url}") from exc
|
|
70
|
+
except (OSError, ValueError, KeyError, IndexError, TypeError) as exc:
|
|
71
|
+
raise HttpLlmError(f"{type(exc).__name__}: {exc}") from exc
|
|
72
|
+
if not isinstance(text, str) or not text.strip():
|
|
73
|
+
raise HttpLlmError("empty completion")
|
|
74
|
+
# raw keeps the OpenAI body, but `usage` in the CLI envelope's keys so meters read it
|
|
75
|
+
usage = data.get("usage") or {}
|
|
76
|
+
raw = {**data, "usage": {"input_tokens": usage.get("prompt_tokens"),
|
|
77
|
+
"output_tokens": usage.get("completion_tokens")}}
|
|
78
|
+
return ClaudeResult(text=text, cost_usd=0.0, raw=raw,
|
|
79
|
+
duration_ms=int((time.monotonic() - t0) * 1000))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def try_http(prompt: str, *, backend: str, cli_only: bool, **kw) -> ClaudeResult | None:
|
|
83
|
+
"""The HTTP answer, or None when run_claude should use the CLI.
|
|
84
|
+
|
|
85
|
+
``backend``: "cli" never, "api" always (errors propagate, no cooldown), "auto" when
|
|
86
|
+
a key exists, the gateway is not cooling down and the call needs nothing CLI-only.
|
|
87
|
+
"""
|
|
88
|
+
global _down_until
|
|
89
|
+
backend = os.environ.get("CLAUDE_CLI_BACKEND") or backend
|
|
90
|
+
if backend == "cli" or cli_only:
|
|
91
|
+
return None
|
|
92
|
+
if backend == "api":
|
|
93
|
+
return omniroute_chat(prompt, **kw)
|
|
94
|
+
if time.monotonic() < _down_until or not api_key():
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
return omniroute_chat(prompt, **kw)
|
|
98
|
+
except HttpLlmError as exc:
|
|
99
|
+
_down_until = time.monotonic() + COOLDOWN_SECS
|
|
100
|
+
log.warning("omniroute http failed (%s); CLI fallback, http off for %ds",
|
|
101
|
+
exc, COOLDOWN_SECS)
|
|
102
|
+
return None
|