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 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