ghost-api-cli 0.2.0__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.
- ghost_api/__init__.py +3 -0
- ghost_api/__main__.py +5 -0
- ghost_api/adopt.py +173 -0
- ghost_api/agent/__init__.py +0 -0
- ghost_api/agent/orchestrator.py +413 -0
- ghost_api/agent/prompts.py +149 -0
- ghost_api/agent/tools.py +84 -0
- ghost_api/analysis/__init__.py +0 -0
- ghost_api/analysis/analyzer.py +130 -0
- ghost_api/analysis/detectors.py +188 -0
- ghost_api/analysis/exposure.py +80 -0
- ghost_api/analysis/graphql.py +67 -0
- ghost_api/analysis/models.py +64 -0
- ghost_api/analysis/schema.py +109 -0
- ghost_api/analysis/url_grouping.py +217 -0
- ghost_api/api.py +264 -0
- ghost_api/audit.py +78 -0
- ghost_api/browser/__init__.py +0 -0
- ghost_api/browser/auth_state.py +172 -0
- ghost_api/browser/driver.py +273 -0
- ghost_api/browser/egress.py +183 -0
- ghost_api/browser/gate.py +127 -0
- ghost_api/browser/install.py +28 -0
- ghost_api/browser/snapshot.py +221 -0
- ghost_api/capture/__init__.py +0 -0
- ghost_api/capture/filters.py +255 -0
- ghost_api/capture/har.py +148 -0
- ghost_api/capture/models.py +41 -0
- ghost_api/capture/personal.py +188 -0
- ghost_api/capture/recorder.py +181 -0
- ghost_api/cli/__init__.py +44 -0
- ghost_api/cli/_common.py +219 -0
- ghost_api/cli/client.py +487 -0
- ghost_api/cli/data.py +492 -0
- ghost_api/cli/explore.py +379 -0
- ghost_api/cli/manage.py +437 -0
- ghost_api/config.py +132 -0
- ghost_api/drift.py +344 -0
- ghost_api/drive.py +211 -0
- ghost_api/errors.py +106 -0
- ghost_api/events.py +213 -0
- ghost_api/exitcodes.py +21 -0
- ghost_api/export/__init__.py +71 -0
- ghost_api/export/confidence.py +45 -0
- ghost_api/export/html.py +41 -0
- ghost_api/export/openapi.py +201 -0
- ghost_api/export/report.py +227 -0
- ghost_api/export/site_data.py +196 -0
- ghost_api/export/tabular.py +88 -0
- ghost_api/export/viewer/report.html +286 -0
- ghost_api/generator/__init__.py +221 -0
- ghost_api/generator/cassettes.py +80 -0
- ghost_api/generator/models_gen.py +144 -0
- ghost_api/generator/naming.py +174 -0
- ghost_api/generator/package.py +197 -0
- ghost_api/generator/python_gen.py +172 -0
- ghost_api/generator/spec.py +486 -0
- ghost_api/generator/templates/client.py.j2 +518 -0
- ghost_api/generator/templates/client.test.ts.j2 +106 -0
- ghost_api/generator/templates/client.ts.j2 +339 -0
- ghost_api/generator/templates/models.py.j2 +45 -0
- ghost_api/generator/templates/package_readme.md.j2 +111 -0
- ghost_api/generator/templates/pyproject.toml.j2 +22 -0
- ghost_api/generator/templates/test_client.py.j2 +105 -0
- ghost_api/generator/ts_gen.py +232 -0
- ghost_api/integrations/__init__.py +0 -0
- ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
- ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
- ghost_api/integrations/setup.py +84 -0
- ghost_api/llm/__init__.py +0 -0
- ghost_api/llm/doctor.py +60 -0
- ghost_api/llm/gateway.py +476 -0
- ghost_api/llm/providers.py +124 -0
- ghost_api/memory/__init__.py +0 -0
- ghost_api/memory/knowledge.py +157 -0
- ghost_api/memory/store.py +883 -0
- ghost_api/net.py +235 -0
- ghost_api/netguard.py +203 -0
- ghost_api/pipeline.py +745 -0
- ghost_api/probe/__init__.py +0 -0
- ghost_api/probe/prober.py +311 -0
- ghost_api/probe/replay.py +313 -0
- ghost_api/project.py +186 -0
- ghost_api/py.typed +0 -0
- ghost_api/runtime.py +397 -0
- ghost_api/serve/__init__.py +101 -0
- ghost_api/serve/explorer_mcp.py +305 -0
- ghost_api/serve/mcp.py +121 -0
- ghost_api/serve/mock.py +130 -0
- ghost_api/serve/rest.py +132 -0
- ghost_api/site.py +84 -0
- ghost_api/specdiff.py +122 -0
- ghost_api/ui/__init__.py +0 -0
- ghost_api/ui/keys.py +134 -0
- ghost_api/ui/repl.py +453 -0
- ghost_api/ui/terminal.py +573 -0
- ghost_api/verify/__init__.py +0 -0
- ghost_api/verify/classify.py +84 -0
- ghost_api/verify/repair.py +40 -0
- ghost_api/verify/runner.py +110 -0
- ghost_api/verify/verifier.py +291 -0
- ghost_api/watch.py +313 -0
- ghost_api/web/__init__.py +1 -0
- ghost_api/web/server.py +690 -0
- ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
- ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
- ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
- ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
- ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
- ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
- ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
- ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
- ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
- ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
- ghost_api/web/static/favicon.svg +1 -0
- ghost_api/web/static/index.html +26 -0
- ghost_api/workflows.py +203 -0
- ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
- ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
- ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
- ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
ghost_api/llm/gateway.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""One reliable way to call an LLM on free tiers (Stage 3).
|
|
2
|
+
|
|
3
|
+
All supported providers speak the OpenAI-compatible chat-completions protocol, so a
|
|
4
|
+
provider is just a base URL, an API key and a model name (NFR-27).
|
|
5
|
+
|
|
6
|
+
`ask()` behavior:
|
|
7
|
+
- Redacts secrets from every message before it leaves the machine (NFR-13).
|
|
8
|
+
- Serves repeated prompts from the SQLite cache (FR-44).
|
|
9
|
+
- Retries 429 / 5xx / network errors with backoff, then falls back to the next
|
|
10
|
+
provider (NFR-3, NFR-7).
|
|
11
|
+
- With `response_model`, parses and validates JSON and re-asks up to
|
|
12
|
+
MAX_REPAIRS times when the reply is invalid (NFR-8).
|
|
13
|
+
- Counts calls and tokens and enforces the call budget (NFR-2).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import time
|
|
22
|
+
from collections import Counter
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from dataclasses import dataclass, field, replace
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Generic, TypeVar
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
from pydantic import BaseModel, ValidationError
|
|
30
|
+
|
|
31
|
+
from ghost_api.capture.filters import redact_text
|
|
32
|
+
from ghost_api.config import USER_AGENT_SUFFIX
|
|
33
|
+
from ghost_api.llm.providers import Provider
|
|
34
|
+
from ghost_api.memory.store import Store
|
|
35
|
+
|
|
36
|
+
T = TypeVar("T", bound=BaseModel)
|
|
37
|
+
Message = dict[str, str]
|
|
38
|
+
|
|
39
|
+
MAX_RETRIES = 2 # per provider, for 429 / 5xx / network errors
|
|
40
|
+
MAX_REPAIRS = 2 # re-asks after an invalid JSON reply
|
|
41
|
+
BACKOFF_BASE_S = 2.0
|
|
42
|
+
MAX_BACKOFF_S = 20.0
|
|
43
|
+
TIMEOUT_S = 90.0
|
|
44
|
+
DEFAULT_COOLDOWN_S = 60.0 # skip a rate-limited model this long
|
|
45
|
+
OVERLOAD_COOLDOWN_S = 30.0 # skip an overloaded (5xx) or unreachable model this long
|
|
46
|
+
MAX_COOLDOWN_WAIT_S = 65.0 # when all are rate-limited, wait at most this long
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LLMError(Exception):
|
|
50
|
+
"""Base class for gateway errors."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BudgetExceeded(LLMError):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LLMUnavailable(LLMError):
|
|
58
|
+
"""Every provider failed. `errors` says why, per provider."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, errors: dict[str, str]) -> None:
|
|
61
|
+
self.errors = errors
|
|
62
|
+
detail = "; ".join(f"{name}: {err}" for name, err in errors.items()) or "none configured"
|
|
63
|
+
super().__init__(f"No LLM provider could answer ({detail})")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _Retryable(Exception):
|
|
67
|
+
def __init__(self, message: str, retry_after: float | None = None, quota: bool = False) -> None:
|
|
68
|
+
super().__init__(message)
|
|
69
|
+
self.retry_after = retry_after
|
|
70
|
+
self.quota = quota # rate/quota limit: retrying right away won't help
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _Fatal(Exception):
|
|
74
|
+
"""Provider can't serve this request (bad key, bad model, bad request)."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, message: str, status: int = 0) -> None:
|
|
77
|
+
super().__init__(message)
|
|
78
|
+
self.status = status
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Usage:
|
|
83
|
+
calls: int = 0 # every HTTP request sent
|
|
84
|
+
answered: int = 0 # requests that returned a reply; this is what the budget counts
|
|
85
|
+
prompt_tokens: int = 0
|
|
86
|
+
completion_tokens: int = 0
|
|
87
|
+
cache_hits: int = 0
|
|
88
|
+
by_provider: dict[str, int] = field(default_factory=dict)
|
|
89
|
+
retries: int = 0 # 429 / 5xx / network errors retried
|
|
90
|
+
repairs: int = 0 # invalid JSON replies sent back for correction
|
|
91
|
+
fallbacks: int = 0 # times a provider was given up on for a request
|
|
92
|
+
problems: Counter[str] = field(default_factory=Counter) # short reasons, for diagnosis
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def tokens(self) -> int:
|
|
96
|
+
return self.prompt_tokens + self.completion_tokens
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class LLMReply(Generic[T]):
|
|
101
|
+
text: str
|
|
102
|
+
provider: str
|
|
103
|
+
model: str
|
|
104
|
+
cached: bool
|
|
105
|
+
parsed: T | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class LLMGateway:
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
providers: list[Provider],
|
|
112
|
+
store: Store | None = None,
|
|
113
|
+
max_calls: int | None = None,
|
|
114
|
+
client: httpx.Client | None = None,
|
|
115
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
116
|
+
clock: Callable[[], float] = time.monotonic,
|
|
117
|
+
cooldown_file: Path | None = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
self.providers = [p for p in providers if p.api_key]
|
|
120
|
+
# One route per (provider, model): each provider's candidate models, in order.
|
|
121
|
+
self._routes = [
|
|
122
|
+
replace(p, model=m)
|
|
123
|
+
for p in self.providers
|
|
124
|
+
for m in dict.fromkeys((p.model, *p.fallback_models))
|
|
125
|
+
]
|
|
126
|
+
self._dead: set[str] = set() # route keys whose model is gone (404)
|
|
127
|
+
self._disabled: set[str] = set() # provider names with a bad key (401/403)
|
|
128
|
+
self.store = store
|
|
129
|
+
self.max_calls = max_calls
|
|
130
|
+
self.usage = Usage()
|
|
131
|
+
self._client = client or httpx.Client(timeout=TIMEOUT_S)
|
|
132
|
+
self._sleep = sleep
|
|
133
|
+
self._clock = clock
|
|
134
|
+
self._cooldown_until: dict[str, float] = {} # route key -> clock time
|
|
135
|
+
# Remembered between runs, so a provider that just said "too many requests" is not
|
|
136
|
+
# asked again right away by the next command (it would only waste a request).
|
|
137
|
+
self._cooldown_file = cooldown_file
|
|
138
|
+
self._load_cooldowns()
|
|
139
|
+
|
|
140
|
+
def close(self) -> None:
|
|
141
|
+
self._client.close()
|
|
142
|
+
|
|
143
|
+
# --- public API ---
|
|
144
|
+
|
|
145
|
+
def ask(
|
|
146
|
+
self,
|
|
147
|
+
messages: list[Message],
|
|
148
|
+
response_model: type[T] | None = None,
|
|
149
|
+
temperature: float = 0.0,
|
|
150
|
+
) -> LLMReply[T]:
|
|
151
|
+
if not self.providers:
|
|
152
|
+
raise LLMUnavailable({})
|
|
153
|
+
messages = [{**m, "content": redact_text(m["content"])} for m in messages]
|
|
154
|
+
if response_model is not None:
|
|
155
|
+
messages = _with_json_instructions(messages, response_model)
|
|
156
|
+
|
|
157
|
+
for provider in self._routes:
|
|
158
|
+
cached = self._cache_get(provider, messages, temperature)
|
|
159
|
+
if cached is not None:
|
|
160
|
+
parsed = _parse(cached, response_model) if response_model else None
|
|
161
|
+
if response_model is None or parsed is not None:
|
|
162
|
+
self.usage.cache_hits += 1
|
|
163
|
+
return LLMReply(cached, provider.name, provider.model, True, parsed)
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
return self._ask_chain(messages, response_model, temperature)
|
|
167
|
+
except LLMUnavailable:
|
|
168
|
+
# Every provider is rate-limited. If one frees up soon, wait for it once.
|
|
169
|
+
wait = self._shortest_cooldown()
|
|
170
|
+
if wait is None or wait > MAX_COOLDOWN_WAIT_S:
|
|
171
|
+
raise
|
|
172
|
+
self.usage.problems[f"waited {wait:.0f}s for a rate limit to clear"] += 1
|
|
173
|
+
self._sleep(wait)
|
|
174
|
+
return self._ask_chain(messages, response_model, temperature)
|
|
175
|
+
|
|
176
|
+
def _has_alternative(self, route: Provider) -> bool:
|
|
177
|
+
"""Is any other usable route free right now?"""
|
|
178
|
+
now = self._clock()
|
|
179
|
+
return any(
|
|
180
|
+
_key(r) != _key(route) and self._cooldown_until.get(_key(r), 0.0) <= now
|
|
181
|
+
for r in self._alive()
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def _alive(self) -> list[Provider]:
|
|
185
|
+
return [
|
|
186
|
+
r for r in self._routes if _key(r) not in self._dead and r.name not in self._disabled
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
def _shortest_cooldown(self) -> float | None:
|
|
190
|
+
"""Seconds until a usable route frees up, if every usable route is rate-limited."""
|
|
191
|
+
now = self._clock()
|
|
192
|
+
alive = self._alive()
|
|
193
|
+
waits = [self._cooldown_until.get(_key(r), 0.0) - now for r in alive]
|
|
194
|
+
if not alive or any(w <= 0 for w in waits):
|
|
195
|
+
return None # nothing left, or something failed for another reason
|
|
196
|
+
return min(waits) + 1.0
|
|
197
|
+
|
|
198
|
+
def _ask_chain(
|
|
199
|
+
self,
|
|
200
|
+
messages: list[Message],
|
|
201
|
+
response_model: type[T] | None,
|
|
202
|
+
temperature: float,
|
|
203
|
+
) -> LLMReply[T]:
|
|
204
|
+
errors: dict[str, str] = {}
|
|
205
|
+
for route in self._alive():
|
|
206
|
+
key = _key(route)
|
|
207
|
+
if key in self._dead or route.name in self._disabled:
|
|
208
|
+
continue # ruled out earlier in this same request
|
|
209
|
+
wait = self._cooldown_until.get(key, 0.0) - self._clock()
|
|
210
|
+
if wait > 0:
|
|
211
|
+
errors[key] = f"rate-limited, cooling down for {wait:.0f}s"
|
|
212
|
+
continue
|
|
213
|
+
try:
|
|
214
|
+
return self._ask_provider(route, messages, response_model, temperature)
|
|
215
|
+
except BudgetExceeded:
|
|
216
|
+
raise
|
|
217
|
+
except (_Retryable, _Fatal, _InvalidReply) as exc:
|
|
218
|
+
if isinstance(exc, _Retryable):
|
|
219
|
+
# Rate-limited or overloaded: this model rests, the next one is tried.
|
|
220
|
+
default = DEFAULT_COOLDOWN_S if exc.quota else OVERLOAD_COOLDOWN_S
|
|
221
|
+
self._cooldown_until[key] = self._clock() + (exc.retry_after or default)
|
|
222
|
+
self._save_cooldowns()
|
|
223
|
+
elif isinstance(exc, _Fatal) and exc.status in (401, 403):
|
|
224
|
+
self._disabled.add(route.name) # bad key: no model of it will work
|
|
225
|
+
elif isinstance(exc, _Fatal) and exc.status == 404:
|
|
226
|
+
self._dead.add(key) # model retired or unknown: try the next one
|
|
227
|
+
errors[key] = str(exc)
|
|
228
|
+
self.usage.fallbacks += 1
|
|
229
|
+
self.usage.problems[f"{key}: {str(exc)[:70]}"] += 1
|
|
230
|
+
raise LLMUnavailable(errors)
|
|
231
|
+
|
|
232
|
+
# --- internals ---
|
|
233
|
+
|
|
234
|
+
def _load_cooldowns(self) -> None:
|
|
235
|
+
if self._cooldown_file is None:
|
|
236
|
+
return
|
|
237
|
+
try:
|
|
238
|
+
saved = json.loads(self._cooldown_file.read_text(encoding="utf-8"))
|
|
239
|
+
except (OSError, ValueError):
|
|
240
|
+
return # missing or damaged: start fresh
|
|
241
|
+
if not isinstance(saved, dict):
|
|
242
|
+
return
|
|
243
|
+
now_wall, now = time.time(), self._clock()
|
|
244
|
+
for key, until in saved.items():
|
|
245
|
+
if isinstance(until, int | float) and until > now_wall:
|
|
246
|
+
self._cooldown_until[str(key)] = now + (until - now_wall)
|
|
247
|
+
|
|
248
|
+
def _save_cooldowns(self) -> None:
|
|
249
|
+
if self._cooldown_file is None:
|
|
250
|
+
return
|
|
251
|
+
now_wall, now = time.time(), self._clock()
|
|
252
|
+
data = {k: now_wall + (v - now) for k, v in self._cooldown_until.items() if v > now}
|
|
253
|
+
try:
|
|
254
|
+
self._cooldown_file.parent.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
self._cooldown_file.write_text(json.dumps(data), encoding="utf-8")
|
|
256
|
+
except OSError:
|
|
257
|
+
pass # remembering is a nicety; never fail a run over it
|
|
258
|
+
|
|
259
|
+
def _ask_provider(
|
|
260
|
+
self,
|
|
261
|
+
provider: Provider,
|
|
262
|
+
messages: list[Message],
|
|
263
|
+
response_model: type[T] | None,
|
|
264
|
+
temperature: float,
|
|
265
|
+
) -> LLMReply[T]:
|
|
266
|
+
convo = list(messages)
|
|
267
|
+
last_error = ""
|
|
268
|
+
for _attempt in range(MAX_REPAIRS + 1):
|
|
269
|
+
text = self._complete_with_retries(provider, convo, response_model, temperature)
|
|
270
|
+
if response_model is None:
|
|
271
|
+
self._cache_put(provider, messages, temperature, text)
|
|
272
|
+
return LLMReply(text, provider.name, provider.model, False)
|
|
273
|
+
try:
|
|
274
|
+
parsed = _validate(text, response_model)
|
|
275
|
+
except (ValueError, ValidationError) as exc:
|
|
276
|
+
last_error = _short_error(exc)
|
|
277
|
+
self.usage.repairs += 1
|
|
278
|
+
self.usage.problems[f"{provider.name} invalid reply: {last_error[:70]}"] += 1
|
|
279
|
+
convo += [
|
|
280
|
+
{"role": "assistant", "content": text},
|
|
281
|
+
{
|
|
282
|
+
"role": "user",
|
|
283
|
+
"content": f"That reply was not valid: {last_error}\n"
|
|
284
|
+
"Reply again with ONLY a JSON object that matches the schema.",
|
|
285
|
+
},
|
|
286
|
+
]
|
|
287
|
+
continue
|
|
288
|
+
self._cache_put(provider, messages, temperature, text)
|
|
289
|
+
return LLMReply(text, provider.name, provider.model, False, parsed)
|
|
290
|
+
raise _InvalidReply(f"invalid JSON after {MAX_REPAIRS} repairs ({last_error})")
|
|
291
|
+
|
|
292
|
+
def _complete_with_retries(
|
|
293
|
+
self,
|
|
294
|
+
provider: Provider,
|
|
295
|
+
messages: list[Message],
|
|
296
|
+
response_model: type[BaseModel] | None,
|
|
297
|
+
temperature: float,
|
|
298
|
+
) -> str:
|
|
299
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
300
|
+
try:
|
|
301
|
+
return self._complete(provider, messages, response_model, temperature)
|
|
302
|
+
except _Retryable as exc:
|
|
303
|
+
# With another model or provider available, moving on beats waiting here.
|
|
304
|
+
if attempt == MAX_RETRIES or exc.quota or self._has_alternative(provider):
|
|
305
|
+
raise
|
|
306
|
+
self.usage.retries += 1
|
|
307
|
+
self.usage.problems[f"{provider.name}: {str(exc)[:70]}"] += 1
|
|
308
|
+
delay = exc.retry_after or BACKOFF_BASE_S * (2**attempt)
|
|
309
|
+
self._sleep(min(delay, MAX_BACKOFF_S))
|
|
310
|
+
raise AssertionError("unreachable")
|
|
311
|
+
|
|
312
|
+
def _complete(
|
|
313
|
+
self,
|
|
314
|
+
provider: Provider,
|
|
315
|
+
messages: list[Message],
|
|
316
|
+
response_model: type[BaseModel] | None,
|
|
317
|
+
temperature: float,
|
|
318
|
+
) -> str:
|
|
319
|
+
if self.max_calls is not None and self.usage.answered >= self.max_calls:
|
|
320
|
+
raise BudgetExceeded(f"LLM call budget of {self.max_calls} used up")
|
|
321
|
+
payload: dict[str, Any] = {
|
|
322
|
+
"model": provider.model,
|
|
323
|
+
"messages": messages,
|
|
324
|
+
"temperature": temperature,
|
|
325
|
+
}
|
|
326
|
+
if response_model is not None and provider.json_mode:
|
|
327
|
+
payload["response_format"] = {"type": "json_object"}
|
|
328
|
+
|
|
329
|
+
self.usage.calls += 1
|
|
330
|
+
self.usage.by_provider[provider.name] = self.usage.by_provider.get(provider.name, 0) + 1
|
|
331
|
+
try:
|
|
332
|
+
resp = self._client.post(
|
|
333
|
+
f"{provider.base_url}/chat/completions",
|
|
334
|
+
json=payload,
|
|
335
|
+
headers={
|
|
336
|
+
"Authorization": f"Bearer {provider.api_key}",
|
|
337
|
+
"User-Agent": USER_AGENT_SUFFIX,
|
|
338
|
+
**provider.extra_headers,
|
|
339
|
+
},
|
|
340
|
+
)
|
|
341
|
+
except httpx.HTTPError as exc:
|
|
342
|
+
raise _Retryable(f"network error: {type(exc).__name__}") from exc
|
|
343
|
+
|
|
344
|
+
if resp.status_code == 429 or resp.status_code >= 500:
|
|
345
|
+
retry_after = _retry_after(resp.headers.get("retry-after")) or _retry_delay(resp.text)
|
|
346
|
+
raise _Retryable(
|
|
347
|
+
f"HTTP {resp.status_code}: {_error_message(resp)}",
|
|
348
|
+
retry_after=retry_after,
|
|
349
|
+
quota=resp.status_code == 429,
|
|
350
|
+
)
|
|
351
|
+
if resp.status_code >= 400:
|
|
352
|
+
raise _Fatal(f"HTTP {resp.status_code}: {_error_message(resp)}", resp.status_code)
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
data = resp.json()
|
|
356
|
+
text = data["choices"][0]["message"]["content"] or ""
|
|
357
|
+
except (ValueError, KeyError, IndexError, TypeError) as exc:
|
|
358
|
+
raise _Retryable("malformed response from provider") from exc
|
|
359
|
+
self.usage.answered += 1
|
|
360
|
+
usage = data.get("usage") or {}
|
|
361
|
+
self.usage.prompt_tokens += int(usage.get("prompt_tokens") or 0)
|
|
362
|
+
self.usage.completion_tokens += int(usage.get("completion_tokens") or 0)
|
|
363
|
+
if not text.strip():
|
|
364
|
+
raise _Retryable("empty reply")
|
|
365
|
+
return str(text)
|
|
366
|
+
|
|
367
|
+
# --- cache ---
|
|
368
|
+
|
|
369
|
+
def _cache_key(self, provider: Provider, messages: list[Message], temperature: float) -> str:
|
|
370
|
+
blob = json.dumps(
|
|
371
|
+
{"model": provider.model, "messages": messages, "temperature": temperature},
|
|
372
|
+
sort_keys=True,
|
|
373
|
+
)
|
|
374
|
+
return hashlib.sha256(blob.encode()).hexdigest()
|
|
375
|
+
|
|
376
|
+
def _cache_get(
|
|
377
|
+
self, provider: Provider, messages: list[Message], temperature: float
|
|
378
|
+
) -> str | None:
|
|
379
|
+
if self.store is None:
|
|
380
|
+
return None
|
|
381
|
+
return self.store.cache_get(self._cache_key(provider, messages, temperature))
|
|
382
|
+
|
|
383
|
+
def _cache_put(
|
|
384
|
+
self, provider: Provider, messages: list[Message], temperature: float, text: str
|
|
385
|
+
) -> None:
|
|
386
|
+
if self.store is not None:
|
|
387
|
+
key = self._cache_key(provider, messages, temperature)
|
|
388
|
+
self.store.cache_put(key, provider.name, provider.model, text)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class _InvalidReply(Exception):
|
|
392
|
+
pass
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _key(route: Provider) -> str:
|
|
396
|
+
return f"{route.name}/{route.model}"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# --- JSON helpers ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
_FENCE_RE = re.compile(r"^```(?:json)?\s*(.*?)\s*```$", re.S)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def extract_json(text: str) -> Any:
|
|
405
|
+
"""Parse JSON from a reply, tolerating ```json fences and text around one object."""
|
|
406
|
+
text = text.strip()
|
|
407
|
+
fence = _FENCE_RE.match(text)
|
|
408
|
+
if fence:
|
|
409
|
+
text = fence.group(1)
|
|
410
|
+
try:
|
|
411
|
+
return json.loads(text)
|
|
412
|
+
except ValueError:
|
|
413
|
+
start, end = text.find("{"), text.rfind("}")
|
|
414
|
+
if start != -1 and end > start:
|
|
415
|
+
return json.loads(text[start : end + 1])
|
|
416
|
+
raise
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _validate(text: str, model: type[T]) -> T:
|
|
420
|
+
return model.model_validate(extract_json(text))
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _parse(text: str, model: type[T] | None) -> T | None:
|
|
424
|
+
if model is None:
|
|
425
|
+
return None
|
|
426
|
+
try:
|
|
427
|
+
return _validate(text, model)
|
|
428
|
+
except (ValueError, ValidationError):
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _with_json_instructions(messages: list[Message], model: type[BaseModel]) -> list[Message]:
|
|
433
|
+
schema = json.dumps(model.model_json_schema(), separators=(",", ":"))
|
|
434
|
+
instruction = (
|
|
435
|
+
"Respond with ONLY a JSON object (no prose, no code fences) that matches this "
|
|
436
|
+
f"JSON schema:\n{schema}"
|
|
437
|
+
)
|
|
438
|
+
if messages and messages[0]["role"] == "system":
|
|
439
|
+
first = {**messages[0], "content": messages[0]["content"] + "\n\n" + instruction}
|
|
440
|
+
return [first, *messages[1:]]
|
|
441
|
+
return [{"role": "system", "content": instruction}, *messages]
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _short_error(exc: Exception) -> str:
|
|
445
|
+
if isinstance(exc, ValidationError):
|
|
446
|
+
errs = exc.errors()[:3]
|
|
447
|
+
return "; ".join(f"{'.'.join(map(str, e['loc'])) or 'root'}: {e['msg']}" for e in errs)
|
|
448
|
+
return str(exc)[:200]
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _retry_after(value: str | None) -> float | None:
|
|
452
|
+
try:
|
|
453
|
+
return float(value) if value else None
|
|
454
|
+
except ValueError:
|
|
455
|
+
return None
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
_RETRY_DELAY_RE = re.compile(r'"retryDelay"\s*:\s*"(\d+(?:\.\d+)?)s"')
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _retry_delay(body: str) -> float | None:
|
|
462
|
+
"""Gemini puts the wait time in the error body: "retryDelay": "37s"."""
|
|
463
|
+
match = _RETRY_DELAY_RE.search(body)
|
|
464
|
+
return float(match.group(1)) if match else None
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _error_message(resp: httpx.Response) -> str:
|
|
468
|
+
try:
|
|
469
|
+
data = resp.json()
|
|
470
|
+
except ValueError:
|
|
471
|
+
return resp.text[:200]
|
|
472
|
+
if isinstance(data, list) and data: # Gemini wraps errors in a list
|
|
473
|
+
data = data[0]
|
|
474
|
+
err = data.get("error") if isinstance(data, dict) else None
|
|
475
|
+
msg = err.get("message") if isinstance(err, dict) else err
|
|
476
|
+
return str(msg or resp.text)[:200]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""LLM providers. All use the OpenAI-compatible chat-completions API.
|
|
2
|
+
|
|
3
|
+
Models can be overridden in .env, e.g. GHOST_API_GEMINI_MODEL=gemini-2.5-flash-lite.
|
|
4
|
+
The fallback order can be changed with GHOST_API_PROVIDERS=groq,gemini,openrouter.
|
|
5
|
+
|
|
6
|
+
Local models (Ollama, LM Studio, llama.cpp, vLLM: anything OpenAI-compatible) keep every
|
|
7
|
+
prompt on your machine: set GHOST_API_LOCAL_URL (e.g. http://localhost:11434/v1) and
|
|
8
|
+
GHOST_API_LOCAL_MODEL. Then only the local model is used, unless GHOST_API_PROVIDERS lists
|
|
9
|
+
others too.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Provider:
|
|
20
|
+
name: str
|
|
21
|
+
base_url: str
|
|
22
|
+
model: str
|
|
23
|
+
api_key: str | None = field(default=None, repr=False) # never printed
|
|
24
|
+
json_mode: bool = True # supports response_format={"type": "json_object"}
|
|
25
|
+
extra_headers: dict[str, str] = field(default_factory=dict)
|
|
26
|
+
# Tried in order when a model is retired (404) or rate-limited (429). Free-tier
|
|
27
|
+
# model names change often, so one fixed name goes stale.
|
|
28
|
+
fallback_models: tuple[str, ...] = ()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class _Spec:
|
|
33
|
+
base_url: str
|
|
34
|
+
key_env: str
|
|
35
|
+
default_model: str
|
|
36
|
+
json_mode: bool = True
|
|
37
|
+
extra_headers: dict[str, str] = field(default_factory=dict)
|
|
38
|
+
fallback_models: tuple[str, ...] = ()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
SPECS: dict[str, _Spec] = {
|
|
42
|
+
"gemini": _Spec(
|
|
43
|
+
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
|
44
|
+
key_env="GEMINI_API_KEY",
|
|
45
|
+
# The "-latest" aliases point at the newest, busiest model and often return 503.
|
|
46
|
+
default_model="gemini-2.5-flash",
|
|
47
|
+
fallback_models=("gemini-3.5-flash", "gemini-flash-latest"),
|
|
48
|
+
),
|
|
49
|
+
# Google's free quotas are per model, so the lite model is a separate allowance
|
|
50
|
+
# on the same key.
|
|
51
|
+
"gemini-lite": _Spec(
|
|
52
|
+
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
|
53
|
+
key_env="GEMINI_API_KEY",
|
|
54
|
+
default_model="gemini-3.5-flash-lite",
|
|
55
|
+
fallback_models=("gemini-3.1-flash-lite", "gemini-flash-lite-latest"),
|
|
56
|
+
),
|
|
57
|
+
"groq": _Spec(
|
|
58
|
+
base_url="https://api.groq.com/openai/v1",
|
|
59
|
+
key_env="GROQ_API_KEY",
|
|
60
|
+
default_model="openai/gpt-oss-120b",
|
|
61
|
+
# Groq's per-minute token limits are per model.
|
|
62
|
+
fallback_models=("qwen/qwen3.8-27b", "openai/gpt-oss-20b"),
|
|
63
|
+
),
|
|
64
|
+
"openrouter": _Spec(
|
|
65
|
+
base_url="https://openrouter.ai/api/v1",
|
|
66
|
+
key_env="OPENROUTER_API_KEY",
|
|
67
|
+
default_model="nvidia/nemotron-3-super-120b-a12b:free",
|
|
68
|
+
fallback_models=("google/gemma-4-31b-it:free", "google/gemma-4-26b-a4b-it:free"),
|
|
69
|
+
# Free models vary in JSON-mode support; rely on instructions + validation.
|
|
70
|
+
json_mode=False,
|
|
71
|
+
extra_headers={"X-Title": "Ghost API"},
|
|
72
|
+
),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
DEFAULT_ORDER = ("gemini", "groq", "gemini-lite", "openrouter")
|
|
76
|
+
LOCAL = "local"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def local_provider() -> Provider | None:
|
|
80
|
+
"""The local OpenAI-compatible model from GHOST_API_LOCAL_URL, if one is set."""
|
|
81
|
+
url = os.getenv("GHOST_API_LOCAL_URL", "").strip().rstrip("/")
|
|
82
|
+
if not url:
|
|
83
|
+
return None
|
|
84
|
+
return Provider(
|
|
85
|
+
name=LOCAL,
|
|
86
|
+
base_url=url,
|
|
87
|
+
model=os.getenv("GHOST_API_LOCAL_MODEL") or "llama3.1",
|
|
88
|
+
# Local servers usually need no key; the gateway only needs a value to send one.
|
|
89
|
+
api_key=os.getenv("GHOST_API_LOCAL_KEY") or "local",
|
|
90
|
+
json_mode=os.getenv("GHOST_API_LOCAL_JSON_MODE", "") == "1",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def default_providers() -> list[Provider]:
|
|
95
|
+
"""Providers in fallback order. Ones without an API key are skipped by the gateway."""
|
|
96
|
+
order = os.getenv("GHOST_API_PROVIDERS")
|
|
97
|
+
local = local_provider()
|
|
98
|
+
if not order and local is not None:
|
|
99
|
+
return [local] # private by default: nothing goes to a cloud provider
|
|
100
|
+
names = [n.strip() for n in order.split(",") if n.strip()] if order else list(DEFAULT_ORDER)
|
|
101
|
+
providers: list[Provider] = []
|
|
102
|
+
for name in names:
|
|
103
|
+
if name == LOCAL:
|
|
104
|
+
if local is None:
|
|
105
|
+
raise ValueError("GHOST_API_PROVIDERS lists 'local' but GHOST_API_LOCAL_URL "
|
|
106
|
+
"is not set") # fmt: skip
|
|
107
|
+
providers.append(local)
|
|
108
|
+
continue
|
|
109
|
+
spec = SPECS.get(name)
|
|
110
|
+
if spec is None:
|
|
111
|
+
raise ValueError(f"Unknown LLM provider '{name}'. Known: {', '.join(SPECS)}")
|
|
112
|
+
providers.append(
|
|
113
|
+
Provider(
|
|
114
|
+
name=name,
|
|
115
|
+
base_url=spec.base_url,
|
|
116
|
+
model=os.getenv(f"GHOST_API_{name.upper().replace('-', '_')}_MODEL")
|
|
117
|
+
or spec.default_model,
|
|
118
|
+
api_key=os.getenv(spec.key_env) or None,
|
|
119
|
+
json_mode=spec.json_mode,
|
|
120
|
+
extra_headers=dict(spec.extra_headers),
|
|
121
|
+
fallback_models=spec.fallback_models,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
return providers
|
|
File without changes
|