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.
Files changed (125) hide show
  1. ghost_api/__init__.py +3 -0
  2. ghost_api/__main__.py +5 -0
  3. ghost_api/adopt.py +173 -0
  4. ghost_api/agent/__init__.py +0 -0
  5. ghost_api/agent/orchestrator.py +413 -0
  6. ghost_api/agent/prompts.py +149 -0
  7. ghost_api/agent/tools.py +84 -0
  8. ghost_api/analysis/__init__.py +0 -0
  9. ghost_api/analysis/analyzer.py +130 -0
  10. ghost_api/analysis/detectors.py +188 -0
  11. ghost_api/analysis/exposure.py +80 -0
  12. ghost_api/analysis/graphql.py +67 -0
  13. ghost_api/analysis/models.py +64 -0
  14. ghost_api/analysis/schema.py +109 -0
  15. ghost_api/analysis/url_grouping.py +217 -0
  16. ghost_api/api.py +264 -0
  17. ghost_api/audit.py +78 -0
  18. ghost_api/browser/__init__.py +0 -0
  19. ghost_api/browser/auth_state.py +172 -0
  20. ghost_api/browser/driver.py +273 -0
  21. ghost_api/browser/egress.py +183 -0
  22. ghost_api/browser/gate.py +127 -0
  23. ghost_api/browser/install.py +28 -0
  24. ghost_api/browser/snapshot.py +221 -0
  25. ghost_api/capture/__init__.py +0 -0
  26. ghost_api/capture/filters.py +255 -0
  27. ghost_api/capture/har.py +148 -0
  28. ghost_api/capture/models.py +41 -0
  29. ghost_api/capture/personal.py +188 -0
  30. ghost_api/capture/recorder.py +181 -0
  31. ghost_api/cli/__init__.py +44 -0
  32. ghost_api/cli/_common.py +219 -0
  33. ghost_api/cli/client.py +487 -0
  34. ghost_api/cli/data.py +492 -0
  35. ghost_api/cli/explore.py +379 -0
  36. ghost_api/cli/manage.py +437 -0
  37. ghost_api/config.py +132 -0
  38. ghost_api/drift.py +344 -0
  39. ghost_api/drive.py +211 -0
  40. ghost_api/errors.py +106 -0
  41. ghost_api/events.py +213 -0
  42. ghost_api/exitcodes.py +21 -0
  43. ghost_api/export/__init__.py +71 -0
  44. ghost_api/export/confidence.py +45 -0
  45. ghost_api/export/html.py +41 -0
  46. ghost_api/export/openapi.py +201 -0
  47. ghost_api/export/report.py +227 -0
  48. ghost_api/export/site_data.py +196 -0
  49. ghost_api/export/tabular.py +88 -0
  50. ghost_api/export/viewer/report.html +286 -0
  51. ghost_api/generator/__init__.py +221 -0
  52. ghost_api/generator/cassettes.py +80 -0
  53. ghost_api/generator/models_gen.py +144 -0
  54. ghost_api/generator/naming.py +174 -0
  55. ghost_api/generator/package.py +197 -0
  56. ghost_api/generator/python_gen.py +172 -0
  57. ghost_api/generator/spec.py +486 -0
  58. ghost_api/generator/templates/client.py.j2 +518 -0
  59. ghost_api/generator/templates/client.test.ts.j2 +106 -0
  60. ghost_api/generator/templates/client.ts.j2 +339 -0
  61. ghost_api/generator/templates/models.py.j2 +45 -0
  62. ghost_api/generator/templates/package_readme.md.j2 +111 -0
  63. ghost_api/generator/templates/pyproject.toml.j2 +22 -0
  64. ghost_api/generator/templates/test_client.py.j2 +105 -0
  65. ghost_api/generator/ts_gen.py +232 -0
  66. ghost_api/integrations/__init__.py +0 -0
  67. ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
  68. ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
  69. ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
  70. ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
  71. ghost_api/integrations/setup.py +84 -0
  72. ghost_api/llm/__init__.py +0 -0
  73. ghost_api/llm/doctor.py +60 -0
  74. ghost_api/llm/gateway.py +476 -0
  75. ghost_api/llm/providers.py +124 -0
  76. ghost_api/memory/__init__.py +0 -0
  77. ghost_api/memory/knowledge.py +157 -0
  78. ghost_api/memory/store.py +883 -0
  79. ghost_api/net.py +235 -0
  80. ghost_api/netguard.py +203 -0
  81. ghost_api/pipeline.py +745 -0
  82. ghost_api/probe/__init__.py +0 -0
  83. ghost_api/probe/prober.py +311 -0
  84. ghost_api/probe/replay.py +313 -0
  85. ghost_api/project.py +186 -0
  86. ghost_api/py.typed +0 -0
  87. ghost_api/runtime.py +397 -0
  88. ghost_api/serve/__init__.py +101 -0
  89. ghost_api/serve/explorer_mcp.py +305 -0
  90. ghost_api/serve/mcp.py +121 -0
  91. ghost_api/serve/mock.py +130 -0
  92. ghost_api/serve/rest.py +132 -0
  93. ghost_api/site.py +84 -0
  94. ghost_api/specdiff.py +122 -0
  95. ghost_api/ui/__init__.py +0 -0
  96. ghost_api/ui/keys.py +134 -0
  97. ghost_api/ui/repl.py +453 -0
  98. ghost_api/ui/terminal.py +573 -0
  99. ghost_api/verify/__init__.py +0 -0
  100. ghost_api/verify/classify.py +84 -0
  101. ghost_api/verify/repair.py +40 -0
  102. ghost_api/verify/runner.py +110 -0
  103. ghost_api/verify/verifier.py +291 -0
  104. ghost_api/watch.py +313 -0
  105. ghost_api/web/__init__.py +1 -0
  106. ghost_api/web/server.py +690 -0
  107. ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
  108. ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
  109. ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
  110. ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
  111. ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
  112. ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
  113. ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
  114. ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
  115. ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
  116. ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
  117. ghost_api/web/static/favicon.svg +1 -0
  118. ghost_api/web/static/index.html +26 -0
  119. ghost_api/workflows.py +203 -0
  120. ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
  121. ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
  122. ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
  123. ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
  124. ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  125. ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
@@ -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