athena-llm 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- athena_llm-0.2.0/PKG-INFO +7 -0
- athena_llm-0.2.0/athena_llm.egg-info/PKG-INFO +7 -0
- athena_llm-0.2.0/athena_llm.egg-info/SOURCES.txt +7 -0
- athena_llm-0.2.0/athena_llm.egg-info/dependency_links.txt +1 -0
- athena_llm-0.2.0/athena_llm.egg-info/requires.txt +2 -0
- athena_llm-0.2.0/athena_llm.egg-info/top_level.txt +1 -0
- athena_llm-0.2.0/athena_llm.py +444 -0
- athena_llm-0.2.0/pyproject.toml +22 -0
- athena_llm-0.2.0/setup.cfg +4 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
athena_llm
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Athena LLM client — unified async completion across a frontier API (Anthropic),
|
|
2
|
+
hosted-open providers (DeepInfra / Together), and the local Ollama service on MS-01.
|
|
3
|
+
|
|
4
|
+
Backend selection is per call, so "use an open model where it makes sense" is a
|
|
5
|
+
config choice, not a rewrite. Its only dependencies are `anthropic` and `httpx`,
|
|
6
|
+
which the AI projects already have — a framework was rejected on measurement
|
|
7
|
+
(see `docs/provider-redundancy.md`).
|
|
8
|
+
|
|
9
|
+
**This module is the seam.** Every model call in a project goes through `complete()`,
|
|
10
|
+
so the internals here can change — a second provider, a different library, a gateway —
|
|
11
|
+
without touching a single call site. That is what makes the cheap implementation the
|
|
12
|
+
correct one: it stays reversible.
|
|
13
|
+
|
|
14
|
+
Local inference keeps data on MS-01 (consistent with the MCP data-boundary rule).
|
|
15
|
+
|
|
16
|
+
Env:
|
|
17
|
+
ATHENA_OLLAMA_URL default http://127.0.0.1:11434 (localhost-only by design)
|
|
18
|
+
ATHENA_TIMEOUT default 120 (seconds)
|
|
19
|
+
ATHENA_HOSTED_CHAIN default "deepinfra,together" — ordered hosted-open failover
|
|
20
|
+
DEEPINFRA_API_KEY / TOGETHER_API_KEY per-provider keys (per-app, never shared)
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
from athena_llm import complete
|
|
24
|
+
text = await complete(system, user, backend="deepinfra",
|
|
25
|
+
model="deepseek-ai/DeepSeek-V4-Pro", max_tokens=400)
|
|
26
|
+
# backend="anthropic", model="claude-sonnet-5" -> frontier path
|
|
27
|
+
# backend="ollama", model="qwen2.5:7b" -> on-box path
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import base64
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import mimetypes
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import time
|
|
38
|
+
|
|
39
|
+
import anthropic
|
|
40
|
+
import httpx
|
|
41
|
+
|
|
42
|
+
log = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
OLLAMA_URL = os.environ.get("ATHENA_OLLAMA_URL", "http://127.0.0.1:11434")
|
|
45
|
+
DEFAULT_TIMEOUT = float(os.environ.get("ATHENA_TIMEOUT", "120"))
|
|
46
|
+
|
|
47
|
+
# Hosted-open providers, all OpenAI-compatible → one code path, no new dep.
|
|
48
|
+
# A second provider is not a nicety: a migration off frontier fixes cap-exhaustion and
|
|
49
|
+
# REBUILDS single-vendor risk at a new address. Same weights, different vendor, so a
|
|
50
|
+
# failover changes the address and not the answers.
|
|
51
|
+
# Kept as module-level names for back-compat: callers that predate the provider chain
|
|
52
|
+
# import DEEPINFRA_URL directly (e.g. a path that POSTs to the vendor itself rather than
|
|
53
|
+
# going through complete()). Such a caller is OUTSIDE the seam and gets no failover.
|
|
54
|
+
DEEPINFRA_URL = os.environ.get("DEEPINFRA_URL", "https://api.deepinfra.com/v1/openai")
|
|
55
|
+
TOGETHER_URL = os.environ.get("TOGETHER_URL", "https://api.together.xyz/v1")
|
|
56
|
+
|
|
57
|
+
HOSTED_PROVIDERS = {
|
|
58
|
+
"deepinfra": {
|
|
59
|
+
"url": DEEPINFRA_URL,
|
|
60
|
+
"probe_url": "https://api.deepinfra.com/v1/openai", # canonical — see _probe_provider
|
|
61
|
+
"key_var": "DEEPINFRA_API_KEY",
|
|
62
|
+
# fail_fast: when DeepInfra has no capacity, reject INSTANTLY with 429 instead of
|
|
63
|
+
# holding the connection open. Measured on BidScout 2026-08-04: without it, latencies
|
|
64
|
+
# were 60s(timeout) / 27s / 60s(timeout) / 18s; with it, 0.1-0.2s every time. That
|
|
65
|
+
# matters far more here than there, because _hosted_once retries 3x at DEFAULT_TIMEOUT
|
|
66
|
+
# (120s) — a stalled provider burned ~364s BEFORE the chain ever tried Together, so the
|
|
67
|
+
# second source could not do its job. A rejected request never reaches the model and is
|
|
68
|
+
# not billed, and a healthy DeepInfra serves normally, so there is no downside.
|
|
69
|
+
"quirks": {"fail_fast": True},
|
|
70
|
+
},
|
|
71
|
+
"together": {
|
|
72
|
+
"url": TOGETHER_URL,
|
|
73
|
+
"probe_url": "https://api.together.xyz/v1",
|
|
74
|
+
"key_var": "TOGETHER_API_KEY",
|
|
75
|
+
# Together serves DeepSeek-V4-Pro in REASONING mode by default: the whole max_tokens
|
|
76
|
+
# budget is spent on reasoning_tokens and `content` comes back EMPTY with
|
|
77
|
+
# finish_reason=length. DeepInfra returns content for the identical request. Same
|
|
78
|
+
# weights, different serving config — normalize it, or a failover silently answers
|
|
79
|
+
# nothing. Both providers accept this param.
|
|
80
|
+
"quirks": {"reasoning_effort": "none"},
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
# Ordered failover chain. A provider with no key configured is skipped, so a project
|
|
84
|
+
# that hasn't set up a second vendor keeps today's single-provider behaviour exactly.
|
|
85
|
+
HOSTED_CHAIN = [p.strip() for p in
|
|
86
|
+
os.environ.get("ATHENA_HOSTED_CHAIN", "deepinfra,together").split(",")
|
|
87
|
+
if p.strip()]
|
|
88
|
+
|
|
89
|
+
_TRANSIENT_HTTP = (408, 409, 425, 429, 500, 502, 503, 504, 529)
|
|
90
|
+
_PROBE_TTL = 60.0
|
|
91
|
+
_probe_cache: dict[str, tuple[float, str]] = {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def loads_lenient(text: str):
|
|
95
|
+
"""`json.loads` for hosted-open output. Frontier models return clean JSON; hosted-open
|
|
96
|
+
ones wrap it in ```code fences``` and leak raw control chars even in json mode — which
|
|
97
|
+
vanilla `json.loads` rejects (fleet gotcha, LESSONS 2026-07-19). Strips fences, tolerates
|
|
98
|
+
control chars (`strict=False`), falls back to the first {...}/[...] block. Raises on total
|
|
99
|
+
failure so a real parse problem still surfaces.
|
|
100
|
+
|
|
101
|
+
Also strips a leading Qwen3 `<think>…</think>` block, which the model emits (often empty)
|
|
102
|
+
even with `/no_think`."""
|
|
103
|
+
s = (text or "").strip()
|
|
104
|
+
s = re.sub(r"^<think>.*?</think>", "", s, count=1, flags=re.DOTALL).strip()
|
|
105
|
+
if s.startswith("```"):
|
|
106
|
+
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
|
107
|
+
s = re.sub(r"\s*```$", "", s).strip()
|
|
108
|
+
try:
|
|
109
|
+
return json.loads(s, strict=False)
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
m = re.search(r"[\{\[].*[\}\]]", s, re.DOTALL)
|
|
112
|
+
if m:
|
|
113
|
+
return json.loads(m.group(0), strict=False)
|
|
114
|
+
raise
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_anthropic_client: anthropic.AsyncAnthropic | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _anthropic() -> anthropic.AsyncAnthropic:
|
|
121
|
+
"""Client for the frontier (Anthropic) path.
|
|
122
|
+
|
|
123
|
+
Every BULK/BATCH caller flows through here, so this is the single place to isolate their
|
|
124
|
+
spend: set ``ANTHROPIC_SCORING_API_KEY`` to a SEPARATE workspace with its own cap, and the
|
|
125
|
+
whole batch bills there. That workspace hitting its cap then only stalls batch work — it
|
|
126
|
+
can never starve on-demand user analysis, which bills to ``ANTHROPIC_API_KEY`` through its
|
|
127
|
+
own client. Unset → falls back to ``ANTHROPIC_API_KEY`` (unchanged single-key behaviour)."""
|
|
128
|
+
global _anthropic_client
|
|
129
|
+
if _anthropic_client is None:
|
|
130
|
+
key = os.environ.get("ANTHROPIC_SCORING_API_KEY") or os.environ.get("ANTHROPIC_API_KEY")
|
|
131
|
+
_anthropic_client = anthropic.AsyncAnthropic(api_key=key) if key else anthropic.AsyncAnthropic()
|
|
132
|
+
return _anthropic_client
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def eval_anthropic_client() -> anthropic.AsyncAnthropic:
|
|
136
|
+
"""Client for the frontier arm of an A/B or any eval — NEVER the production key.
|
|
137
|
+
|
|
138
|
+
A bare ``anthropic.AsyncAnthropic()`` inherits ``ANTHROPIC_API_KEY``, i.e. the live
|
|
139
|
+
product's workspace and cap; an eval through it can (and once did) trip prod's cap and take
|
|
140
|
+
the paid product down. Evals bill to a dedicated ``ANTHROPIC_EVAL_API_KEY``. Hard-fails when
|
|
141
|
+
unset rather than silently falling back — an eval that can't isolate its spend must not run."""
|
|
142
|
+
key = os.environ.get("ANTHROPIC_EVAL_API_KEY")
|
|
143
|
+
if not key:
|
|
144
|
+
raise RuntimeError(
|
|
145
|
+
"ANTHROPIC_EVAL_API_KEY is not set. Evals must bill to a dedicated eval workspace, "
|
|
146
|
+
"never ANTHROPIC_API_KEY (prod) — set an eval key or don't run the frontier arm."
|
|
147
|
+
)
|
|
148
|
+
return anthropic.AsyncAnthropic(api_key=key)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _encode_image(item) -> tuple[str, str]:
|
|
152
|
+
"""Return (base64_data, media_type) for one image.
|
|
153
|
+
|
|
154
|
+
`item` may be a filesystem path (str), raw bytes, or a (bytes|path, media_type) pair.
|
|
155
|
+
Rasterized PDF pages (`pdftoppm -png`) are PNG, so that's the default when the type
|
|
156
|
+
can't be inferred."""
|
|
157
|
+
media_type = None
|
|
158
|
+
if isinstance(item, tuple):
|
|
159
|
+
item, media_type = item
|
|
160
|
+
if isinstance(item, (bytes, bytearray)):
|
|
161
|
+
data = bytes(item)
|
|
162
|
+
else: # filesystem path
|
|
163
|
+
with open(item, "rb") as fh:
|
|
164
|
+
data = fh.read()
|
|
165
|
+
media_type = media_type or mimetypes.guess_type(str(item))[0]
|
|
166
|
+
return base64.standard_b64encode(data).decode("ascii"), media_type or "image/png"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def _probe_provider(provider: str) -> str:
|
|
170
|
+
"""Is the provider that just failed actually reachable? -> healthy|down|unknown.
|
|
171
|
+
|
|
172
|
+
A failover fires on a SYMPTOM — timeout, dropped connection, 5xx — and those are identical
|
|
173
|
+
whether the vendor is down or the fault is local (a restart, the container network, a bad
|
|
174
|
+
base URL, a dead key). Unattended, a silent failover hides our own bugs: the product keeps
|
|
175
|
+
answering and nobody learns the primary is broken. `healthy` is the interesting answer.
|
|
176
|
+
|
|
177
|
+
Probes the provider's CANONICAL endpoint, never the configured one: if the failure was a
|
|
178
|
+
bad base URL, probing that same bad URL would report "down" and blame the vendor for our
|
|
179
|
+
own misconfiguration — the exact misattribution this is here to prevent.
|
|
180
|
+
|
|
181
|
+
GET /models only — no inference, no spend. Cached so a burst can't become a probe storm."""
|
|
182
|
+
spec = HOSTED_PROVIDERS.get(provider)
|
|
183
|
+
if not spec:
|
|
184
|
+
return "unknown"
|
|
185
|
+
key = os.environ.get(spec["key_var"])
|
|
186
|
+
if not key:
|
|
187
|
+
return "unknown"
|
|
188
|
+
|
|
189
|
+
now = time.monotonic()
|
|
190
|
+
cached = _probe_cache.get(provider)
|
|
191
|
+
if cached and now - cached[0] < _PROBE_TTL:
|
|
192
|
+
return cached[1]
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
196
|
+
r = await client.get(f"{spec.get('probe_url') or spec['url']}/models",
|
|
197
|
+
headers={"Authorization": f"Bearer {key}"})
|
|
198
|
+
# 2xx = up and our key works. 401/403 = up, but OUR key is the problem — still
|
|
199
|
+
# "healthy" for attribution: the vendor isn't down, we are.
|
|
200
|
+
verdict = "healthy" if r.status_code < 500 else "down"
|
|
201
|
+
except Exception:
|
|
202
|
+
verdict = "down"
|
|
203
|
+
|
|
204
|
+
_probe_cache[provider] = (now, verdict)
|
|
205
|
+
return verdict
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def _hosted_once(provider: str, payload: dict, timeout: float | None):
|
|
209
|
+
"""One hosted-open provider, up to 3 attempts. Retries transient failures only — a single
|
|
210
|
+
load spike must not kill a whole analysis, but a 4xx (bad request, bad model, dead key) is
|
|
211
|
+
a config problem and is raised immediately rather than retried into a failover that hides it."""
|
|
212
|
+
spec = HOSTED_PROVIDERS[provider]
|
|
213
|
+
key = os.environ[spec["key_var"]]
|
|
214
|
+
payload = {**payload, **(spec.get("quirks") or {})}
|
|
215
|
+
last_exc: Exception | None = None
|
|
216
|
+
for attempt in range(3):
|
|
217
|
+
try:
|
|
218
|
+
async with httpx.AsyncClient(timeout=timeout or DEFAULT_TIMEOUT) as client:
|
|
219
|
+
r = await client.post(
|
|
220
|
+
f"{spec['url']}/chat/completions",
|
|
221
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
222
|
+
json=payload,
|
|
223
|
+
)
|
|
224
|
+
if r.status_code in _TRANSIENT_HTTP:
|
|
225
|
+
last_exc = RuntimeError(f"{provider} transient HTTP {r.status_code}")
|
|
226
|
+
if attempt < 2:
|
|
227
|
+
await asyncio.sleep(1.5 * (attempt + 1))
|
|
228
|
+
continue
|
|
229
|
+
raise last_exc
|
|
230
|
+
r.raise_for_status()
|
|
231
|
+
data = r.json()
|
|
232
|
+
# An HTTP 200 with EMPTY content is a failure, not an answer — never hand the caller
|
|
233
|
+
# "". Seen for real and intermittently: a provider serving the model in reasoning
|
|
234
|
+
# mode spends the whole budget before writing a word (finish_reason=length). It
|
|
235
|
+
# recurs sporadically even with reasoning disabled, so retry here first and only
|
|
236
|
+
# then let the chain fail over — a retry is cheaper than a vendor switch.
|
|
237
|
+
choice = (data.get("choices") or [{}])[0]
|
|
238
|
+
if not ((choice.get("message") or {}).get("content") or "").strip():
|
|
239
|
+
last_exc = RuntimeError(
|
|
240
|
+
f"{provider} returned empty content "
|
|
241
|
+
f"(finish_reason={choice.get('finish_reason')})")
|
|
242
|
+
if attempt < 2:
|
|
243
|
+
await asyncio.sleep(1.5 * (attempt + 1))
|
|
244
|
+
continue
|
|
245
|
+
raise last_exc
|
|
246
|
+
return data
|
|
247
|
+
except (httpx.TimeoutException, httpx.TransportError) as e:
|
|
248
|
+
last_exc = e
|
|
249
|
+
if attempt < 2:
|
|
250
|
+
await asyncio.sleep(1.5 * (attempt + 1))
|
|
251
|
+
continue
|
|
252
|
+
raise
|
|
253
|
+
raise last_exc # pragma: no cover — loop always returns or raises
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
async def _hosted_open(payload: dict, timeout: float | None):
|
|
257
|
+
"""Walk the provider chain; return the first success. Providers without a configured key
|
|
258
|
+
are skipped, so a project with one vendor behaves exactly as before.
|
|
259
|
+
|
|
260
|
+
On a failover, probe the provider that failed and log ONE greppable line with a verdict —
|
|
261
|
+
`provider=healthy` means the fault was ours. Logged, never pushed: a failover that
|
|
262
|
+
succeeded is not an on-fire event (fleet notification standard)."""
|
|
263
|
+
usable = [p for p in HOSTED_CHAIN
|
|
264
|
+
if p in HOSTED_PROVIDERS and os.environ.get(HOSTED_PROVIDERS[p]["key_var"])]
|
|
265
|
+
if not usable:
|
|
266
|
+
raise RuntimeError(
|
|
267
|
+
f"no hosted-open provider configured — set one of: "
|
|
268
|
+
f"{', '.join(HOSTED_PROVIDERS[p]['key_var'] for p in HOSTED_CHAIN if p in HOSTED_PROVIDERS)}"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
first_error: Exception | None = None
|
|
272
|
+
failed_from: str | None = None
|
|
273
|
+
for provider in usable:
|
|
274
|
+
try:
|
|
275
|
+
data = await _hosted_once(provider, payload, timeout)
|
|
276
|
+
except Exception as e:
|
|
277
|
+
if first_error is None:
|
|
278
|
+
first_error, failed_from = e, provider
|
|
279
|
+
log.warning("hosted-open provider %s failed: %s: %s",
|
|
280
|
+
provider, type(e).__name__, e)
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
if failed_from:
|
|
284
|
+
health = await _probe_provider(failed_from)
|
|
285
|
+
verdict = {"healthy": "ours", "down": "theirs"}.get(health, "unknown")
|
|
286
|
+
log.warning(
|
|
287
|
+
"ATHENA_FALLBACK fired=t from=%s to=%s model=%s err=%s provider=%s verdict=%s",
|
|
288
|
+
failed_from, provider, payload.get("model"),
|
|
289
|
+
type(first_error).__name__, health, verdict,
|
|
290
|
+
)
|
|
291
|
+
return data
|
|
292
|
+
|
|
293
|
+
raise first_error
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
async def complete(
|
|
297
|
+
system: str,
|
|
298
|
+
user: str,
|
|
299
|
+
*,
|
|
300
|
+
backend: str,
|
|
301
|
+
model: str,
|
|
302
|
+
max_tokens: int = 512,
|
|
303
|
+
temperature: float = 0.0,
|
|
304
|
+
json_mode: bool = False,
|
|
305
|
+
images: list | None = None,
|
|
306
|
+
timeout: float | None = None,
|
|
307
|
+
num_ctx: int | None = None,
|
|
308
|
+
eval: bool = False,
|
|
309
|
+
return_usage: bool = False,
|
|
310
|
+
api_key: str | None = None,
|
|
311
|
+
):
|
|
312
|
+
"""Return the model's text completion.
|
|
313
|
+
|
|
314
|
+
backend: "anthropic" (frontier), "ollama" (local, self-hosted), or "deepinfra" (hosted
|
|
315
|
+
open — OpenAI-compatible). NOTE: "deepinfra" now names the hosted-open TIER, not a
|
|
316
|
+
single vendor: it starts at DeepInfra and fails over along ATHENA_HOSTED_CHAIN. Parse
|
|
317
|
+
hosted-open JSON with `loads_lenient`, not raw `json.loads`.
|
|
318
|
+
json_mode: forces valid JSON output on the local (Ollama `format:"json"`) and hosted paths,
|
|
319
|
+
eliminating the sloppy-JSON failure mode of open models. Ignored on the Anthropic path
|
|
320
|
+
(callers already prompt for JSON there).
|
|
321
|
+
temperature: applied to local and hosted paths; the Anthropic path omits it so newer models
|
|
322
|
+
that reject sampling params don't 400.
|
|
323
|
+
images: optional list of images (paths, raw bytes, or (bytes|path, media_type) pairs) for a
|
|
324
|
+
vision task — e.g. rasterized PDF pages. Works on all three backends, so the same images
|
|
325
|
+
can be A/B'd through a local VLM, a hosted VLM, and the frontier model. Requires a
|
|
326
|
+
vision-capable model.
|
|
327
|
+
num_ctx: local path only — Ollama's context window in tokens. MUST be set for long-input
|
|
328
|
+
tasks: Ollama otherwise defaults to ~2048 and SILENTLY truncates the prompt to the tail.
|
|
329
|
+
eval: frontier arm of an A/B — route Anthropic through the dedicated ANTHROPIC_EVAL_API_KEY
|
|
330
|
+
(hard-fails if unset), so an eval can never bill to or trip the live product's cap.
|
|
331
|
+
return_usage: when True, return ``(text, {"input": int, "output": int})`` instead of just
|
|
332
|
+
text — so a caller can meter the actual token usage. Zeros if the backend omits it.
|
|
333
|
+
"""
|
|
334
|
+
if backend == "anthropic":
|
|
335
|
+
if images:
|
|
336
|
+
content: list = [
|
|
337
|
+
{"type": "image",
|
|
338
|
+
"source": {"type": "base64", "media_type": mt, "data": b64}}
|
|
339
|
+
for b64, mt in (_encode_image(i) for i in images)
|
|
340
|
+
]
|
|
341
|
+
content.append({"type": "text", "text": user})
|
|
342
|
+
else:
|
|
343
|
+
content = user
|
|
344
|
+
# `eval=True` is opt-in, so a harness that simply forgets it bills the PRODUCT's key and
|
|
345
|
+
# nothing complains (that is exactly how a packet-QC A/B billed BidScout's prod key on
|
|
346
|
+
# 2026-08-01). Athena's tooling sets ATHENA_EVAL_MODE=1; under it, an un-flagged frontier
|
|
347
|
+
# call is a bug, so fail loudly instead of spending. Product code never sets the var.
|
|
348
|
+
if os.environ.get("ATHENA_EVAL_MODE") == "1" and not eval:
|
|
349
|
+
raise RuntimeError(
|
|
350
|
+
"ATHENA_EVAL_MODE=1 but complete(backend='anthropic') was called without eval=True — "
|
|
351
|
+
"that would bill the PRODUCT's ANTHROPIC_API_KEY for an eval. Pass eval=True to route "
|
|
352
|
+
"through ANTHROPIC_EVAL_API_KEY."
|
|
353
|
+
)
|
|
354
|
+
# An explicit api_key wins over the environment. Why this exists: a caller can hold a
|
|
355
|
+
# perfectly good key in CONFIG (pydantic reading a .env FILE) while os.environ has
|
|
356
|
+
# none — a systemd unit without EnvironmentFile, a cron job, a bare script. The guard
|
|
357
|
+
# `if not settings.anthropic_api_key` then PASSES and this client still fails auth,
|
|
358
|
+
# which reads as coverage while providing none. That divergence caused three separate
|
|
359
|
+
# QuantumPools failures on 2026-08-04 alone, one of them a regulatory read that had
|
|
360
|
+
# never once succeeded. Callers holding a key should pass it rather than hope the
|
|
361
|
+
# process inherited it. Hosted-open providers still resolve from the environment:
|
|
362
|
+
# the chain fails over ACROSS vendors, so a single key cannot serve them.
|
|
363
|
+
if api_key and not eval:
|
|
364
|
+
client = anthropic.AsyncAnthropic(api_key=api_key)
|
|
365
|
+
else:
|
|
366
|
+
client = eval_anthropic_client() if eval else _anthropic()
|
|
367
|
+
resp = await client.messages.create(
|
|
368
|
+
model=model,
|
|
369
|
+
max_tokens=max_tokens,
|
|
370
|
+
system=system,
|
|
371
|
+
messages=[{"role": "user", "content": content}],
|
|
372
|
+
)
|
|
373
|
+
text = resp.content[0].text
|
|
374
|
+
if return_usage:
|
|
375
|
+
u = getattr(resp, "usage", None)
|
|
376
|
+
return text, {"input": getattr(u, "input_tokens", 0) or 0,
|
|
377
|
+
"output": getattr(u, "output_tokens", 0) or 0}
|
|
378
|
+
return text
|
|
379
|
+
|
|
380
|
+
if backend == "ollama":
|
|
381
|
+
user_msg: dict = {"role": "user", "content": user}
|
|
382
|
+
if images:
|
|
383
|
+
user_msg["images"] = [b64 for b64, _ in (_encode_image(i) for i in images)]
|
|
384
|
+
payload: dict = {
|
|
385
|
+
"model": model,
|
|
386
|
+
"messages": [{"role": "system", "content": system}, user_msg],
|
|
387
|
+
"stream": False,
|
|
388
|
+
"options": {"num_predict": max_tokens, "temperature": temperature},
|
|
389
|
+
}
|
|
390
|
+
if num_ctx is not None:
|
|
391
|
+
payload["options"]["num_ctx"] = num_ctx
|
|
392
|
+
if json_mode:
|
|
393
|
+
payload["format"] = "json"
|
|
394
|
+
async with httpx.AsyncClient(timeout=timeout or DEFAULT_TIMEOUT) as client:
|
|
395
|
+
r = await client.post(f"{OLLAMA_URL}/api/chat", json=payload)
|
|
396
|
+
r.raise_for_status()
|
|
397
|
+
data = r.json()
|
|
398
|
+
if return_usage:
|
|
399
|
+
return data["message"]["content"], {
|
|
400
|
+
"input": data.get("prompt_eval_count", 0) or 0,
|
|
401
|
+
"output": data.get("eval_count", 0) or 0}
|
|
402
|
+
return data["message"]["content"]
|
|
403
|
+
|
|
404
|
+
if backend in ("deepinfra", "hosted", "together"):
|
|
405
|
+
sys_prompt = system
|
|
406
|
+
# Qwen3 runs a hidden reasoning pass by default; disable it for fast, deterministic
|
|
407
|
+
# instruct-style scoring/summarizing (fleet finding — score with /no_think).
|
|
408
|
+
if "qwen3" in model.lower():
|
|
409
|
+
sys_prompt = "/no_think\n" + system
|
|
410
|
+
if images:
|
|
411
|
+
user_content: list | str = [
|
|
412
|
+
{"type": "image_url",
|
|
413
|
+
"image_url": {"url": f"data:{mt};base64,{b64}"}}
|
|
414
|
+
for b64, mt in (_encode_image(i) for i in images)
|
|
415
|
+
]
|
|
416
|
+
user_content.append({"type": "text", "text": user})
|
|
417
|
+
else:
|
|
418
|
+
user_content = user
|
|
419
|
+
payload = {
|
|
420
|
+
"model": model,
|
|
421
|
+
"messages": [{"role": "system", "content": sys_prompt},
|
|
422
|
+
{"role": "user", "content": user_content}],
|
|
423
|
+
"max_tokens": max_tokens,
|
|
424
|
+
"temperature": temperature,
|
|
425
|
+
}
|
|
426
|
+
if json_mode:
|
|
427
|
+
payload["response_format"] = {"type": "json_object"}
|
|
428
|
+
|
|
429
|
+
# An explicit `backend="together"` pins that vendor; "deepinfra"/"hosted" walk the chain.
|
|
430
|
+
global HOSTED_CHAIN
|
|
431
|
+
if backend == "together":
|
|
432
|
+
data = await _hosted_once("together", payload, timeout)
|
|
433
|
+
else:
|
|
434
|
+
data = await _hosted_open(payload, timeout)
|
|
435
|
+
|
|
436
|
+
text = data["choices"][0]["message"]["content"]
|
|
437
|
+
if return_usage:
|
|
438
|
+
usage = data.get("usage") or {}
|
|
439
|
+
return text, {"input": usage.get("prompt_tokens", 0) or 0,
|
|
440
|
+
"output": usage.get("completion_tokens", 0) or 0}
|
|
441
|
+
return text
|
|
442
|
+
|
|
443
|
+
raise ValueError(
|
|
444
|
+
f"unknown backend: {backend!r} (expected 'anthropic', 'ollama', or 'deepinfra')")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Packaged so projects INSTALL the client instead of copying it.
|
|
2
|
+
#
|
|
3
|
+
# Vendoring drifted in exactly the way you'd predict: BidScout's copy grew a whole
|
|
4
|
+
# hosted-open backend, retries and usage metering that the canonical file never had,
|
|
5
|
+
# so the "shared" client was shared in name only and a fix landed in one place.
|
|
6
|
+
# Pin a tag per app; upgrade is a bump plus a redeploy.
|
|
7
|
+
#
|
|
8
|
+
# Deps stay anthropic + httpx on purpose — a framework was rejected on measurement
|
|
9
|
+
# (27 transitive packages for ~30 lines of retry logic). See docs/provider-redundancy.md.
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["setuptools>=61"]
|
|
12
|
+
build-backend = "setuptools.build_meta"
|
|
13
|
+
|
|
14
|
+
[project]
|
|
15
|
+
name = "athena-llm"
|
|
16
|
+
version = "0.2.0"
|
|
17
|
+
description = "Athena LLM client — one seam across frontier, hosted-open (with failover), and local backends"
|
|
18
|
+
requires-python = ">=3.10"
|
|
19
|
+
dependencies = ["anthropic>=0.40.0", "httpx>=0.27"]
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
py-modules = ["athena_llm"]
|