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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: athena-llm
3
+ Version: 0.2.0
4
+ Summary: Athena LLM client — one seam across frontier, hosted-open (with failover), and local backends
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: anthropic>=0.40.0
7
+ Requires-Dist: httpx>=0.27
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: athena-llm
3
+ Version: 0.2.0
4
+ Summary: Athena LLM client — one seam across frontier, hosted-open (with failover), and local backends
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: anthropic>=0.40.0
7
+ Requires-Dist: httpx>=0.27
@@ -0,0 +1,7 @@
1
+ athena_llm.py
2
+ pyproject.toml
3
+ athena_llm.egg-info/PKG-INFO
4
+ athena_llm.egg-info/SOURCES.txt
5
+ athena_llm.egg-info/dependency_links.txt
6
+ athena_llm.egg-info/requires.txt
7
+ athena_llm.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ anthropic>=0.40.0
2
+ httpx>=0.27
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+