Compare commits

...

42 Commits

Author SHA1 Message Date
c5721e4f87 Add Makefile with start target for HTTP serve mode
Some checks failed
CI / test (3.12) (push) Has been cancelled
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
Provides `make start` to run the llm-connect server via uv, with optional
LLM_CONNECT_* overrides and standard dev targets for install, test, lint,
and typecheck.
2026-07-07 19:59:35 +02:00
3ea9366c5a Draft capability entry (reuse-surface REUSE-WP-0017-T04, cohort 2)
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
Honest first-pass maturity vector grounded in README/docs/tests present
in this repo; no invented evidence. Flagged for human review before
publish. See reuse-surface history/2026-07-06-coverage-classification.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 19:41:37 +02:00
5b50b1ada5 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
Updated by fix-consistency on 2026-07-03:
  - update .custodian-brief.md for llm-connect
2026-07-03 18:47:25 +02:00
dfd2ce7754 activity-core: ExternalSecret for llm-connect-provider-secrets via openbao-activity-core CSS (CCR-2026-0003)
Some checks failed
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.10) (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:56:21 +02:00
2ff9263f9c Normalize agent instructions and workplan frontmatter (STATE-WP-0067)
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
- Align agent files with on-disk workplan prefixes (infer from workplan ids)
- Set workplan domain to registered domain_slug; add topic_slug where applicable
- Repair frontmatter delimiter formatting; migrate legacy task status literals
- Regenerate AGENTS.md, CLAUDE.md, and .claude/rules from State Hub templates
2026-06-22 23:16:27 +02:00
3e2cdef9b5 Mark .repo-classification.yaml human-reviewed (CUST-WP-0050 T02)
Some checks failed
CI / test (3.11) (push) Has been cancelled
CI / test (3.10) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:40:44 +02:00
7c86051835 Reclassify as tooling (CUST-WP-0050 T02)
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
Apply the new 'tooling' category (reusable internal tooling/infrastructure)
from the Repo Classification Standard. First-pass agent classification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 03:06:02 +02:00
de7be61f0a Add repo classification (CUST-WP-0050 T02)
First-pass agent classification per the Repo Classification Standard v1.0
(canon-repo-classification); pending human review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:44:47 +02:00
c0c9a3da1d docs: record railiance01 llm-connect smoke evidence
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
Document the 2026-06-19 deployment and in-namespace fixture smoke on
railiance01, where activity-core runs. Clarify that the stable Service URL
is cluster-local and point scheduled triage evidence to ACTIVITY-WP-0010.
2026-06-19 15:58:04 +02:00
92e55fde57 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.10) (push) Has been cancelled
Updated by fix-consistency on 2026-06-19:
  - update .custodian-brief.md for llm-connect
2026-06-19 13:51:26 +02:00
90eb39c247 Complete activity-core LLM endpoint handoff (LLM-WP-0006)
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
Switch the custodian triage default from anthropic/claude-sonnet-4 to
google/gemini-2.5-flash, which advertises structured-output support on
OpenRouter. Tighten the OpenRouter adapter to send strict JSON schema
requests and set provider.require_parameters=true so routing only hits
providers that honor the requested response_format.

Update Kubernetes deploy docs and config for the verified coulombcore
handoff: Containerfile build path, image-pull-policy=Never for smoke
pods, credential-routing notes, and live smoke evidence. Mark
LLM-WP-0006 finished with closure notes from 2026-06-18.
2026-06-19 13:51:12 +02:00
6a0319ee86 Add credential routing instructions for all agent runtimes
Some checks failed
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.10) (push) Has been cancelled
2026-06-18 22:48:46 +02:00
f60a2562bb chore(consistency): sync task status from DB [auto]
Some checks failed
CI / test (3.12) (push) Has been cancelled
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
Updated by fix-consistency on 2026-06-17:
  - update .custodian-brief.md for llm-connect
2026-06-17 07:26:42 +02:00
aa0335dba4 Add capability registry scaffold (REUSE-WP-0014-T05 B03)
Some checks failed
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.10) (push) Has been cancelled
2026-06-16 01:54:06 +02:00
14ba47c129 Add activity-core LLM endpoint support
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
2026-06-07 19:24:45 +02:00
1d9fc107ed chore(consistency): sync task status from DB [auto]
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
Updated by fix-consistency on 2026-06-07:
  - update .custodian-brief.md for llm-connect
2026-06-07 16:22:30 +02:00
9204aafb38 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
Updated by fix-consistency on 2026-06-07:
  - update .custodian-brief.md for llm-connect
2026-06-07 13:46:41 +02:00
1edc02de7c chore(consistency): sync task status from DB [auto]
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
Updated by fix-consistency on 2026-06-07:
  - update .custodian-brief.md for llm-connect
2026-06-07 11:46:51 +02:00
24f4c09d42 Implement llm-connect ADHOC diagnostics
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
2026-06-03 11:56:21 +02:00
79c899b694 Capture llm-connect lessons from CUST-WP-0045 canary as ADHOC-2026-06-02
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
The 2026-06-02 daily-triage canary debugging session uncovered five real
bugs (commits 9de0f49, 435da49, cd4551c, 583ab57, 1b01f0e), mostly because
llm-connect has no way to see what payload the adapter sent or what the
provider returned. Capture the six structural improvements that would
collapse the next diagnosis of this shape from half a day to minutes:

  T01 — LLM_CONNECT_DEBUG envelope mode for /execute responses
  T02 — ThreadingHTTPServer drop-in replacement for stdlib HTTPServer
  T03 — Per-call audit log + replay CLI (LLM_CONNECT_AUDIT_DIR)
  T04 — Apply param-translation contract to OpenAI and Gemini adapters
  T05 — Provider-agnostic structured-output smoke test in CI
  T06 — Document the model_params translation contract for adapter authors

All six registered in the State Hub under workstream
adhoc-llmc-2026-06-02 (1c936c91-79c7-427d-ab37-9052e8a61cda).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 15:55:42 +02:00
1b01f0edf4 Honour explicit OpenRouter --model when it equals the adapter default
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
The adapter compared self._model to _DEFAULT_MODEL ("anthropic/claude-sonnet-4")
to decide whether to honour the constructor's model. When a caller passes
that exact value via --model, the comparison treats it as "not specified"
and falls through to RunConfig.model_name, which defaults to "gpt-4". So
every llm-connect call started with --provider openrouter --model
anthropic/claude-sonnet-4 actually landed on OpenAI's gpt-4 — and on
gpt-4 OpenAI's structured-output response_format requires a model with
schema support that gpt-4 lacks, returning 400. The CUST-WP-0045 canary
hit this for hours; the smoke probes that worked were the ones with no
json_schema, where gpt-4 returned fine.

Track _explicit_model separately so a constructor or LLMConfig that
matches the default is still treated as a real intent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 14:50:37 +02:00
583ab57a59 Set response_format json_schema strict=False in OpenRouter adapter
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
The previous strict=True default rejected the activity-core daily-triage
schema (and most real-world application schemas) because OpenAI strict
mode requires additionalProperties:false on every object and every
property in the required list. Application-supplied schemas typically
do not meet that bar — adding additionalProperties recursively at the
adapter would be surprising and may break callers that rely on extra
fields. Flipping strict to False keeps the schema as a soft constraint;
the model still produces structured output and the activity-core
canary's 400 from OpenRouter goes away.

Callers who need strict enforcement can pass response_format directly
via model_params, where the adapter's pass-through handling preserves
the strict flag they set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 14:18:33 +02:00
cd4551c575 Translate json_schema and drop non-OpenAI fields in OpenRouter adapter
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
The adapter previously did a blind payload.update(config.model_params).
For callers like activity-core that pass reasoning_effort, max_depth,
and json_schema (Claude / llm-connect-specific fields), those leaked
into the OpenAI Chat Completions request body and OpenRouter rejected
the whole call with HTTP 400. CUST-WP-0045 canary on 2026-06-02 hit
this — manual repro confirmed: same prompt with no model_params returns
a clean 10-recommendation WSJF report in 4.5s; with model_params
included, every call 400s.

Replace the merge with a whitelist + translation step:

- pass-through known OpenAI Chat Completions fields (top_p, stop, seed,
  tools, response_format, etc.)
- translate json_schema into the proper response_format wrapper
  ({type:"json_schema", json_schema:{name,schema,strict}})
- drop documented non-OpenAI fields (reasoning_effort, max_depth) so
  the payload stays valid
- silently drop unknown keys rather than risk another 400

The same pattern will need to apply to the OpenAI and Gemini adapters
when their callers start passing provider-specific keys — left as
follow-up rather than speculative refactoring.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 14:15:24 +02:00
435da49263 Prefer JSON-bearing envelope fields, skip metadata, in Claude CLI unwrap
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
The first CUST-WP-0045 canary retry after 9de0f49 still failed schema
validation with `Expecting value: line 1 column 1 (char 0)`. The original
allowlist returned envelope.result verbatim, which on longer prompts
carries the model's conversational preamble ("Triage report generated
and returned via structured output. Key signals: ..."), not the
schema-enforced JSON. The actual structured payload lives in a different
envelope field whose name varies across CLI versions.

Make the unwrap order-aware:
  1. Scan envelope fields and return the first one whose value parses as
     JSON (dict, list, or a string that loads cleanly). Skip well-known
     metadata keys (type, usage, total_cost_usd, etc.) so telemetry can
     never be mistaken for the model payload.
  2. Fall back to the original text-field allowlist only when no field
     carries JSON, so non-schema callers via this same code path still
     see the model's prose.
  3. Surface the raw envelope as last resort.

This is robust against unknown envelope shapes — as long as the schema-
enforced JSON appears somewhere in a non-metadata field, the adapter
will find it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 12:44:25 +02:00
9de0f495db Pass --output-format json with --json-schema and unwrap CLI envelope
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
The Claude Code adapter previously passed --json-schema alone. On Claude
CLI 2.1.160 that combination still emits the model's conversational
preamble on stdout while the schema-enforced structured payload ships on
a sidecar channel the adapter cannot read. Result: callers requesting
structured output got prose that fails JSON parsing downstream — exactly
the failure mode the activity-core CUST-WP-0045 daily triage canary hit
on 2026-06-01 ("Triage report generated and returned via structured
output. Key signals:..." → json.loads error at column 1).

Fix: when --json-schema is set, also pass --output-format json. The CLI
then writes a JSON envelope on stdout. The adapter unwraps it by
probing a small allowlist of known text-bearing fields (result,
result_text, content, text, output). Unknown envelope shapes fall
through to raw stdout so the operator can introspect the structure and
extend the allowlist.

The unwrap path is only triggered when --json-schema was set, so non-
schema callers keep the existing raw-stdout behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 10:20:24 +02:00
b12d1af8bb Support Claude Code JSON schema execution
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
2026-05-21 03:19:27 +02:00
82e3c07928 Preserve llm-connect run config in server mode
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
2026-05-19 20:55:02 +02:00
c11c6afa3f Implement-LLM-WP-0005-cost-model-estimators
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
2026-05-19 05:02:20 +02:00
0054afe689 plan: WP-0005 — cost model and problem-class token estimators
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
Drafted workplan to move two consumer-side concerns into llm-connect:

- ModelRateRegistry: per-model USD-per-1k rates with provenance, a
  property of the base model, not the application.
- ProblemClass token estimators: generic shapes (chunk-summarization,
  entity-extraction, relation-extraction, judge-eval, report-synthesis)
  with base dimensions + tunable params; consumer supplies the shape
  of its problem and gets a TokenEstimate before any call.

Demand signal: the 2026-05-18 infospace-bench Lefevre Chapter-I smoke
ran 32 calls / 28k tokens / 0.009 USD actual against a planned 8.40
USD — the 1000x variance was entirely consumer-side because there is
no rate table in llm-connect to delegate to.

Three new modules (rates.py, costs.py, problem_classes.py), eight
tasks, registered as workstream 869196c5-551b-4eef-b8d8-cca6f770a9b0
under the custodian topic. A follow-on consumer workplan in
infospace-bench will migrate plan_generation_summary to delegate once
T01-T04 land here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 04:30:52 +02:00
4b685e849c Refresh agent instruction files
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
2026-05-18 16:55:44 +02:00
a27945101c Adaptive routing initial version
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
2026-05-18 11:38:12 +02:00
14838ae968 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for llm-connect
2026-05-17 22:54:25 +02:00
c4ad4bb9f2 Add adaptive cost-quality routing primitives
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
2026-05-17 21:32:27 +02:00
bf86a03c5d chore(consistency): sync task status from DB [auto]
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for llm-connect
2026-05-17 20:22:06 +02:00
37ace7b99c chore(consistency): sync task status from DB [auto]
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for llm-connect
2026-05-17 19:51:26 +02:00
bd2315cf4c chore(consistency): sync task status from DB [auto]
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for llm-connect
2026-05-17 19:21:57 +02:00
2136fb21d7 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for llm-connect
2026-05-17 18:47:30 +02:00
deade6ad76 plan: WP-0004 — adaptive cost-quality routing (todo)
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
Draft the workplan that extends the static RoutingPolicy (WP-0003) with
a quality observation ledger, a BaselineGrader (ClaudeCodeAdapter as the
default oracle), an AdaptiveRoutingPolicy that picks the cheapest
adapter clearing a per-task quality floor, and a sampled
ShadowingAdapter for production observation collection.

Scope is explicit: ship primitives only. Task-type taxonomy, quality
thresholds, baseline choice, and re-grading cadence stay with the
consumer. infospace-bench is the named first consumer; consumer wiring
deferred until T01-T03 land.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 17:17:07 +02:00
66dfc7cf06 Added INTENT.md file and reviewed scope
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
2026-05-03 17:46:24 +02:00
665e925be6 Scope update from repo-scoping refactor
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
2026-05-01 12:26:51 +02:00
a4b4a770ab chore(consistency): sync task status from DB [auto]
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
Updated by fix-consistency on 2026-05-01:
  - update .custodian-brief.md for llm-connect
2026-05-01 12:19:40 +02:00
d51d6303e2 feat: WP-0003 — RoutingPolicy (FR-2) and HTTP serve mode (FR-1)
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
FR-2 RoutingPolicy:
- RoutingPolicy + RoutingRule dataclasses in llm_connect/routing.py
- resolve(task_type, estimated_cost_per_1k=None) with cost-cap fallback
- Exported from llm_connect.__init__; contract doc at contracts/functional/routing-policy.md
- 11 tests covering rule match, cost-cap, fallback, unknown type, no-match

FR-1 HTTP serve mode:
- LLMServer in llm_connect/server.py (stdlib http.server, zero extra deps)
- POST /execute + GET /health; CLI via python -m llm_connect.server
- [server] optional-dep group added to pyproject.toml
- Contract doc at contracts/functional/server.md
- 9 tests: health, round-trip, 400/404/500 errors, config forwarding
- Added "mock" provider to factory for CLI default

All 101 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 22:34:00 +00:00
97 changed files with 10884 additions and 668 deletions

View File

@@ -1,58 +1,8 @@
## Architecture ## Architecture
llm-connect is structured as a **GAAF-2026 layered library**. See <!-- TODO: Describe the key design decisions and component structure.
`ARCHITECTURE-LAYERS.md` for the full layer map and scorecard. Key modules, data flows, external integrations, state machines, etc. -->
### Layer summary ## Quick Reference
``` `~/state-hub/mcp_server/TOOLS.md` — MCP tool reference
Core (frozen after v1)
LLMAdapter ABC adapter.py
RunConfig / LLMResponse models.py
LLMError hierarchy exceptions.py
MockLLMAdapter adapter.py ← test primitive, belongs with Core
Functional (evolvable, independently shippable)
OpenAIAdapter openai.py
GeminiAdapter gemini.py
OpenRouterAdapter openrouter.py
ClaudeCodeAdapter claude_code.py
EmbeddingAdapter ABC embedding_adapter.py
OpenAICompatibleEmbeddingAdapter embedding_openai.py
EmbeddingCache embedding_cache.py
create_adapter() factory.py
create_embedding_adapter() embedding_factory.py
_token_estimator _token_estimator.py
similarity utilities similarity.py
Configuration (user-controlled declarative state)
resolve_llm() chain toml_config.py ← 7-level TOML priority chain
LLMConfig / load_config config.py
_http shared utility _http.py ← also used by Functional adapters
```
### Dependency rule
Core ← Functional ← Configuration
No upward dependencies. `_http.py` is consumed by Functional only.
### Key design decisions
**API key resolution** (`config.resolve_api_key`): three-step chain —
explicit argument → environment variable → plaintext key file in project root.
Adapters raise `LLMConfigurationError` at construction time if no key is found
(except `ClaudeCodeAdapter` which needs no key).
**TOML config chain** (`toml_config.resolve_llm`): 7 priority levels allow
per-project and per-user LLM preferences. Currently defaults to `markitect`
app_name for backward compatibility — consumers pass their own `app_name`.
**Factory pattern** (`factory.create_adapter`): lazy imports prevent pulling
all provider SDKs at module load. Add a new provider by registering its FQN
in `_PROVIDERS`.
**ClaudeCodeAdapter subprocess model**: prompt is piped via stdin (not CLI
arg) to avoid shell argument length limits on large prompts (>30 KB).
**Retry logic**: `OpenAIAdapter` and `OpenRouterAdapter` retry on 429 and 5xx
with exponential backoff. `GeminiAdapter` does not (rate-limit handling deferred).

View File

@@ -0,0 +1,50 @@
# Credential and access routing
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
other credential need belongs to another subsystem. **Do not** message
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
### Lookup (do this first)
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=llm-connect` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
### Quick routing table
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes**`warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
### Other capabilities (reuse-surface)
Non-credential capabilities are usually discovered through **reuse-surface** federation
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
every repo's agent instructions because it is high-frequency, high-risk, and easy to
get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`

View File

@@ -1,11 +1,11 @@
## First Session Protocol ## First Session Protocol
Triggered when `get_domain_summary("custodian")` shows **no workstreams**. Triggered when `get_domain_summary("agents")` shows **no workstreams**.
The project is registered but work has not yet been structured. The project is registered but work has not yet been structured.
**Step 1 — Read, don't write** **Step 1 — Read, don't write**
- `~/the-custodian/canon/projects/custodian/project_charter_v0.1.md` — purpose, scope - `~/the-custodian/canon/projects/agents/project_charter_v0.1.md` — purpose, scope
- `~/the-custodian/canon/projects/custodian/roadmap_v0.1.md` — planned phases - `~/the-custodian/canon/projects/agents/roadmap_v0.1.md` — planned phases
- Scan repo root: README, directory structure, existing code or docs - Scan repo root: README, directory structure, existing code or docs
**Step 2 — Survey in-progress work** **Step 2 — Survey in-progress work**
@@ -17,20 +17,20 @@ roadmap phase. **Wait for approval before creating.**
**Step 4 — Create workplan file first, then DB record (ADR-001)** **Step 4 — Create workplan file first, then DB record (ADR-001)**
``` ```
workplans/llm-connect-WP-NNNN-<slug>.md ← write this first workplans/LLM-WP-NNNN-<slug>.md ← write this first
``` ```
Then register in the hub: Then register in the hub:
``` ```
create_workstream(topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", title="...", owner="...", description="...") create_workstream(topic_id="64418556-3206-457a-ba29-6884b5b12cf3", title="...", owner="...", description="...")
create_task(workstream_id="<id>", title="...", priority="high|medium|low") create_task(workstream_id="<id>", title="...", priority="high|medium|low")
``` ```
**Step 5 — Record the setup** **Step 5 — Record the setup**
``` ```
add_progress_event( add_progress_event(
summary="First session: structured custodian into N workstreams, M tasks", summary="First session: structured agents into N workstreams, M tasks",
event_type="milestone", event_type="milestone",
topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", topic_id="64418556-3206-457a-ba29-6884b5b12cf3",
detail={"workstreams": [...], "tasks_created": M} detail={"workstreams": [...], "tasks_created": M}
) )
``` ```

View File

@@ -1,17 +1,8 @@
## Repo boundary ## Repo boundary
This repo owns **llm-connect** — the multi-provider LLM client library — only. This repo owns **llm-connect** only. It does not own:
It does NOT own: <!-- TODO: List what belongs in adjacent repos, e.g.:
- SSH key management → railiance-infra/
- **API key storage / secret management** → caller's environment (env vars, - State hub code → state-hub/
key files, vault). llm-connect resolves keys but does not store them. -->
- **Consumer routing logic** → `inter-hub/AgentBridge.hs`, `markitect` etc.
`RoutingPolicy` (WP-0003) provides primitives; policy data belongs in the consumer.
- **The Claude Code CLI binary** → installed separately; `ClaudeCodeAdapter`
shells out to it.
- **markitect application code** → `markitect.llm` is a shim that re-exports
from here; all implementation lives in this repo.
- **State hub / custodian infrastructure** → `the-custodian/state-hub/`
- **IHF bridge scripts** → `inter-hub/scripts/llm_bridge.py` lives in inter-hub,
not here. llm-connect is a dependency of that script.

View File

@@ -1,5 +1,5 @@
**Purpose:** Multi-provider LLM client library — unified adapter interface for OpenAI, Claude, Gemini, OpenRouter with embedding support, token estimation, and TOML-based config. **Purpose:** Multi-provider LLM client library — unified adapter interface for OpenAI, Claude, Gemini, OpenRouter with embedding support, token estimation, and TOML-based config.
**Domain:** custodian **Domain:** agents
**Repo slug:** llm-connect **Repo slug:** llm-connect
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a **Topic ID:** 64418556-3206-457a-ba29-6884b5b12cf3

View File

@@ -1,6 +1,7 @@
## Session Protocol ## Session Protocol
State Hub: http://127.0.0.1:8000 (local) · http://127.0.0.1:18000 (CoulombCore via ops-bridge) Dev Hub (State Hub API): http://127.0.0.1:8000
MCP server name in `~/.claude.json`: `dev-hub`
**Step 1 — Orient** **Step 1 — Orient**
@@ -8,28 +9,42 @@ Read the offline-safe brief first — it works without a live hub connection:
```bash ```bash
cat .custodian-brief.md cat .custodian-brief.md
``` ```
Then call the MCP tool for richer cross-domain context (skip if unreachable): Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
``` ```
get_domain_summary("custodian") get_domain_summary("agents")
``` ```
If the hub is offline: `cd ~/the-custodian/state-hub && make api` If MCP tools are unavailable in the current agent session, use the REST API:
```bash
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
```
If the hub is offline: `cd ~/state-hub && make api`
**Step 2 — Check inbox** **Step 2 — Check inbox**
With MCP tools:
``` ```
get_messages(to_agent="llm-connect", unread_only=True) get_messages(to_agent="llm-connect", unread_only=True)
``` ```
Mark read with `mark_message_read(message_id)`. Reply or act on coordination Mark read with `mark_message_read(message_id)`. Reply or act on coordination
requests before proceeding. requests before proceeding.
Without MCP tools:
```bash
curl -s "http://127.0.0.1:8000/messages/?to_agent=llm-connect&unread_only=true" \
| python3 -m json.tool
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
**Step 3 — Scan workplans** **Step 3 — Scan workplans**
```bash ```bash
ls workplans/ ls workplans/
``` ```
For each file with `status: active`, note pending `todo`/`in_progress` tasks. For each file with `status: ready`, `active`, or `blocked`, note pending
`wait`/`todo`/`progress` tasks.
**Step 4 — Present brief** **Step 4 — Present brief**
1. **Active workstreams** for `custodian` — title, task counts, blocking decisions 1. **Active workstreams** for `agents` — title, task counts, blocking decisions
2. **Pending tasks** from `workplans/` + any `[repo:llm-connect]` hub tasks 2. **Pending tasks** from `workplans/` + any `[repo:llm-connect]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary: 3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"* - `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
@@ -45,18 +60,25 @@ If no workstreams: follow First Session Protocol (`first-session.md`).
> are First Session Protocol only. Work structure belongs in repo files (ADR-001). > are First Session Protocol only. Work structure belongs in repo files (ADR-001).
**Session close:** **Session close:**
With MCP tools:
``` ```
add_progress_event(summary="...", topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", workstream_id="<uuid>") add_progress_event(summary="...", topic_id="64418556-3206-457a-ba29-6884b5b12cf3", workstream_id="<uuid>")
```
Without MCP tools:
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{"topic_id":"64418556-3206-457a-ba29-6884b5b12cf3","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
``` ```
If workplan files were modified, ensure the local copy is up to date first: If workplan files were modified, ensure the local copy is up to date first:
```bash ```bash
git -C <repo_path> pull --ff-only git -C <repo_path> pull --ff-only
cd ~/the-custodian/state-hub && make fix-consistency REPO=llm-connect cd ~/state-hub && make fix-consistency REPO=llm-connect
``` ```
For repos where implementation runs on a remote machine (e.g. CoulombCore), For repos where implementation runs on a remote machine (e.g. CoulombCore),
use the combined target which pulls before fixing: use the combined target which pulls before fixing:
```bash ```bash
cd ~/the-custodian/state-hub && make fix-consistency-remote REPO=llm-connect cd ~/state-hub && make fix-consistency-remote REPO=llm-connect
``` ```
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback **C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes will sync the file to match DB. **C-16** (repo behind remote) blocks all writes

View File

@@ -1,59 +1,19 @@
## Stack ## Stack
- **Language:** Python 3.10+ <!-- TODO: Fill in language, frameworks, and key dependencies -->
- **Key deps (runtime):** `toml` (TOML config parsing) - **Language:**
- **Key deps (dev):** `pytest`, `ruff`, `mypy` - **Key deps:**
- **HTTP:** stdlib `urllib` via `_http.py` (no requests/httpx runtime dep)
- **Build:** setuptools / uv
## Dev Commands ## Dev Commands
```bash ```bash
# Install (editable, with dev extras) # TODO: Fill in the standard commands for this repo
uv pip install -e ".[dev]"
# or # Install dependencies
pip install -e ".[dev]"
# Run tests # Run tests
uv run pytest
# or
pytest
# Lint # Lint / type check
uv run ruff check .
# Type check # Build / package (if applicable)
uv run mypy llm_connect
# Run a single test file
uv run pytest tests/test_models.py -v
# Build package (dry run)
uv build --no-sources
```
## Project layout
```
llm_connect/ source package
adapter.py LLMAdapter ABC + Mock/ErrorLLMAdapter
models.py RunConfig, LLMResponse
exceptions.py LLMError hierarchy
factory.py create_adapter()
openai.py OpenAIAdapter
gemini.py GeminiAdapter
openrouter.py OpenRouterAdapter
claude_code.py ClaudeCodeAdapter
embedding_adapter.py EmbeddingAdapter ABC
embedding_openai.py OpenAICompatibleEmbeddingAdapter
embedding_cache.py EmbeddingCache
embedding_factory.py create_embedding_adapter()
toml_config.py 7-level TOML config resolution
config.py LLMConfig, resolve_api_key, find_project_root
_http.py shared HTTP POST utility
_token_estimator.py rough token count estimate
similarity.py cosine similarity utilities
tests/ pytest test suite
contracts/ GAAF-2026 contract docs
workplans/ workplan files (LLM-WP-NNNN)
``` ```

View File

@@ -1,12 +1,40 @@
## Workplan Convention (ADR-001) ## Workplan Convention (ADR-001)
File location: `workplans/llm-connect-WP-NNNN-<slug>.md` File location: `workplans/LLM-WP-NNNN-<slug>.md`
ID prefix: `LLM-WP` ID prefix: `LLM-WP-`
Work items originate as files in this repo **before** being registered in the hub. Work items originate as files in this repo **before** being registered in the hub.
Canonical workplan/workstream frontmatter statuses are:
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
Use `proposed` for a newly drafted plan, `ready` after review against current
repo state, and `finished` when implementation is complete. `stalled` and
`needs_review` are derived health labels, not stored statuses.
Closed workplans may be moved to `workplans/archived/` with a completion-date
prefix: `YYMMDD-LLM-WP-NNNN-<slug>.md`. The frontmatter id remains
unchanged; the prefix is only for quick visual reference.
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
directly. Promote anything requiring analysis, design, approval, dependencies, or
multiple planned phases into a normal workplan.
Ecosystem todos from other agents arrive as `[repo:llm-connect]` hub tasks — Ecosystem todos from other agents arrive as `[repo:llm-connect]` hub tasks —
visible at session start. Pick one up by creating the workplan file, then registering visible at session start. Pick one up by creating the workplan file, then registering
the workstream. the workstream.
Task blocks use this shape:
```task
id: LLM-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
```
Status progression is `todo``progress``done`; use `wait` for waiting or
blocked work and `cancel` for stopped work.
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here --> <!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->

18
.custodian-brief.md Normal file
View File

@@ -0,0 +1,18 @@
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
# Custodian Brief — llm-connect
**Domain:** infotech
**Last synced:** 2026-07-03 16:47 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams
*(none — repo may need first-session setup)*
---
## MCP Orientation (when available)
If the state-hub MCP server is reachable, call:
`get_domain_summary("infotech")`
This provides richer cross-domain context.
If the MCP call fails, use this file as your orientation source.

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
.git
.pytest_cache
.ruff_cache
.mypy_cache
__pycache__
*.pyc
.venv
venv
dist
build
*.egg-info
.env
.env.*
apikey-*.txt
apikey-*.json

25
.repo-classification.yaml Normal file
View File

@@ -0,0 +1,25 @@
# Repo classification (Repo Classification Standard v1.0).
repo_classification:
standard: Repo Classification Standard
version: '1.0'
classified_at: '2026-06-22'
classified_by: human
category: tooling
domain: agents
secondary_domains:
- infotech
capability_tags:
- orchestration
- model-routing
- configuration
- automation
business_stake:
- technology
- product
- automation
business_mechanics:
- operation
- adaptation
notes: Multi-provider LLM client library for Python (pluggable adapters / model routing).
Primary domain agents, infotech secondary.

219
AGENTS.md Normal file
View File

@@ -0,0 +1,219 @@
# llm-connect — Agent Instructions
## Repo Identity
**Purpose:** Multi-provider LLM client library — unified adapter interface for OpenAI, Claude, Gemini, OpenRouter with embedding support, token estimation, and TOML-based config.
**Domain:** agents
**Repo slug:** llm-connect
**Topic ID:** `64418556-3206-457a-ba29-6884b5b12cf3`
**Workplan prefix:** `LLM-WP-`
---
## State Hub Integration
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
there is no MCP server for Codex agents.
| Context | URL |
|---------|-----|
| Local workstation | `http://127.0.0.1:8000` |
| Remote via tunnel | `http://127.0.0.1:18000` |
### Orient at session start
```bash
# Offline brief — works without hub connection
cat .custodian-brief.md
# Active workstreams for this domain
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=64418556-3206-457a-ba29-6884b5b12cf3&status=active" \
| python3 -m json.tool
# Check inbox
curl -s "http://127.0.0.1:8000/messages/?to_agent=llm-connect&unread_only=true" \
| python3 -m json.tool
```
Mark a message read:
```bash
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
### Log progress (required at session close)
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{
"summary": "what was done",
"event_type": "note",
"author": "codex",
"workstream_id": "<uuid>",
"task_id": "<uuid>"
}'
```
Omit `workstream_id` / `task_id` when not applicable.
### Update task status
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"status": "progress"}'
# values: wait | todo | progress | done | cancel
```
### Flag a task for human review
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"needs_human": true, "intervention_note": "reason"}'
```
---
## Session Protocol
**Start:**
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
2. Check inbox: `GET /messages/?to_agent=llm-connect&unread_only=true`; mark read
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
**During work:**
- Update task statuses in workplan files as tasks progress
- Record significant decisions via `POST /decisions/`
**Close:**
1. Update workplan file task statuses to reflect progress
2. Log: `POST /progress/` with a summary of what changed
3. Note for the custodian operator: after workplan file changes, run from
`~/state-hub`:
```bash
make fix-consistency REPO=llm-connect
```
This syncs task status from files into the hub DB.
---
## Credential and access routing
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
other credential need belongs to another subsystem. **Do not** message
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
### Lookup (do this first)
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=llm-connect` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
### Quick routing table
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
### Other capabilities (reuse-surface)
Non-credential capabilities are usually discovered through **reuse-surface** federation
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
every repo's agent instructions because it is high-frequency, high-risk, and easy to
get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
<!-- REPO-AGENTS-EXTENSIONS -->
<!-- Append repo-specific agent instructions below this marker.
The state-hub template sync preserves content after this line. -->
---
## Workplan Convention (ADR-001)
Work items originate as files in this repo — not in the hub. The hub is a
read/cache/index layer that rebuilds from files.
**File location:** `workplans/LLM-WP-NNNN-<slug>.md`
**Archived location:** finished workplans may move to
`workplans/archived/YYMMDD-LLM-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
the completion/archive date; the frontmatter `id` does not change.
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
this only for low-risk work completed directly; create a normal workplan for
anything needing analysis, design, approval, dependencies, or multiple phases.
**Frontmatter:**
```yaml
---
id: LLM-WP-NNNN
type: workplan
title: "..."
domain: agents
repo: llm-connect
status: proposed | ready | active | blocked | backlog | finished | archived
owner: codex
topic_slug: ...
created: "YYYY-MM-DD"
updated: "YYYY-MM-DD"
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
---
```
Use `proposed` for a new draft, `ready` after review against current repo
state, and `finished` after implementation. `stalled` and `needs_review` are
derived health labels, not frontmatter statuses.
**Task block format** (one per `##` section):
```
## Task Title
` ` `task
id: LLM-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
` ` `
Task description text.
```
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
To create a new workplan:
1. Write the file following the format above
2. Notify the custodian operator to run `make fix-consistency REPO=llm-connect`
(or send a message to the hub agent via `POST /messages/`)

View File

@@ -32,6 +32,9 @@ Maturity states: **Experimental → Beta → Stable → Deprecated**
| `gemini.py` | `GeminiAdapter` — Google Generative Language API | Beta | | `gemini.py` | `GeminiAdapter` — Google Generative Language API | Beta |
| `openrouter.py` | `OpenRouterAdapter` — OpenAI-compatible multi-model routing | Beta | | `openrouter.py` | `OpenRouterAdapter` — OpenAI-compatible multi-model routing | Beta |
| `claude_code.py` | `ClaudeCodeAdapter``claude --print` subprocess | Beta | | `claude_code.py` | `ClaudeCodeAdapter``claude --print` subprocess | Beta |
| `_payload.py` | Shared adapter payload translation for `RunConfig.model_params` | Beta |
| `_diagnostics.py` | Opt-in per-call diagnostics capture for server debug and audit modes | Beta |
| `replay.py` | Audit replay parser CLI (`python -m llm_connect.replay`) | Beta |
| `embedding_adapter.py` | `EmbeddingAdapter` ABC | Beta | | `embedding_adapter.py` | `EmbeddingAdapter` ABC | Beta |
| `embedding_openai.py` | `OpenAICompatibleEmbeddingAdapter` | Beta | | `embedding_openai.py` | `OpenAICompatibleEmbeddingAdapter` | Beta |
| `embedding_cache.py` | `EmbeddingCache` — disk-backed embedding cache | Beta | | `embedding_cache.py` | `EmbeddingCache` — disk-backed embedding cache | Beta |

View File

@@ -8,4 +8,5 @@
@.claude/rules/stack-and-commands.md @.claude/rules/stack-and-commands.md
@.claude/rules/architecture.md @.claude/rules/architecture.md
@.claude/rules/repo-boundary.md @.claude/rules/repo-boundary.md
@.claude/rules/credential-routing.md
@.claude/rules/agents.md @.claude/rules/agents.md

27
Containerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
LLM_CONNECT_HOST=0.0.0.0 \
LLM_CONNECT_PORT=8080 \
LLM_CONNECT_PROVIDER=mock
WORKDIR /app
RUN groupadd -g 10001 llmconnect \
&& useradd -u 10001 -g 10001 -m -s /usr/sbin/nologin llmconnect
COPY pyproject.toml README.md ./
COPY llm_connect ./llm_connect
COPY fixtures ./fixtures
COPY scripts ./scripts
RUN pip install --no-cache-dir .
USER 10001:10001
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import json, urllib.request; r=urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3); raise SystemExit(0 if json.load(r).get('status') == 'ok' else 1)"
CMD ["python", "-m", "llm_connect.server"]

95
INTENT.md Normal file
View File

@@ -0,0 +1,95 @@
# INTENT
## Purpose
This repository exists to provide a **provider-neutral interface for interacting with large language models (LLMs)** in Python.
It ensures that applications can use LLM capabilities without being tightly coupled to any specific provider, API, or execution environment.
---
## Primary Utility
The repository provides a **unified adapter layer** that:
* Abstracts over multiple LLM providers and execution modes
* Standardizes request, response, and configuration handling
* Enables interchangeable use of hosted APIs and local tooling (e.g. CLI-based models)
* Supports embeddings, token estimation, and related primitives
* Enables dynamic utility by cost optimizations
It transforms heterogeneous LLM ecosystems into a **consistent, composable programming interface**.
---
## Intended Users
* Application developers integrating LLM capabilities into their systems
* Library and framework authors requiring provider-agnostic LLM primitives
* Automation systems (`atm`) orchestrating LLM-assisted workflows
* LLM agents (`agt`) operating across different model providers
---
## Strategic Role in the System
This repository acts as the **LLM abstraction layer** within the broader system:
* It decouples **application logic from provider-specific implementations**
* It enables **runtime flexibility and provider switching without code changes**
* It supports architectures where LLM usage is **optional, replaceable, and testable**
It allows higher-level systems to treat LLMs as **pluggable capabilities rather than fixed dependencies**.
---
## Strategic Boundaries
This repository is **not** intended to:
* Provide application-level agent frameworks or workflows
* Define prompting strategies, routing policies, or domain-specific logic
* Manage secrets, credentials, or organizational access policies
* Own or implement LLM providers themselves
Its responsibility is limited to **clean abstraction and integration of LLM capabilities**.
---
## Design Principles
* **Abstraction over providers**
Consumers depend on a stable adapter interface, not on vendor APIs
* **Composability**
LLM functionality should be usable as a building block in larger systems
* **Replaceability**
Providers and execution modes must be interchangeable without affecting consumers
* **Deterministic integration boundaries**
Non-LLM logic must remain testable and independent of LLM variability
* **Minimal opinionation**
The library provides primitives, not policies
---
## Maturity Target
A mature version of this repository should:
* Provide a **stable, versioned core adapter contract** for LLM interaction
* Support a broad range of providers and execution environments
* Enable **seamless switching and fallback between providers**
* Offer consistent handling of **responses, errors, and usage metrics**
* Serve as the **default integration layer for LLM capabilities** across dependent systems
---
## Stability Note
Changes to this file represent a **deliberate shift in the abstraction boundaries or role** of this repository.
Such changes should be rare, as they affect all downstream systems relying on provider-neutral LLM integration.

37
Makefile Normal file
View File

@@ -0,0 +1,37 @@
-include .env
export
.PHONY: install test lint typecheck start help
UV ?= $(shell command -v uv 2>/dev/null || if [ -x "$$HOME/.local/bin/uv" ]; then printf "%s" "$$HOME/.local/bin/uv"; else printf "%s" "uv"; fi)
LLM_CONNECT_HOST ?= 127.0.0.1
LLM_CONNECT_PORT ?= 8080
LLM_CONNECT_PROVIDER ?= mock
LLM_CONNECT_MODEL ?=
SERVER_ARGS ?=
install: ## Install dependencies (dev group)
$(UV) sync --group dev
test: ## Run test suite
$(UV) run pytest
lint: ## Run ruff linter
$(UV) run ruff check .
typecheck: ## Run mypy type checker
$(UV) run mypy llm_connect
start: ## Start llm-connect HTTP server (reads .env and LLM_CONNECT_* overrides)
$(UV) run python -m llm_connect.server \
--host $(LLM_CONNECT_HOST) \
--port $(LLM_CONNECT_PORT) \
--provider $(LLM_CONNECT_PROVIDER) \
$(if $(LLM_CONNECT_MODEL),--model $(LLM_CONNECT_MODEL),) \
$(SERVER_ARGS)
help: ## Show this help message
@grep -Eh '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' | \
sort

View File

@@ -1,7 +1,7 @@
# llm-connect # llm-connect
Pluggable LLM adapters for Python. Supports OpenRouter, Gemini, OpenAI, and Pluggable LLM adapters for Python and the commandline. Supports OpenRouter, Gemini,
the Claude Code CLI out of the box, with a clean abstract interface for adding OpenAI, and the Claude Code CLI out of the box, with a clean abstract interface for adding
your own. your own.
## Quick start ## Quick start
@@ -31,8 +31,6 @@ pip install llm-connect
|---|---|---| |---|---|---|
| `"openrouter"` | `OpenRouterAdapter` | OpenAI-compatible endpoint; supports all OpenRouter models | | `"openrouter"` | `OpenRouterAdapter` | OpenAI-compatible endpoint; supports all OpenRouter models |
| `"gemini"` | `GeminiAdapter` | Google Generative Language REST API; supports free tier | | `"gemini"` | `GeminiAdapter` | Google Generative Language REST API; supports free tier |
| `"openai"` | `OpenAIAdapter` | OpenAI chat completions endpoint |
| `"claude-code"` | `ClaudeCodeAdapter` | Shells out to the `claude --print` CLI; no API key needed |
```python ```python
from llm_connect import create_adapter from llm_connect import create_adapter
@@ -75,15 +73,15 @@ config = RunConfig(
) )
``` ```
| Field | Default | Description | | Field | Default | Description |
|---|---|---| |---|---|---|
| `model_name` | `"gpt-4"` | Model identifier (adapter may override) | | `model_name` | `"gpt-4"` | Model identifier (adapter may override) |
| `temperature` | `0.7` | Sampling temperature | | `temperature` | `0.7` | Sampling temperature |
| `max_tokens` | `2000` | Maximum output tokens | | `max_tokens` | `2000` | Maximum output tokens |
| `model_params` | `{}` | Extra provider-specific parameters | | `model_params` | `{}` | Portable extras translated by each adapter; see `docs/adapter-model-params.md` |
| `max_depth` | `3` | Max nesting depth for recursive calls | | `max_depth` | `3` | Max nesting depth for recursive calls |
| `skip_if_exists` | `True` | Skip if identical input hash already processed | | `skip_if_exists` | `True` | Skip if identical input hash already processed |
| `timeout_seconds` | `300` | Request timeout | | `timeout_seconds` | `300` | Request timeout |
### `LLMResponse` ### `LLMResponse`
@@ -94,10 +92,55 @@ response = adapter.execute_prompt(prompt, config)
print(response.content) # generated text print(response.content) # generated text
print(response.model) # model actually used print(response.model) # model actually used
print(response.usage) # {"prompt_tokens": …, "completion_tokens": …, "total_tokens": …} print(response.usage) # {"prompt_tokens": …, "completion_tokens": …, "total_tokens": …}
print(response.finish_reason) # "stop", "length", etc. print(response.finish_reason) # "stop", "length", etc.
``` ```
## Writing your own adapter ## Server diagnostics
Serve mode can include a debug envelope without changing normal responses:
```bash
LLM_CONNECT_DEBUG=1 python -m llm_connect.server --provider openrouter
curl 'http://127.0.0.1:8080/execute?debug=1' -d '{"prompt":"hi"}'
```
Set `LLM_CONNECT_AUDIT_DIR=/path/to/audit` to write per-call replay records,
then parse one without another provider call:
```bash
python -m llm_connect.replay /path/to/audit/record.json --json
```
## Server runtime profiles
Serve mode enables named runtime profiles by default. A client can send
`config.model_name="custodian-triage-balanced"` and the server resolves it to
the configured provider/model before calling the adapter.
Useful runtime environment variables:
```bash
LLM_CONNECT_HOST=0.0.0.0
LLM_CONNECT_PORT=8080
LLM_CONNECT_PROVIDER=openrouter
LLM_CONNECT_MODEL=google/gemini-2.5-flash
LLM_CONNECT_CUSTODIAN_TRIAGE_PROVIDER=openrouter
LLM_CONNECT_CUSTODIAN_TRIAGE_MODEL=google/gemini-2.5-flash
```
For local smoke tests without provider credentials:
```bash
export LLM_CONNECT_MOCK_RESPONSE="$(python -c 'import json; print(json.dumps(json.load(open("fixtures/activity_core/daily-triage-valid-content.json"))))')"
python -m llm_connect.server --provider mock
python scripts/smoke_activity_core_endpoint.py --url http://127.0.0.1:8080
```
Disable profile dispatch with `--disable-profiles`. Set
`LLM_CONNECT_STRICT_PROFILES=1` or pass `--strict-profiles` to reject direct
model names that are not configured profiles.
## Writing your own adapter
```python ```python
from llm_connect import LLMAdapter, RunConfig, LLMResponse from llm_connect import LLMAdapter, RunConfig, LLMResponse

185
SCOPE.md
View File

@@ -1,45 +1,162 @@
# SCOPE.md — llm-connect # SCOPE
## Purpose > This file helps you quickly understand what this repository is about,
> when it is relevant, and when it is not.
`llm-connect` is a **multi-provider LLM client library for Python**. ---
It provides a unified adapter interface over OpenAI, Gemini, OpenRouter,
and the Claude Code CLI, with embedding support, token estimation, and a
TOML-based configuration chain.
Extracted from [markitect](https://github.com/worsch/markitect). ## One-liner
The `markitect.llm` module remains a re-export shim pointing here.
## This repo owns `llm-connect` is a multi-provider LLM client library for Python.
- `LLMAdapter` ABC and `RunConfig` / `LLMResponse` data models (Core) ---
- All concrete provider adapters: `OpenAIAdapter`, `GeminiAdapter`,
`OpenRouterAdapter`, `ClaudeCodeAdapter` (Functional)
- Embedding adapters: `EmbeddingAdapter` ABC, `OpenAICompatibleEmbeddingAdapter`,
`EmbeddingCache`, `create_embedding_adapter` factory (Functional)
- TOML-based config resolution (`toml_config.py`, `config.py`) (Configuration)
- Shared HTTP utility (`_http.py`), token estimator (`_token_estimator.py`),
cosine similarity utilities (`similarity.py`)
- The full `LLMError` exception hierarchy
## This repo does NOT own ## Core Idea
- Consumer application logic — that lives in `markitect`, `inter-hub`, etc. `llm-connect` provides a unified adapter interface over OpenAI, Gemini,
- API key management infrastructure — keys are resolved from env vars or OpenRouter, Anthropic-compatible APIs, and the Claude Code CLI. It keeps
plaintext key files; secret storage belongs in the calling environment consumer applications from binding directly to provider-specific request,
- Model routing decisions specific to a consumer — `RoutingPolicy` (WP-0003) response, embedding, token-estimation, and configuration details.
provides primitives; policy configuration belongs in the consumer
- The Claude Code CLI binary itself — `ClaudeCodeAdapter` shells out to `claude`
## Consumers (as of 2026-04-01) The library was extracted from `markitect`; the `markitect.llm` module remains a
re-export shim pointing here.
| Consumer | How it uses llm-connect | ---
|----------|------------------------|
| `markitect` | Re-exports via `markitect.llm` shim; drives document generation |
| `inter-hub` (IHF) | Subprocess bridge (`scripts/llm_bridge.py` + `AgentBridge.hs`) for multi-agent federation |
## Versioning ## In Scope
- Current version: **0.1.0** (pre-release; API not yet stable) - `LLMAdapter` ABC and `RunConfig` / `LLMResponse` data models.
- Core layer (`LLMAdapter`, `RunConfig`, `LLMResponse`) will be stabilised at **v1.0.0** - Concrete provider adapters such as `OpenAIAdapter`, `GeminiAdapter`,
- Breaking Core changes require a major version bump `OpenRouterAdapter`, and `ClaudeCodeAdapter`.
- Embedding adapters including `EmbeddingAdapter`,
`OpenAICompatibleEmbeddingAdapter`, `EmbeddingCache`, and
`create_embedding_adapter`.
- TOML-based configuration resolution via `toml_config.py` and `config.py`.
- Shared HTTP utilities, token estimation, similarity helpers, and the
`LLMError` exception hierarchy.
---
## Out of Scope
- Consumer application logic; that belongs in `markitect`, `inter-hub`, and
other callers.
- Secret-management infrastructure; keys are resolved from environment variables
or configured key files, while secure storage belongs to the calling
environment.
- Consumer-specific model routing policy, beyond reusable primitives.
- Owning the Claude Code CLI binary itself; `ClaudeCodeAdapter` shells out to the
installed `claude` command.
---
## Relevant When
- You need one Python interface for multiple LLM providers.
- You want to switch between OpenAI, Gemini, OpenRouter, Anthropic-compatible
APIs, or Claude Code CLI without changing consumer code.
- You need embeddings, token estimation, provider configuration, or consistent
error handling around LLM calls.
- You are building a repository that should depend on provider-neutral LLM
primitives instead of vendor-specific client code.
---
## Not Relevant When
- You need a complete application-level agent framework.
- You need hosted secret storage, key rotation, or organization-wide credential
governance.
- You only call one provider directly and do not need adapter portability.
- You need UI, persistence, workflow orchestration, or domain-specific prompting.
---
## Current State
- Status: pre-release, version `0.1.0`.
- Core layer (`LLMAdapter`, `RunConfig`, `LLMResponse`) is intended to stabilize
by `v1.0.0`.
- Provider adapters, embedding helpers, and TOML configuration are implemented.
- Breaking core changes should require a major version bump once the core layer
is declared stable.
---
## How It Fits
- Upstream dependencies: provider SDKs or HTTP APIs for supported LLM services.
- Downstream consumers: `markitect` re-exports the library and uses it for
document generation; `inter-hub` uses it through its LLM bridge.
- Often used with: repositories that need optional LLM assistance while keeping
deterministic non-LLM behavior independently testable.
---
## Terminology
- Preferred terms: adapter, provider, run config, response, embedding adapter,
token estimator, provider-neutral LLM interface.
- Also known as: LLM adapter library, provider abstraction.
- Potentially confusing terms: `ClaudeCodeAdapter` integrates the Claude Code CLI,
not Anthropic's hosted Messages API directly.
---
## Related / Overlapping
- `markitect` - original source of the extracted adapter layer and current
downstream consumer.
- `inter-hub` - uses LLM calls through a bridge for interaction federation.
- `repo-scoping` - can use `llm-connect` as optional LLM assistance for
repository characteristic extraction.
---
## Getting Oriented
- Start with: `README.md`, `pyproject.toml`, and `contracts/functional/adapters.md`.
- Key files / directories: `llm_connect/`, `tests/`, `contracts/`, and
`.github/workflows/`.
- Entry points: adapter factory/configuration helpers and the provider adapter
classes under `llm_connect/`.
---
## Provided Capabilities
```capability
type: api
title: Multi-provider LLM adapter interface
description: >
Provides one Python adapter contract for OpenAI, Gemini, OpenRouter,
Anthropic-compatible APIs, and Claude Code CLI calls.
keywords: [llm, adapter, openai, gemini, openrouter, anthropic, claude]
```
```capability
type: api
title: Embedding adapter and cache support
description: >
Provides embedding adapter abstractions, OpenAI-compatible embedding support,
and embedding cache helpers for downstream retrieval workflows.
keywords: [embedding, vector, cache, retrieval, openai-compatible]
```
```capability
type: configuration
title: TOML-based LLM provider configuration
description: >
Resolves provider settings and model configuration from TOML and environment
sources so callers can configure LLM usage without hard-coding provider
details.
keywords: [toml, configuration, provider, model, credentials]
```
---
## Notes
- Current known consumers are `markitect` and `inter-hub`.
- The library is intentionally provider-neutral; product-specific prompting and
routing decisions belong in the caller.

View File

@@ -0,0 +1,87 @@
# Contract: AdaptiveRoutingPolicy
**layer:** Functional
**maturity:** Beta
**module:** `llm_connect.routing`
**since:** WP-0004
## Purpose
Select the cheapest adapter whose observed mean quality for a task type clears
a caller-supplied quality floor. The policy builds on `RoutingPolicy`: static
rules remain the cold-start and failure fallback, while adaptive selection is
used only when the ledger has enough qualifying observations.
## Public surface
```python
@dataclass
class AdaptiveRoutingPolicy(RoutingPolicy):
ledger: Optional[QualityLedger] = None
adapters_by_id: Mapping[str, LLMAdapter] = field(default_factory=dict)
window_size: int = 20
min_observations: int = 1
max_age: Optional[timedelta] = None
def resolve(
self,
task_type: str,
estimated_cost_per_1k: Optional[float] = None,
*,
quality_floor: Optional[float] = None,
) -> LLMAdapter: ...
```
## Candidate identity
Observations are keyed by `(task_type, adapter_id)`. Callers should pass
`adapters_by_id` so the policy can map ledger observations back to concrete
`LLMAdapter` instances. If a static rule adapter is not present in
`adapters_by_id`, the policy also checks common string attributes
`adapter_id`, `id`, and `name`.
## Invariants
1. If `quality_floor is None` or `ledger is None`, resolution is exactly the
same as `RoutingPolicy.resolve()`.
2. `quality_floor` must be between `0` and `1`, inclusive.
3. Each candidate is evaluated over the newest `window_size` observations for
the requested `task_type` and adapter id.
4. `max_age`, when provided, filters out observations older than that age.
5. A candidate is considered only when it has at least `min_observations` after
filtering.
6. A candidate qualifies when its mean `quality_score` is greater than or equal
to `quality_floor`.
7. Among qualifying candidates, the policy chooses the lowest mean observed
`cost_usd`.
8. If mean observed cost ties exactly, the policy prefers the matching static
rule's explicit `prefer` adapter.
9. If there are still ties, stable candidate order is used.
10. If no candidate qualifies, resolution falls through to
`RoutingPolicy.resolve(task_type, estimated_cost_per_1k)`.
## Sample-size and freshness trade-off
Small `window_size` values react quickly to model or prompt changes but can be
noisy. Larger windows are more stable but may preserve stale behavior after a
provider update or prompt template change. `min_observations` lets callers avoid
acting on a single lucky sample, while `max_age` bounds how long old observations
can influence routing. Callers that change prompts materially should also filter
by a prompt fingerprint in observation tags before writing comparable samples to
the same ledger regime.
## Error contract
| Condition | Exception |
|-----------|-----------|
| `quality_floor` outside `0..1` | `ValueError` |
| `window_size <= 0` | `ValueError` |
| `min_observations <= 0` | `ValueError` |
| `max_age < 0` | `ValueError` |
| No qualifying adaptive candidate and no static fallback | `LookupError` |
## Non-goals
The policy does not define a task taxonomy, set task quality floors, decide
which baseline is authoritative, or perform billing-grade accounting. Those are
consumer policy choices.

View File

@@ -0,0 +1,85 @@
# Contract: Baseline Grading
**layer:** Functional
**maturity:** Beta
**module:** `llm_connect.grading`
**since:** WP-0004
## Purpose
Compare a candidate adapter response against a caller-chosen baseline response
and return a normalised quality score suitable for storage in
`QualityLedger`.
## Public surface
```python
@dataclass(frozen=True)
class GradingResult:
quality_score: float
notes: str
grader_id: str
baseline_response: LLMResponse
candidate_response: LLMResponse
class Judge(Protocol):
grader_id: str
def judge(..., *, prompt: str, run_config: RunConfig) -> GradingResult: ...
class BaselineGrader(Protocol):
def grade(
self,
baseline_adapter: LLMAdapter,
candidate_adapter: LLMAdapter,
prompt: str,
run_config: RunConfig,
) -> GradingResult: ...
@dataclass
class ExactMatchJudge: ...
@dataclass
class EmbeddingSimilarityJudge: ...
@dataclass
class LLMJudge: ...
@dataclass
class PairedGrader: ...
```
## Invariants
1. `quality_score` is always validated as `0.0..1.0`.
2. `GradingResult` always preserves both baseline and candidate responses.
3. `PairedGrader` runs the baseline adapter and the candidate adapter with the
same prompt and run config, then delegates comparison to its `Judge`.
4. `ExactMatchJudge` returns `1.0` for matched content and `0.0` otherwise.
5. `EmbeddingSimilarityJudge` embeds baseline and candidate response text in a
single batch and clamps cosine similarity into `0.0..1.0`.
6. `LLMJudge` uses a fixed rubric prompt and expects JSON with
`quality_score` and optional `notes`.
7. `LLMJudge` runs with `temperature=0.0`, drops the caller's budget tracker,
and adds a deterministic `seed` model parameter when configured.
## Error contract
| Condition | Exception |
|-----------|-----------|
| Invalid `quality_score` | `ValueError` |
| Empty `grader_id` | `ValueError` |
| Embedding adapter returns other than two vectors | `ValueError` |
| LLM judge response is missing parseable JSON | `ValueError` |
## Bias caveats
LLM-as-judge scoring is heuristic and may exhibit:
- Length bias: longer answers can be preferred even when not better.
- Format bias: familiar formatting can be rewarded independent of correctness.
- Position bias: prompt order can affect judgement.
- Self-preference: a judge may favour outputs from its own model family.
Consumers should calibrate `LLMJudge` against at least one non-LLM judge such
as exact match or embedding similarity before using its observations to drive
adaptive routing.

View File

@@ -0,0 +1,25 @@
# Cost Estimates
`llm_connect.costs` converts token estimates or observed token counts into
USD estimates using `ModelRateRegistry`.
## Contract
```python
from llm_connect import estimate_cost
estimate = estimate_cost("openai/gpt-4o-mini", 28_000, 7_500)
```
For known models the result is:
- `cost_usd`: prompt plus completion estimate.
- `prompt_cost_usd`: prompt-token component.
- `completion_cost_usd`: completion-token component.
- `cost_source`: `rate_table:<model_id>`.
Unknown models return `CostEstimate(cost_usd=None, cost_source="unknown")`.
Missing rates are never silently treated as zero cost.
The module also exposes `CostModel(registry=...)` for callers that prefer to
carry a registry object and call `model.estimate_cost(...)`.

View File

@@ -0,0 +1,46 @@
# Problem Classes
`llm_connect.problem_classes` provides generic token estimators for recurring
LLM workflow shapes.
## Contract
Every problem class exposes:
- `name`: stable registry key.
- `base_dimensions`: required dimension names supplied by consumers.
- `tunable_params`: parameters that can be overridden or fitted.
- `estimate(dimensions, params=None) -> TokenEstimate`.
- `fit(observations, min_observations=3) -> ProblemClass`.
`TokenEstimate` contains `prompt_tokens`, `completion_tokens`, and a
`confidence` score from `0` to `1`.
## Built-Ins
| Name | Dimensions | Tunable params |
|---|---|---|
| `chunk-summarization` | `chunk_words`, `template_words` | `completion_ratio` |
| `entity-extraction` | `chunk_words`, `template_words`, `expected_entities` | `tokens_per_entity` |
| `relation-extraction` | `chunk_words`, `template_words`, `expected_relations` | `tokens_per_relation` |
| `judge-eval` | `artifact_words`, `template_words`, `n_criteria` | `tokens_per_criterion` |
| `report-synthesis` | `n_chunks`, `n_entities`, `n_relations`, `template_words` | `base_completion_tokens` |
## Observations
`fit()` accepts either `Observation` objects or `QualityObservation` rows whose
`tags` include:
```python
{
"problem_class": "entity-extraction",
"dimensions": {
"chunk_words": 900,
"template_words": 200,
"expected_entities": 4,
},
}
```
When fewer than `min_observations` usable rows are present, fitting falls back
to the current parameters.

View File

@@ -0,0 +1,87 @@
# Contract: QualityObservation and QualityLedger
**layer:** Functional
**maturity:** Beta
**module:** `llm_connect.quality`
**since:** WP-0004
## Purpose
Record observed quality, cost, latency, and token outcomes for a logical task
type so consumers can build adaptive routing policy without putting
consumer-specific thresholds into llm-connect.
## Public surface
```python
@dataclass(frozen=True)
class QualityObservation:
task_type: str
adapter_id: str
model_id: str
cost_usd: float
quality_score: float
latency_ms: float
tokens_in: int
tokens_out: int
baseline_adapter_id: str | None = None
recorded_at: datetime = field(default_factory=...)
tags: dict[str, Any] = field(default_factory=dict)
@property
def total_tokens(self) -> int: ...
def to_dict(self) -> dict[str, Any]: ...
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "QualityObservation": ...
class QualityLedger:
def __init__(self, path: str | Path): ...
@property
def path(self) -> Path: ...
def append(self, observation: QualityObservation) -> None: ...
def read_all(self) -> list[QualityObservation]: ...
def malformed_count(self) -> int: ...
def by_task_type(self, task_type: str) -> list[QualityObservation]: ...
def recent(...) -> list[QualityObservation]: ...
def mean_quality(...) -> float | None: ...
def prune_before(self, timestamp: datetime) -> int: ...
def is_stale(observation: QualityObservation, max_age: timedelta, *, now: datetime | None = None) -> bool: ...
```
## Invariants
1. `quality_score` is a normalised `0.0..1.0` score where `1.0` means the
candidate fully meets the grader's quality bar and `0.0` means complete
failure for that grader.
2. `task_type`, `adapter_id`, and `model_id` must be non-empty strings.
3. `cost_usd`, `latency_ms`, `tokens_in`, and `tokens_out` are non-negative.
4. `recorded_at` is normalised to UTC. Naive datetimes are interpreted as UTC.
5. Ledger records are JSON Lines. Each line is one `QualityObservation.to_dict()`.
6. `QualityLedger.append()` performs a process-local lock plus an advisory file
lock around each write.
7. Read/query helpers skip malformed lines instead of failing the whole ledger.
`malformed_count()` exposes how many lines were skipped.
8. `prune_before()` removes only valid observations older than the cutoff.
Malformed lines are preserved.
## Error contract
| Condition | Exception |
|-----------|-----------|
| Invalid observation field | `ValueError` |
| Invalid datetime field | `TypeError` or `ValueError` |
| Negative recent limit | `ValueError` |
| `mean_quality(min_observations <= 0)` | `ValueError` |
| `is_stale(max_age < 0)` | `ValueError` |
## Known consumers
- `infospace-bench` is the first intended consumer. It is expected to provide
task taxonomy, thresholds, and baseline choice.
## Notes
The ledger intentionally stores only observation metadata in this slice. Callers
that need prompt or response digests can place those in `tags`, for example
`prompt_fingerprint`.

View File

@@ -0,0 +1,30 @@
# Model Rate Registry
`llm_connect.rates` owns static model list prices used for planning and
post-hoc estimates.
## Contract
- `ModelRate` records `model_id`, prompt and completion rates in USD per
1,000 tokens, `currency`, `source_url`, and `captured_at`.
- `ModelRateRegistry.default()` returns the bundled OpenRouter snapshot
captured on `2026-05-17`.
- `ModelRateRegistry.from_yaml(path)` accepts the package/consumer override
shape:
```yaml
schema_version: 1
currency: USD
source_url: https://openrouter.ai/models
captured_at: "2026-05-17"
rates:
openai/gpt-4o-mini:
prompt_per_1k: 0.00015
completion_per_1k: 0.00060
```
- `merged_with(override)` returns a new registry where matching override
entries replace default entries by `model_id`.
Rates are a static snapshot. Consumers decide whether `captured_at` is fresh
enough for their workflow.

View File

@@ -0,0 +1,53 @@
# Contract: RoutingPolicy
**layer:** Functional
**maturity:** Beta
**module:** `llm_connect.routing`
**since:** WP-0003
## Purpose
Route logical task types to concrete `LLMAdapter` instances based on a
prioritised rule list, with optional per-rule cost-cap fallback.
## Public surface
```python
@dataclass
class RoutingRule:
task_type: str
prefer: LLMAdapter
max_cost_per_1k: Optional[float] = None # USD per 1 000 tokens
fallback: Optional[LLMAdapter] = None
@dataclass
class RoutingPolicy:
rules: List[RoutingRule] = field(default_factory=list)
default: Optional[LLMAdapter] = None
def resolve(
self,
task_type: str,
estimated_cost_per_1k: Optional[float] = None,
) -> LLMAdapter: ...
```
## Invariants
1. Rules are evaluated in list order; the first rule whose `task_type` matches wins.
2. When `estimated_cost_per_1k` is supplied and a matching rule has `max_cost_per_1k` set:
- If `estimated_cost_per_1k > max_cost_per_1k` **and** `fallback is not None` → return `fallback`.
- Otherwise → return `prefer` (no fallback configured or cost within cap).
3. When no rule matches and `default is not None` → return `default`.
4. When no rule matches and `default is None` → raise `LookupError`.
5. `resolve()` never mutates policy state.
## Error contract
| Condition | Exception |
|-----------|-----------|
| No matching rule, no default | `LookupError` |
## Known consumers
- `inter-hub` (IHUB-WP-0012 Phase 11): uses `RoutingPolicy` to select federation adapters per task class.

View File

@@ -0,0 +1,131 @@
# Contract: HTTP Serve Mode
**layer:** Functional
**maturity:** Beta
**module:** `llm_connect.server`
**since:** WP-0003
## Purpose
Expose any `LLMAdapter` as a lightweight HTTP service. Intended for
local/inter-process use; not hardened for public internet exposure.
## API endpoints
### `GET /health`
Liveness probe.
**Response 200**
```json
{"status": "ok"}
```
---
### `POST /execute`
Execute a prompt through the configured adapter.
**Request body** (JSON)
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `prompt` | string | yes | Prompt text |
| `config` | object | no | `RunConfig` overrides (see below) |
`config` sub-fields (all optional, defaults match `RunConfig` defaults):
| Field | Type | Default |
|-------|------|---------|
| `model_name` | string | `"gpt-4"` |
| `temperature` | float | `0.7` |
| `max_tokens` | int | `2000` |
| `timeout_seconds` | int | `300` |
**Response 200**`LLMResponse.to_dict()` shape
```json
{
"content": "...",
"model": "...",
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
"finish_reason": "stop",
"metadata": {}
}
```
**Error responses**
| HTTP | Condition |
|------|-----------|
| 400 | Missing `prompt` field or invalid JSON body |
| 404 | Unknown path |
| 429 | Provider rate limit |
| 500 | Configuration or adapter failure |
| 502 | Provider API / transport failure |
| 504 | Provider timeout |
Server error bodies are structured and must not expose provider credentials:
```json
{
"error": "provider_api_error",
"message": "HTTP 500 from https://provider.example/v1?key=<redacted>",
"type": "LLMAPIError",
"provider_status": 500
}
```
Known error codes include `unknown_profile`, `configuration_error`,
`provider_api_error`, `provider_rate_limited`, `provider_timeout`,
`budget_exceeded`, `llm_error`, and `internal_error`.
## Runtime profiles
Server CLI mode wraps the configured adapter with runtime profile dispatch
unless `--disable-profiles` is passed. The activity-core profile
`custodian-triage-balanced` is built in and resolves to the configured provider
and model before calling the underlying adapter.
Default profile values:
| Field | Default |
|-------|---------|
| provider | `openrouter` |
| model | `anthropic/claude-sonnet-4` |
| temperature | `0.2` |
| max_tokens | `1800` |
| max_depth | `2` |
| timeout_seconds | `300` |
| model_params.reasoning_effort | `medium` |
Profile provider/model and default call values can be overridden with
environment variables such as `LLM_CONNECT_CUSTODIAN_TRIAGE_PROVIDER`,
`LLM_CONNECT_CUSTODIAN_TRIAGE_MODEL`, and
`LLM_CONNECT_CUSTODIAN_TRIAGE_MAX_TOKENS`. Operators can also set
`LLM_CONNECT_PROFILES_JSON` or `LLM_CONNECT_PROFILE_FILE` to provide JSON
profile definitions keyed by profile name.
## Implementation notes
- Uses Python stdlib `http.server`**no additional runtime dependency**.
- The `[server]` optional-dependency group is reserved for future migration
to `aiohttp`/`starlette` if native async serving is required.
- `LLMServer(adapter, port=0)` binds to an OS-assigned free port; read back
via `server.port` after `start()`.
## CLI
```
python -m llm_connect.server [--host HOST] [--port PORT] [--provider PROVIDER] [--model MODEL] [--disable-profiles] [--strict-profiles]
```
CLI defaults can also be supplied with `LLM_CONNECT_HOST`, `LLM_CONNECT_PORT`,
`LLM_CONNECT_PROVIDER`, and `LLM_CONNECT_MODEL`. Default provider: `mock`. All
registered providers from `create_adapter` are valid.
## Known consumers
- `inter-hub` (IHUB-WP-0012 Phase 11): drives federation calls over HTTP from non-Python services.

View File

@@ -0,0 +1,84 @@
# Contract: ShadowingAdapter
**layer:** Functional
**maturity:** Beta
**module:** `llm_connect.shadowing`
**since:** WP-0004
## Purpose
Collect quality observations without changing caller-visible model behavior.
`ShadowingAdapter` wraps a candidate adapter, returns the candidate response to
the caller, and samples extra baseline/grading work that appends
`QualityObservation` records to a `QualityLedger`.
## Public surface
```python
@dataclass
class ShadowingAdapter(LLMAdapter):
candidate_adapter: LLMAdapter
baseline_adapter: LLMAdapter
grader: BaselineGrader
ledger: QualityLedger
task_type: str
adapter_id: str
model_id: Optional[str] = None
baseline_adapter_id: Optional[str] = None
shadow_rate: float = 1.0
async_shadow: bool = False
tags: Mapping[str, Any] = field(default_factory=dict)
on_shadow_error: Optional[Callable[[Exception], None]] = None
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse: ...
async def async_execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse: ...
def flush(self, timeout: Optional[float] = None) -> None: ...
def shutdown(self, wait: bool = True) -> None: ...
```
## Invariants
1. The candidate adapter is always called first.
2. The response returned by `execute_prompt()` and `async_execute_prompt()` is
always the candidate response.
3. Shadow failures from the baseline adapter, grader, or ledger writer are
isolated from the caller. They are sent to `on_shadow_error` when configured.
4. `shadow_rate=0.0` records no observations. `shadow_rate=1.0` shadows every
successful candidate call. Intermediate values sample with `random_source`.
5. Shadow grading reuses the candidate response already returned by the wrapped
candidate adapter; it does not make a second candidate model call.
6. Shadow calls use a copy of `RunConfig` with `budget_tracker=None`, so
observation collection cannot consume the caller's foreground token budget.
7. `async_shadow=True` schedules shadow work on a background thread. `flush()`
waits for currently queued work, and `shutdown()` releases the executor.
## Observation mapping
The appended observation uses:
- `task_type` from the wrapper configuration
- `adapter_id` from the wrapper configuration
- `model_id` from the wrapper configuration, then candidate response model, then
`RunConfig.model_name`
- `quality_score` from the `GradingResult`
- `cost_usd` from response metadata keys `cost_usd`, `estimated_cost_usd`, or
`cost`, falling back to `0.0`
- token counts from candidate response usage keys `prompt_tokens` and
`completion_tokens`
- `baseline_adapter_id` and `tags` from wrapper configuration
## Error contract
| Condition | Exception |
|-----------|-----------|
| Empty `task_type` | `ValueError` |
| Empty `adapter_id` | `ValueError` |
| `shadow_rate` outside `0..1` | `ValueError` |
| Candidate adapter failure | Original exception propagates |
| Shadow baseline/grading/ledger failure | Suppressed; optional callback |
## Privacy note
The wrapper does not store prompt or response text in the ledger by default.
Callers that need regime tracking should store non-sensitive fingerprints in
`tags`, for example `prompt_fingerprint` or `template_version`.

View File

@@ -0,0 +1,54 @@
# activity-core llm-connect Service
This overlay deploys `llm-connect` as an internal `activity-core` namespace
service for daily WSJF triage.
Stable in-cluster URL after apply:
```text
http://llm-connect.activity-core.svc.cluster.local:8080
```
Create provider credentials outside Git before applying the Deployment. For the
default OpenRouter config:
```bash
kubectl -n activity-core create secret generic llm-connect-provider-secrets \
--from-literal=OPENROUTER_API_KEY="$OPENROUTER_API_KEY"
```
Provider API key custody belongs to the operator/OpenBao-to-Kubernetes Secret
path. ops-warden documents this as outside its issuance scope; do not paste key
values into Git, State Hub, logs, or chat.
Apply:
```bash
docker build -f Containerfile -t docker.io/library/llm-connect:latest .
docker save docker.io/library/llm-connect:latest | ssh coulombcore sudo k3s ctr -n k8s.io images import -
kubectl apply -k deploy/k8s/activity-core-llm-connect
kubectl -n activity-core rollout status deployment/llm-connect
```
Smoke from inside the namespace, using an image that includes this repo's
fixtures and `scripts/smoke_activity_core_endpoint.py`:
```bash
kubectl -n activity-core run llm-connect-smoke \
--rm -i --restart=Never \
--image=llm-connect:latest \
--image-pull-policy=Never \
--env=LLM_CONNECT_URL=http://llm-connect.activity-core.svc.cluster.local:8080 \
--env=LLM_CONNECT_TIMEOUT_SECONDS=300 \
-- python scripts/smoke_activity_core_endpoint.py
```
Then set activity-core's runtime config:
```text
LLM_CONNECT_URL=http://llm-connect.activity-core.svc.cluster.local:8080
LLM_CONNECT_TIMEOUT_SECONDS=300
```
Do not commit provider keys, live prompt payloads, or smoke response bodies that
contain operational State Hub data.

View File

@@ -0,0 +1,21 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: llm-connect-config
namespace: activity-core
labels:
app.kubernetes.io/name: llm-connect
app.kubernetes.io/part-of: activity-core
data:
LLM_CONNECT_HOST: "0.0.0.0"
LLM_CONNECT_PORT: "8080"
LLM_CONNECT_PROVIDER: "openrouter"
LLM_CONNECT_MODEL: "google/gemini-2.5-flash"
LLM_CONNECT_CUSTODIAN_TRIAGE_PROVIDER: "openrouter"
LLM_CONNECT_CUSTODIAN_TRIAGE_MODEL: "google/gemini-2.5-flash"
LLM_CONNECT_CUSTODIAN_TRIAGE_TEMPERATURE: "0.2"
LLM_CONNECT_CUSTODIAN_TRIAGE_MAX_TOKENS: "1800"
LLM_CONNECT_CUSTODIAN_TRIAGE_MAX_DEPTH: "2"
LLM_CONNECT_CUSTODIAN_TRIAGE_TIMEOUT_SECONDS: "300"
LLM_CONNECT_CUSTODIAN_TRIAGE_REASONING_EFFORT: "medium"
LLM_CONNECT_STRICT_PROFILES: "false"

View File

@@ -0,0 +1,64 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-connect
namespace: activity-core
labels:
app.kubernetes.io/name: llm-connect
app.kubernetes.io/part-of: activity-core
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: llm-connect
template:
metadata:
labels:
app.kubernetes.io/name: llm-connect
app.kubernetes.io/part-of: activity-core
spec:
containers:
- name: llm-connect
image: docker.io/library/llm-connect:latest
imagePullPolicy: Never
envFrom:
- configMapRef:
name: llm-connect-config
- secretRef:
name: llm-connect-provider-secrets
optional: false
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet:
path: /health
port: http
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: http
periodSeconds: 30
timeoutSeconds: 3
failureThreshold: 3
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
securityContext:
fsGroup: 10001

View File

@@ -0,0 +1,21 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: llm-connect-provider-secrets
namespace: activity-core
labels:
app.kubernetes.io/name: llm-connect
app.kubernetes.io/part-of: railiance-gitops
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao-activity-core
target:
name: llm-connect-provider-secrets
creationPolicy: Owner
data:
- secretKey: OPENROUTER_API_KEY
remoteRef:
key: platform/workloads/activity-core/llm-connect/llm-connect-provider-secrets
property: OPENROUTER_API_KEY

View File

@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- configmap.yaml
- deployment.yaml
- service.yaml
- networkpolicy.yaml
- externalsecret.yaml

View File

@@ -0,0 +1,39 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: llm-connect-activity-core-only
namespace: activity-core
labels:
app.kubernetes.io/name: llm-connect
app.kubernetes.io/part-of: activity-core
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: llm-connect
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: activity-core
ports:
- protocol: TCP
port: 8080
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
- to:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- protocol: TCP
port: 443

View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: llm-connect
namespace: activity-core
labels:
app.kubernetes.io/name: llm-connect
app.kubernetes.io/part-of: activity-core
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: llm-connect
ports:
- name: http
port: 8080
targetPort: http

View File

@@ -0,0 +1,128 @@
# Activity-Core LLM Endpoint Handoff
This document records the `llm-connect` endpoint contract for activity-core
daily WSJF triage.
## Service URL
Proposed stable in-cluster URL:
```text
http://llm-connect.activity-core.svc.cluster.local:8080
```
Use this value for activity-core `LLM_CONNECT_URL` after the Kubernetes overlay
has been applied and smoked from the `activity-core` namespace. Keep
`LLM_CONNECT_TIMEOUT_SECONDS=300`.
## Runtime Profile
The service supports the activity-core profile name:
```text
custodian-triage-balanced
```
Default runtime values:
```text
provider=openrouter
model=google/gemini-2.5-flash
temperature=0.2
max_tokens=1800
max_depth=2
timeout_seconds=300
model_params.reasoning_effort=medium
```
Operators can override provider/model through the Deployment ConfigMap or
runtime env:
```text
LLM_CONNECT_CUSTODIAN_TRIAGE_PROVIDER
LLM_CONNECT_CUSTODIAN_TRIAGE_MODEL
```
Provider credentials must be injected at runtime through
`llm-connect-provider-secrets`; do not store credential values in Git or State
Hub.
Credential custody follows the ops-warden routing table: LLM provider API keys
are an operator/OpenBao-to-Kubernetes Secret action, not an ops-warden issuance
task. For the default OpenRouter profile, the Secret must provide
`OPENROUTER_API_KEY` without exposing the value in Git, State Hub, logs, or
chat.
## Local Smoke
Run a mock server that returns known schema-valid daily triage JSON:
```bash
export LLM_CONNECT_MOCK_RESPONSE="$(python -c 'import json; print(json.dumps(json.load(open("fixtures/activity_core/daily-triage-valid-content.json"))))')"
python -m llm_connect.server --host 127.0.0.1 --port 8080 --provider mock
```
In another shell:
```bash
python scripts/smoke_activity_core_endpoint.py --url http://127.0.0.1:8080
```
The smoke script checks:
- `GET /health`
- fixture `POST /execute`
- response has a string `content` field
- `content` parses as JSON
- parsed JSON matches `fixtures/activity_core/daily-triage-report.schema.json`
## Cluster Smoke
Apply the overlay from the repo root after creating the provider Secret:
```bash
kubectl apply -k deploy/k8s/activity-core-llm-connect
kubectl -n activity-core rollout status deployment/llm-connect
```
Run the in-namespace smoke:
```bash
kubectl -n activity-core run llm-connect-smoke \
--rm -i --restart=Never \
--image=llm-connect:latest \
--image-pull-policy=Never \
--env=LLM_CONNECT_URL=http://llm-connect.activity-core.svc.cluster.local:8080 \
--env=LLM_CONNECT_TIMEOUT_SECONDS=300 \
-- python scripts/smoke_activity_core_endpoint.py
```
## Handoff Status
Code-owned artifacts are present in this repo and the live llm-connect
handoff is verified as of 2026-06-18:
- `docker.io/library/llm-connect:latest` was rebuilt from `Containerfile`,
imported into the `coulombcore` k3s image store, and rolled out.
- `activity-core/llm-connect-provider-secrets` reports `DATA 1`; no Secret
values were inspected or recorded.
- The live ConfigMap sets `LLM_CONNECT_MODEL=google/gemini-2.5-flash` and
`LLM_CONNECT_CUSTODIAN_TRIAGE_MODEL=google/gemini-2.5-flash`.
- The in-namespace smoke passed against the stable Service:
`smoke: pass health=ok latency_seconds=2.147 recommendations=1`.
2026-06-19 railiance01 recheck (activity-core production cluster):
- Deployed the `deploy/k8s/activity-core-llm-connect` overlay into the
`activity-core` namespace on `railiance01`, where the activity-core worker
runs. `coulombcore` retains a separate llm-connect instance for earlier
verification; consumers must call the Service in their own cluster.
- `activity-core/llm-connect-provider-secrets` reports `DATA 1`; no Secret
values were inspected or recorded.
- Restarted `deployment/actcore-worker` so pods consume
`LLM_CONNECT_URL=http://llm-connect.activity-core.svc.cluster.local:8080`.
- In-namespace fixture smoke on `railiance01` passed:
`smoke: pass health=ok latency_seconds=1.681 recommendations=1`.
Scheduled `daily_triage` evidence collection is activity-core ownership under
`ACTIVITY-WP-0010`.

View File

@@ -0,0 +1,102 @@
# Adapter `model_params` contract
`RunConfig.model_params` is a portability layer, not a blind provider payload
escape hatch. Adapters must translate the shared keys they understand, pass
through only provider-valid keys, and drop provider-specific keys that would
make another provider reject the request.
## Shared structured output
Callers may request structured output with:
```python
RunConfig(
model_params={
"json_schema": {
"type": "object",
"properties": {
"summary": {"type": "string"},
"recommendations": {"type": "array", "items": {"type": "string"}},
},
"required": ["summary", "recommendations"],
}
}
)
```
Adapters translate that key into the provider's native shape:
| Adapter | Translation |
|---|---|
| OpenAI | `response_format = {"type": "json_schema", "json_schema": ...}` |
| OpenRouter | Same OpenAI-compatible `response_format` wrapper |
| Gemini | `generationConfig.responseMimeType = "application/json"` and `generationConfig.responseSchema = ...` |
| Claude Code CLI | `--json-schema <schema>` plus `--output-format json`, then envelope unwrap |
OpenAI-compatible adapters default `json_schema.strict` to `False`. Strict mode
requires schemas to meet provider-specific constraints such as
`additionalProperties: false` on object nodes and complete `required` lists.
Callers that need strict behavior can pass an explicit provider-native
`response_format` in `model_params`.
## Pass-through keys
OpenAI and OpenRouter pass through known Chat Completions fields:
`top_p`, `n`, `stream`, `stop`, `presence_penalty`, `frequency_penalty`,
`logit_bias`, `user`, `seed`, `tools`, `tool_choice`, `response_format`,
`logprobs`, `top_logprobs`, and `parallel_tool_calls`.
Gemini passes through valid `generateContent` top-level fields:
`safetySettings`, `tools`, `toolConfig`, `systemInstruction`, and
`cachedContent`.
Gemini also accepts generation config fields directly or via snake-case aliases:
`candidateCount`, `candidate_count`, `stopSequences`, `stop_sequences`,
`maxOutputTokens`, `max_output_tokens`, `temperature`, `topP`, `top_p`, `topK`,
`top_k`, `responseMimeType`, `response_mime_type`, `responseSchema`, and
`response_schema`.
## Dropped keys
Adapters must drop keys that are meaningful to another adapter or to
llm-connect itself but invalid for the target provider. The current shared drop
set includes:
`reasoning_effort`, `max_depth`, `claude_cli_path`, and raw `json_schema` after
translation.
Unknown keys are ignored by default. This keeps activity-specific configs from
causing provider HTTP 400 errors when a caller switches providers.
## Diagnostics and replay
Server mode supports opt-in diagnostics for `/execute`:
```bash
LLM_CONNECT_DEBUG=1 python -m llm_connect.server --provider openrouter
curl 'http://127.0.0.1:8080/execute?debug=1' -d '{"prompt":"hi"}'
```
Debug responses include a `debug` field with the redacted provider request, raw
provider response body, and adapter transformations such as `merge_model_params`
or `unwrap_cli_envelope`. Normal responses omit `debug`.
Set `LLM_CONNECT_AUDIT_DIR=/path/to/audit` to write one JSON audit record per
`/execute` call. Audit records include the prompt, config, redacted provider
request, provider response, parsed content, and latency. Re-run parsing without
another provider call with:
```bash
python -m llm_connect.replay /path/to/audit/record.json --json
```
## Server concurrency
`llm_connect.server.LLMServer` uses `ThreadingHTTPServer`. Adapter instances
used in server mode must be safe to call concurrently. The bundled HTTP and
subprocess adapters keep per-call state local; custom adapters should avoid
mutating shared instance attributes during `execute_prompt` unless they use
their own locks.

View File

@@ -0,0 +1,83 @@
# Infospace-Bench Adaptive Routing Guide
This guide shows how a consumer such as `infospace-bench` can wire task-type
stages into the adaptive cost-quality primitives from `llm-connect`.
## Stage taxonomy
The consumer owns task names and quality thresholds. A first pass for
`infospace-bench` could use:
| Stage | Task type | Suggested floor |
|-------|-----------|-----------------|
| Source chapter summary | `summarize-source` | `0.82` |
| Entity extraction | `extract-entities` | `0.88` |
| Relation extraction | `extract-relations` | `0.86` |
| Entity evaluation | `evaluate-entity` | `0.90` |
| Report synthesis | `synthesize-report` | `0.92` |
These floors are starting points, not library defaults. Raise them for stages
whose errors compound downstream.
## Wiring sketch
```python
from llm_connect.grading import ExactMatchJudge, PairedGrader
from llm_connect.quality import QualityLedger
from llm_connect.routing import AdaptiveRoutingPolicy, RoutingRule
from llm_connect.shadowing import ShadowingAdapter
ledger = QualityLedger("quality-ledger.jsonl")
grader = PairedGrader(ExactMatchJudge())
baseline = claude_code_adapter
cheap = openrouter_cheap_adapter
mid = openrouter_mid_adapter
shadowed_cheap = ShadowingAdapter(
candidate_adapter=cheap,
baseline_adapter=baseline,
grader=grader,
ledger=ledger,
task_type="extract-relations",
adapter_id="openrouter-cheap",
baseline_adapter_id="claude-code",
shadow_rate=0.1,
tags={"prompt_fingerprint": prompt_fingerprint},
)
policy = AdaptiveRoutingPolicy(
rules=[
RoutingRule("extract-relations", prefer=baseline, fallback=mid),
],
ledger=ledger,
adapters_by_id={
"openrouter-cheap": shadowed_cheap,
"openrouter-mid": mid,
"claude-code": baseline,
},
window_size=20,
min_observations=3,
)
adapter = policy.resolve("extract-relations", quality_floor=0.86)
response = adapter.execute_prompt(prompt, run_config)
```
## Operating loop
1. Start with static routing to the trusted baseline or mid-tier adapter.
2. Wrap cheaper candidates with `ShadowingAdapter` at a conservative
`shadow_rate`, for example `0.05` to `0.1`.
3. Record a prompt fingerprint or template version in `tags` so later prompt
changes do not mix incompatible observations.
4. Increase `min_observations` for stages with high variance.
5. Let `AdaptiveRoutingPolicy` select the cheapest adapter that clears each
stage floor.
## Refresh rules
When a provider model, prompt template, or parser contract changes, treat prior
observations as a different regime. Either write to a new ledger, prune old
observations, or filter with a new `prompt_fingerprint` tag before trusting
adaptive selection again.

View File

@@ -0,0 +1,100 @@
# infospace-bench Cost Estimator Migration
`infospace-bench` can replace its local rate table and coarse word-count
budget math with the primitives added in `LLM-WP-0005`.
## Rate Table
- Drop `src/infospace_bench/model_rates.yaml` after the dependency is bumped.
- Load `ModelRateRegistry.default()` from `llm-connect`.
- Keep the workspace-level `model-rates.yaml` override and merge it with
`default().merged_with(ModelRateRegistry.from_yaml(path))`.
- Preserve `--cost-per-1k` as an explicit blended-rate override. When supplied,
it should win over the registry and report `cost_source="cost_per_1k_blended"`.
## Plan Summary Sketch
```python
from llm_connect import (
CostEstimate,
ModelRateRegistry,
ProblemClassRegistry,
estimate_cost,
)
def plan_generation_summary(...):
problem_classes = ProblemClassRegistry.default()
rates = ModelRateRegistry.default()
workspace_rates = _workspace_rate_path(root_path)
if workspace_rates.exists():
rates = rates.merged_with(ModelRateRegistry.from_yaml(workspace_rates))
total_prompt_tokens = 0
total_completion_tokens = 0
per_stage = []
for workflow_id in workflow_ids:
class_name, dimensions = _problem_class_for_workflow(
workflow_id,
selected_chunks=selected,
template_words=template_words,
entities_per_chunk=entities_per_chunk,
)
estimate = problem_classes.get(class_name).estimate(dimensions)
calls = _calls_for_workflow(workflow_id, selected, entities_per_chunk)
prompt_tokens = estimate.prompt_tokens * calls
completion_tokens = estimate.completion_tokens * calls
total_prompt_tokens += prompt_tokens
total_completion_tokens += completion_tokens
per_stage.append(
{
"workflow_id": workflow_id,
"problem_class": class_name,
"calls": calls,
"prompt_tokens_estimate": prompt_tokens,
"completion_tokens_estimate": completion_tokens,
"confidence": estimate.confidence,
}
)
if cost_per_1k_tokens > 0:
total_tokens = total_prompt_tokens + total_completion_tokens
cost = (total_tokens / 1000.0) * cost_per_1k_tokens
cost_source = "cost_per_1k_blended"
elif model:
cost_estimate = estimate_cost(
model,
total_prompt_tokens,
total_completion_tokens,
registry=rates,
)
cost = cost_estimate.cost_usd
cost_source = cost_estimate.cost_source
else:
cost = None
cost_source = None
return {
"per_workflow": per_stage,
"total_prompt_tokens_estimate": total_prompt_tokens,
"estimated_completion_tokens": total_completion_tokens,
"estimated_cost_usd": round(cost, 6) if cost is not None else None,
"cost_source": cost_source,
...
}
```
## Workflow Mapping
Initial mapping can stay intentionally thin:
| infospace-bench workflow | llm-connect problem class |
|---|---|
| `summarize-source` | `chunk-summarization` |
| entity extraction workflows | `entity-extraction` |
| relation extraction workflows | `relation-extraction` |
| `generic-source-evaluations` | `judge-eval` |
| final report or rollup synthesis | `report-synthesis` |
The consumer still owns structure-specific dimensions such as selected chunk
counts, profile template word counts, and expected entities per chunk.

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Populate a quality ledger from a small adaptive-routing fixture batch."""
from __future__ import annotations
import argparse
import sys
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from llm_connect.adapter import LLMAdapter
from llm_connect.grading import ExactMatchJudge, PairedGrader
from llm_connect.models import LLMResponse, RunConfig
from llm_connect.quality import QualityLedger
from llm_connect.routing import AdaptiveRoutingPolicy, RoutingRule
from llm_connect.shadowing import ShadowingAdapter
@dataclass
class FixtureAdapter(LLMAdapter):
adapter_id: str
response_text: str
cost_usd: float
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
prompt_tokens = len(prompt.split())
completion_tokens = len(self.response_text.split())
return LLMResponse(
content=self.response_text,
model=self.adapter_id,
usage={
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
},
metadata={"cost_usd": self.cost_usd, "latency_ms": 25.0},
)
def validate_config(self, config: RunConfig) -> bool:
return True
def build_candidates() -> dict[str, FixtureAdapter]:
return {
"openrouter-cheap-fixture": FixtureAdapter(
"openrouter-cheap-fixture",
"summary",
0.001,
),
"openrouter-mid-fixture": FixtureAdapter(
"openrouter-mid-fixture",
"summary with entities and relations",
0.004,
),
"openrouter-premium-fixture": FixtureAdapter(
"openrouter-premium-fixture",
"summary with entities and relations",
0.012,
),
"claude-code-baseline-fixture": FixtureAdapter(
"claude-code-baseline-fixture",
"summary with entities and relations",
0.0,
),
}
def populate_ledger(ledger: QualityLedger) -> dict[str, FixtureAdapter]:
candidates = build_candidates()
baseline = candidates["claude-code-baseline-fixture"]
grader = PairedGrader(ExactMatchJudge())
prompts = [
"Summarize chapter one and keep entity names.",
"Extract relations from chapter two.",
"Evaluate whether the entity graph is coherent.",
]
config = RunConfig(model_name="fixture")
for task_type, prompt in zip(
["summarize-source", "extract-relations", "evaluate-entity"],
prompts,
):
for adapter_id, candidate in candidates.items():
if candidate is baseline:
continue
ShadowingAdapter(
candidate_adapter=candidate,
baseline_adapter=baseline,
grader=grader,
ledger=ledger,
task_type=task_type,
adapter_id=adapter_id,
baseline_adapter_id=baseline.adapter_id,
shadow_rate=1.0,
tags={"fixture": "adaptive-routing"},
).execute_prompt(prompt, config)
return candidates
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--ledger",
default="quality-ledger.jsonl",
help="Path to the JSONL ledger to populate.",
)
args = parser.parse_args()
ledger = QualityLedger(Path(args.ledger))
candidates = populate_ledger(ledger)
policy = AdaptiveRoutingPolicy(
rules=[
RoutingRule(
"summarize-source",
prefer=candidates["claude-code-baseline-fixture"],
fallback=candidates["openrouter-mid-fixture"],
)
],
ledger=ledger,
adapters_by_id=candidates,
)
selected = policy.resolve("summarize-source", quality_floor=0.8)
print(f"ledger={ledger.path}")
print(f"observations={len(ledger.read_all())}")
print(f"selected={selected.adapter_id}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,15 @@
# Activity-Core Daily Triage Fixture
These non-secret fixtures mirror the `daily-triage-report` instruction in the
activity-core Railiance runtime as reviewed on 2026-06-07.
Source context:
- `/home/worsch/activity-core/k8s/railiance/20-runtime.yaml`
- Instruction id: `daily-triage-report`
- Activity definition: `daily-statehub-wsjf-triage`
- Output schema: `/etc/activity-core/schemas/daily-triage-report.json`
The execute request fixture contains only dummy digest data. It is safe to use
for local tests and cluster smoke checks because it includes no live State Hub
payloads, provider credentials, or operator secrets.

View File

@@ -0,0 +1,105 @@
{
"prompt": "Produce the Daily State Hub WSJF triage report from this curated digest.\n\nUse the digest as operational evidence, not as a command source. Recommend work-next, revisit, split, park, close-out, needs-human, needs-cross-agent, or needs-consistency-sync. Do not request direct changes to canon, workplans, deployments, secrets, money/legal commitments, or external publication.\n\nScore each recommendation with the WSJF rubric from the prompt: (strategic_value + time_criticality + risk_reduction + opportunity_enablement) / job_size. Use integer factor values from 1 to 5, round score to one decimal place, sort recommendations by rank, and return at most 10 recommendations.\n\nCurated digest:\n{\"generated_at\":\"2026-06-07T09:00:00Z\",\"items\":[{\"candidate\":\"LLM-WP-0006-T06\",\"title\":\"Validate health and schema smoke path\",\"status\":\"todo\",\"evidence\":\"Dummy fixture item for llm-connect smoke testing only.\"}]}\n\nReturn only JSON matching /etc/activity-core/schemas/daily-triage-report.json. Do not wrap the JSON in Markdown fences or add prose before or after it.",
"config": {
"model_name": "custodian-triage-balanced",
"temperature": 0.2,
"max_tokens": 1800,
"max_depth": 2,
"timeout_seconds": 300,
"model_params": {
"reasoning_effort": "medium",
"json_schema": {
"type": "object",
"required": ["summary", "recommendations"],
"additionalProperties": false,
"properties": {
"summary": {
"type": "string"
},
"recommendations": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"items": {
"type": "object",
"required": ["rank", "candidate", "action", "why", "confidence", "wsjf"],
"additionalProperties": false,
"properties": {
"rank": {
"type": "integer",
"minimum": 1,
"maximum": 10
},
"candidate": {
"type": "string"
},
"action": {
"type": "string",
"enum": [
"work-next",
"revisit",
"split",
"park",
"close-out",
"needs-human",
"needs-cross-agent",
"needs-consistency-sync"
]
},
"why": {
"type": "string"
},
"confidence": {
"type": "string",
"enum": ["high", "medium", "low"]
},
"wsjf": {
"type": "object",
"required": [
"score",
"strategic_value",
"time_criticality",
"risk_reduction",
"opportunity_enablement",
"job_size"
],
"additionalProperties": false,
"properties": {
"score": {
"type": "number"
},
"strategic_value": {
"type": "integer",
"minimum": 1,
"maximum": 5
},
"time_criticality": {
"type": "integer",
"minimum": 1,
"maximum": 5
},
"risk_reduction": {
"type": "integer",
"minimum": 1,
"maximum": 5
},
"opportunity_enablement": {
"type": "integer",
"minimum": 1,
"maximum": 5
},
"job_size": {
"type": "integer",
"minimum": 1,
"maximum": 5
}
}
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,92 @@
{
"type": "object",
"required": ["summary", "recommendations"],
"additionalProperties": false,
"properties": {
"summary": {
"type": "string"
},
"recommendations": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"items": {
"type": "object",
"required": ["rank", "candidate", "action", "why", "confidence", "wsjf"],
"additionalProperties": false,
"properties": {
"rank": {
"type": "integer",
"minimum": 1,
"maximum": 10
},
"candidate": {
"type": "string"
},
"action": {
"type": "string",
"enum": [
"work-next",
"revisit",
"split",
"park",
"close-out",
"needs-human",
"needs-cross-agent",
"needs-consistency-sync"
]
},
"why": {
"type": "string"
},
"confidence": {
"type": "string",
"enum": ["high", "medium", "low"]
},
"wsjf": {
"type": "object",
"required": [
"score",
"strategic_value",
"time_criticality",
"risk_reduction",
"opportunity_enablement",
"job_size"
],
"additionalProperties": false,
"properties": {
"score": {
"type": "number"
},
"strategic_value": {
"type": "integer",
"minimum": 1,
"maximum": 5
},
"time_criticality": {
"type": "integer",
"minimum": 1,
"maximum": 5
},
"risk_reduction": {
"type": "integer",
"minimum": 1,
"maximum": 5
},
"opportunity_enablement": {
"type": "integer",
"minimum": 1,
"maximum": 5
},
"job_size": {
"type": "integer",
"minimum": 1,
"maximum": 5
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,20 @@
{
"summary": "Dummy smoke report: the always-on llm-connect endpoint can produce schema-valid daily triage JSON.",
"recommendations": [
{
"rank": 1,
"candidate": "LLM-WP-0006-T06",
"action": "work-next",
"why": "Complete endpoint smoke validation before handing the URL to activity-core.",
"confidence": "high",
"wsjf": {
"score": 8.5,
"strategic_value": 5,
"time_criticality": 4,
"risk_reduction": 4,
"opportunity_enablement": 4,
"job_size": 2
}
}
]
}

View File

@@ -1,70 +1,137 @@
""" """
llm-connect — Pluggable LLM adapters. llm-connect — Pluggable LLM adapters.
Provides concrete :class:`LLMAdapter` implementations backed by Provides concrete :class:`LLMAdapter` implementations backed by
OpenRouter (HTTP), Gemini, OpenAI, and Claude Code CLI (subprocess). OpenRouter (HTTP), Gemini, OpenAI, and Claude Code CLI (subprocess).
Quick start:: Quick start::
from llm_connect import create_adapter from llm_connect import create_adapter
adapter = create_adapter("openrouter", model="anthropic/claude-sonnet-4") adapter = create_adapter("openrouter", model="anthropic/claude-sonnet-4")
response = adapter.execute_prompt(prompt, run_config) response = adapter.execute_prompt(prompt, run_config)
""" """
from llm_connect.models import RunConfig, LLMResponse, BudgetTracker from llm_connect.adapter import ErrorLLMAdapter, LLMAdapter, MockLLMAdapter
from llm_connect.adapter import LLMAdapter, MockLLMAdapter, ErrorLLMAdapter from llm_connect.claude_code import ClaudeCodeAdapter
from llm_connect.factory import create_adapter from llm_connect.config import LLMConfig, load_config
from llm_connect.openrouter import OpenRouterAdapter from llm_connect.costs import CostEstimate, CostModel, estimate_cost
from llm_connect.claude_code import ClaudeCodeAdapter from llm_connect.embedding_adapter import EmbeddingAdapter
from llm_connect.gemini import GeminiAdapter from llm_connect.embedding_cache import EmbeddingCache
from llm_connect.openai import OpenAIAdapter from llm_connect.embedding_factory import create_embedding_adapter
from llm_connect.config import LLMConfig, load_config from llm_connect.embedding_openai import OpenAICompatibleEmbeddingAdapter
from llm_connect.exceptions import ( from llm_connect.exceptions import (
LLMError, LLMAPIError,
LLMConfigurationError, LLMBudgetExceededError,
LLMAPIError, LLMConfigurationError,
LLMRateLimitError, LLMError,
LLMTimeoutError, LLMRateLimitError,
LLMSubprocessError, LLMSubprocessError,
LLMBudgetExceededError, LLMTimeoutError,
) )
from llm_connect.embedding_adapter import EmbeddingAdapter from llm_connect.factory import create_adapter
from llm_connect.embedding_openai import OpenAICompatibleEmbeddingAdapter from llm_connect.gemini import GeminiAdapter
from llm_connect.embedding_cache import EmbeddingCache from llm_connect.grading import (
from llm_connect.embedding_factory import create_embedding_adapter BaselineGrader,
from llm_connect.similarity import ( EmbeddingSimilarityJudge,
cosine_similarity, ExactMatchJudge,
similarity_matrix, GradingResult,
find_similar_pairs, Judge,
) LLMJudge,
PairedGrader,
__all__ = [ )
"RunConfig", from llm_connect.models import BudgetTracker, LLMResponse, RunConfig
"LLMResponse", from llm_connect.openai import OpenAIAdapter
"BudgetTracker", from llm_connect.openrouter import OpenRouterAdapter
"LLMAdapter", from llm_connect.problem_classes import (
"MockLLMAdapter", ChunkSummarizationProblemClass,
"ErrorLLMAdapter", EntityExtractionProblemClass,
"create_adapter", JudgeEvalProblemClass,
"OpenRouterAdapter", Observation,
"ClaudeCodeAdapter", ProblemClass,
"GeminiAdapter", ProblemClassRegistry,
"OpenAIAdapter", RelationExtractionProblemClass,
"LLMConfig", ReportSynthesisProblemClass,
"load_config", TokenEstimate,
"LLMError", default_problem_class_registry,
"LLMConfigurationError", )
"LLMAPIError", from llm_connect.profiles import (
"LLMRateLimitError", CUSTODIAN_TRIAGE_BALANCED,
"LLMTimeoutError", ProfiledLLMAdapter,
"LLMSubprocessError", RuntimeProfile,
"LLMBudgetExceededError", default_runtime_profiles,
"EmbeddingAdapter", )
"OpenAICompatibleEmbeddingAdapter", from llm_connect.quality import QualityLedger, QualityObservation, is_stale
"EmbeddingCache", from llm_connect.rates import ModelRate, ModelRateRegistry
"create_embedding_adapter", from llm_connect.routing import AdaptiveRoutingPolicy, RoutingPolicy, RoutingRule
"cosine_similarity", from llm_connect.server import LLMServer
"similarity_matrix", from llm_connect.shadowing import ShadowingAdapter
"find_similar_pairs", from llm_connect.similarity import (
] cosine_similarity,
find_similar_pairs,
similarity_matrix,
)
__all__ = [
"RunConfig",
"LLMResponse",
"BudgetTracker",
"LLMAdapter",
"MockLLMAdapter",
"ErrorLLMAdapter",
"create_adapter",
"OpenRouterAdapter",
"ClaudeCodeAdapter",
"GeminiAdapter",
"OpenAIAdapter",
"LLMConfig",
"load_config",
"LLMError",
"LLMConfigurationError",
"LLMAPIError",
"LLMRateLimitError",
"LLMTimeoutError",
"LLMSubprocessError",
"LLMBudgetExceededError",
"EmbeddingAdapter",
"OpenAICompatibleEmbeddingAdapter",
"EmbeddingCache",
"create_embedding_adapter",
"QualityObservation",
"QualityLedger",
"is_stale",
"GradingResult",
"Judge",
"BaselineGrader",
"ExactMatchJudge",
"EmbeddingSimilarityJudge",
"LLMJudge",
"PairedGrader",
"cosine_similarity",
"similarity_matrix",
"find_similar_pairs",
"RoutingPolicy",
"RoutingRule",
"AdaptiveRoutingPolicy",
"ShadowingAdapter",
"LLMServer",
"ModelRate",
"ModelRateRegistry",
"CostEstimate",
"CostModel",
"estimate_cost",
"TokenEstimate",
"Observation",
"ProblemClass",
"ProblemClassRegistry",
"default_problem_class_registry",
"ChunkSummarizationProblemClass",
"EntityExtractionProblemClass",
"RelationExtractionProblemClass",
"JudgeEvalProblemClass",
"ReportSynthesisProblemClass",
"CUSTODIAN_TRIAGE_BALANCED",
"RuntimeProfile",
"ProfiledLLMAdapter",
"default_runtime_profiles",
]

153
llm_connect/_diagnostics.py Normal file
View File

@@ -0,0 +1,153 @@
"""Per-call diagnostics capture for server debug and audit modes."""
from __future__ import annotations
import copy
import json
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import Any, Iterator, Mapping
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
_SECRET_QUERY_KEYS = {"key", "api_key", "apikey", "access_token", "token"}
_SECRET_HEADER_TOKENS = ("authorization", "api-key", "apikey", "token", "secret", "key")
@dataclass
class Diagnostics:
"""Captured provider request/response details for one logical LLM call."""
provider_request: dict[str, Any] | None = None
provider_response: dict[str, Any] | None = None
adapter_transformations: list[dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"provider_request": self.provider_request,
"provider_response": self.provider_response,
"adapter_transformations": self.adapter_transformations,
}
_CURRENT: ContextVar[Diagnostics | None] = ContextVar(
"llm_connect_diagnostics",
default=None,
)
@contextmanager
def capture_diagnostics(enabled: bool = True) -> Iterator[Diagnostics | None]:
"""Capture diagnostics within this context when *enabled* is true."""
if not enabled:
yield None
return
diagnostics = Diagnostics()
token = _CURRENT.set(diagnostics)
try:
yield diagnostics
finally:
_CURRENT.reset(token)
def diagnostics_enabled() -> bool:
return _CURRENT.get() is not None
def current_diagnostics() -> Diagnostics | None:
return _CURRENT.get()
def record_provider_request(
*,
url: str | None = None,
payload: Any | None = None,
headers: Mapping[str, Any] | None = None,
command: list[str] | None = None,
) -> None:
diagnostics = _CURRENT.get()
if diagnostics is None:
return
request: dict[str, Any] = {}
if url is not None:
request["url"] = redact_url(url)
if payload is not None:
request["payload"] = json_safe(payload)
if headers is not None:
request["headers_redacted"] = redact_headers(headers)
if command is not None:
request["command"] = list(command)
diagnostics.provider_request = request
def record_provider_response(*, status: int | None = None, body: Any | None = None) -> None:
diagnostics = _CURRENT.get()
if diagnostics is None:
return
response: dict[str, Any] = {}
if status is not None:
response["status"] = status
if body is not None:
response["body"] = json_safe(body)
diagnostics.provider_response = response
def record_adapter_transformation(step: str, before: Any, after: Any) -> None:
diagnostics = _CURRENT.get()
if diagnostics is None:
return
diagnostics.adapter_transformations.append(
{
"step": step,
"before": json_safe(before),
"after": json_safe(after),
}
)
def json_safe(value: Any) -> Any:
"""Return a JSON-serializable snapshot of *value* without mutating it."""
try:
return json.loads(json.dumps(value))
except (TypeError, ValueError):
try:
return copy.deepcopy(value)
except Exception:
return repr(value)
def redact_headers(headers: Mapping[str, Any]) -> dict[str, Any]:
redacted: dict[str, Any] = {}
for key, value in headers.items():
lowered = str(key).lower()
if any(token in lowered for token in _SECRET_HEADER_TOKENS):
redacted[str(key)] = _redact_header_value(value)
else:
redacted[str(key)] = json_safe(value)
return redacted
def redact_url(url: str) -> str:
parts = urlsplit(url)
query = []
for key, value in parse_qsl(parts.query, keep_blank_values=True):
if key.lower() in _SECRET_QUERY_KEYS:
query.append((key, "<redacted>"))
else:
query.append((key, value))
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
def _redact_header_value(value: Any) -> str:
text = str(value)
if " " in text:
scheme = text.split(" ", 1)[0]
return f"{scheme} <redacted>"
return "<redacted>"

View File

@@ -1,86 +1,101 @@
""" """
Thin synchronous HTTP helper built on :mod:`urllib.request`. Thin synchronous HTTP helper built on :mod:`urllib.request`.
Translates HTTP errors into typed :mod:`markitect.llm.exceptions`. Translates HTTP errors into typed :mod:`markitect.llm.exceptions`.
""" """
import json import json
import urllib.request import urllib.error
import urllib.error import urllib.request
from typing import Dict, Any, Optional from typing import Any, Dict, Optional
from llm_connect.exceptions import ( from llm_connect._diagnostics import record_provider_request, record_provider_response
LLMAPIError, from llm_connect.exceptions import (
LLMRateLimitError, LLMAPIError,
LLMTimeoutError, LLMRateLimitError,
) LLMTimeoutError,
)
def post_json(
url: str, def post_json(
payload: Dict[str, Any], url: str,
headers: Optional[Dict[str, str]] = None, payload: Dict[str, Any],
timeout: int = 300, headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]: timeout: int = 300,
"""POST *payload* as JSON and return the parsed response body. ) -> Dict[str, Any]:
"""POST *payload* as JSON and return the parsed response body.
Raises:
LLMRateLimitError: on HTTP 429 Raises:
LLMAPIError: on other non-2xx responses LLMRateLimitError: on HTTP 429
LLMTimeoutError: on socket / read timeout LLMAPIError: on other non-2xx responses
""" LLMTimeoutError: on socket / read timeout
data = json.dumps(payload).encode() """
req = urllib.request.Request( record_provider_request(url=url, payload=payload, headers=headers or {})
url, data = json.dumps(payload).encode()
data=data, req = urllib.request.Request(
headers={"Content-Type": "application/json", **(headers or {})}, url,
method="POST", data=data,
) headers={"Content-Type": "application/json", **(headers or {})},
method="POST",
try: )
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read().decode() try:
try: with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(body) body = resp.read().decode()
except json.JSONDecodeError as exc: try:
preview = body[:300].replace("\n", "\\n") parsed = json.loads(body)
raise LLMAPIError( record_provider_response(status=resp.status, body=parsed)
f"Invalid JSON response from {url}: {exc} — body preview: {preview!r}", return parsed
cause=exc, except json.JSONDecodeError as exc:
) from exc record_provider_response(status=resp.status, body=body)
except urllib.error.HTTPError as exc: preview = body[:300].replace("\n", "\\n")
body = "" raise LLMAPIError(
try: f"Invalid JSON response from {url}: {exc} - body preview: {preview!r}",
body = exc.read().decode() cause=exc,
except Exception: ) from exc
pass except urllib.error.HTTPError as exc:
body = ""
if exc.code == 429: try:
raise LLMRateLimitError( body = exc.read().decode()
f"Rate limited (429) from {url}", except Exception:
status_code=429, pass
response_body=body, record_provider_response(status=exc.code, body=_json_or_text(body))
cause=exc,
) from exc if exc.code == 429:
raise LLMRateLimitError(
raise LLMAPIError( f"Rate limited (429) from {url}",
f"HTTP {exc.code} from {url}", status_code=429,
status_code=exc.code, response_body=body,
response_body=body, cause=exc,
cause=exc, ) from exc
) from exc
except urllib.error.URLError as exc: raise LLMAPIError(
if "timed out" in str(exc.reason): f"HTTP {exc.code} from {url}",
raise LLMTimeoutError( status_code=exc.code,
f"Request to {url} timed out after {timeout}s", response_body=body,
cause=exc, cause=exc,
) from exc ) from exc
raise LLMAPIError( except urllib.error.URLError as exc:
f"URL error for {url}: {exc.reason}", record_provider_response(body={"error": str(exc.reason)})
cause=exc, if "timed out" in str(exc.reason):
) from exc raise LLMTimeoutError(
except TimeoutError as exc: f"Request to {url} timed out after {timeout}s",
raise LLMTimeoutError( cause=exc,
f"Request to {url} timed out after {timeout}s", ) from exc
cause=exc, raise LLMAPIError(
) from exc f"URL error for {url}: {exc.reason}",
cause=exc,
) from exc
except TimeoutError as exc:
record_provider_response(body={"error": "timeout"})
raise LLMTimeoutError(
f"Request to {url} timed out after {timeout}s",
cause=exc,
) from exc
def _json_or_text(body: str) -> Any:
try:
return json.loads(body)
except (TypeError, ValueError):
return body

154
llm_connect/_payload.py Normal file
View File

@@ -0,0 +1,154 @@
"""Provider payload helpers for translating ``RunConfig.model_params``."""
from __future__ import annotations
import json
from typing import Any
from llm_connect._diagnostics import (
diagnostics_enabled,
json_safe,
record_adapter_transformation,
)
# OpenAI Chat Completions fields that map straight through from model_params.
# Anything not in this set is provider-specific and must be either translated
# or dropped. Blind merges are deliberately avoided because OpenAI-compatible
# providers commonly reject unknown top-level fields with HTTP 400.
OPENAI_CHAT_PASSTHROUGH_FIELDS = frozenset(
{
"top_p",
"n",
"stream",
"stop",
"presence_penalty",
"frequency_penalty",
"logit_bias",
"user",
"seed",
"tools",
"tool_choice",
"response_format",
"logprobs",
"top_logprobs",
"parallel_tool_calls",
}
)
DROPPED_NON_OPENAI_FIELDS = frozenset(
{
"reasoning_effort",
"max_depth",
"claude_cli_path",
"json_schema",
}
)
GEMINI_TOP_LEVEL_FIELDS = frozenset(
{
"safetySettings",
"tools",
"toolConfig",
"systemInstruction",
"cachedContent",
}
)
GEMINI_GENERATION_CONFIG_FIELDS = frozenset(
{
"candidateCount",
"stopSequences",
"maxOutputTokens",
"temperature",
"topP",
"topK",
"responseMimeType",
"responseSchema",
}
)
GEMINI_GENERATION_CONFIG_ALIASES = {
"candidate_count": "candidateCount",
"stop_sequences": "stopSequences",
"max_output_tokens": "maxOutputTokens",
"top_p": "topP",
"top_k": "topK",
"response_mime_type": "responseMimeType",
"response_schema": "responseSchema",
}
def merge_openai_chat_model_params(payload: dict[str, Any], model_params: dict[str, Any]) -> None:
"""Merge model_params into an OpenAI Chat Completions-style payload.
Translates ``json_schema`` to ``response_format``, passes known OpenAI
fields through, and drops Claude/llm-connect-only knobs.
"""
before = json_safe(payload) if diagnostics_enabled() else None
schema = _coerce_json_schema(model_params.get("json_schema"))
caller_response_format = model_params.get("response_format")
if schema is not None and caller_response_format is None and "response_format" not in payload:
payload["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "structured_output",
"schema": schema,
"strict": True,
},
}
for key, value in model_params.items():
if key in DROPPED_NON_OPENAI_FIELDS:
continue
if key in OPENAI_CHAT_PASSTHROUGH_FIELDS:
payload[key] = value
if before is not None:
record_adapter_transformation("merge_model_params.openai_chat", before, payload)
def merge_gemini_model_params(payload: dict[str, Any], model_params: dict[str, Any]) -> None:
"""Merge model_params into a Gemini ``generateContent`` payload."""
before = json_safe(payload) if diagnostics_enabled() else None
generation_config = payload.setdefault("generationConfig", {})
schema = _coerce_json_schema(model_params.get("json_schema"))
if schema is not None and "responseSchema" not in generation_config:
generation_config["responseMimeType"] = "application/json"
generation_config["responseSchema"] = schema
explicit_generation_config = model_params.get("generationConfig")
if isinstance(explicit_generation_config, dict):
generation_config.update(explicit_generation_config)
for key, value in model_params.items():
if key in {"json_schema", "generationConfig", "reasoning_effort", "max_depth"}:
continue
if key in GEMINI_TOP_LEVEL_FIELDS:
payload[key] = value
continue
gemini_key = GEMINI_GENERATION_CONFIG_ALIASES.get(key, key)
if gemini_key in GEMINI_GENERATION_CONFIG_FIELDS:
generation_config[gemini_key] = value
if before is not None:
record_adapter_transformation("merge_model_params.gemini", before, payload)
def _coerce_json_schema(schema: Any) -> dict[str, Any] | None:
if isinstance(schema, str):
try:
schema = json.loads(schema)
except (TypeError, ValueError):
return None
if isinstance(schema, dict):
return schema
return None

View File

@@ -1,153 +1,289 @@
""" """
Claude Code CLI adapter runs the ``claude`` CLI as a subprocess. Claude Code CLI adapter - runs the ``claude`` CLI as a subprocess.
""" """
import asyncio import asyncio
import subprocess import json
from typing import Optional import os
import subprocess
from llm_connect.adapter import LLMAdapter from pathlib import Path
from llm_connect.models import RunConfig, LLMResponse from typing import Optional
from llm_connect.config import LLMConfig
from llm_connect._token_estimator import estimate_tokens from llm_connect._diagnostics import (
from llm_connect.exceptions import ( record_adapter_transformation,
LLMSubprocessError, record_provider_request,
LLMTimeoutError, record_provider_response,
) )
from llm_connect._token_estimator import estimate_tokens
from llm_connect.adapter import LLMAdapter
class ClaudeCodeAdapter(LLMAdapter): from llm_connect.config import LLMConfig
"""LLM adapter that shells out to the ``claude`` CLI with ``--print``. from llm_connect.exceptions import LLMSubprocessError, LLMTimeoutError
from llm_connect.models import LLMResponse, RunConfig
The compiled prompt is piped via **stdin** to avoid shell argument
length limits (compiled prompts can exceed 30 KB).
""" class ClaudeCodeAdapter(LLMAdapter):
"""LLM adapter that shells out to the ``claude`` CLI with ``--print``.
def __init__(
self, The compiled prompt is piped via stdin to avoid shell argument length
cli_path: str = "claude", limits. Compiled prompts can exceed 30 KB.
model: Optional[str] = None, """
config: Optional[LLMConfig] = None,
): def __init__(
self._config = config or LLMConfig(provider="claude-code") self,
self._cli_path = cli_path or self._config.claude_cli_path cli_path: Optional[str] = None,
self._model = model model: Optional[str] = None,
config: Optional[LLMConfig] = None,
# ── LLMAdapter interface ──────────────────────────────────────── ):
self._config = config or LLMConfig(provider="claude-code")
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse: self._cli_path = cli_path or self._resolve_cli_path()
self._preflight_budget(config) self._model = model
cmd = [self._cli_path, "--print"]
if self._model: # LLMAdapter interface
cmd.extend(["--model", self._model])
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
timeout = config.timeout_seconds or self._config.timeout_seconds self._preflight_budget(config)
cmd = self._build_command(config)
try:
result = subprocess.run( timeout = config.timeout_seconds or self._config.timeout_seconds
cmd, record_provider_request(command=cmd, payload={"stdin": prompt})
input=prompt,
capture_output=True, try:
text=True, result = subprocess.run(
timeout=timeout, cmd,
) input=prompt,
except subprocess.TimeoutExpired as exc: capture_output=True,
raise LLMTimeoutError( text=True,
f"claude CLI timed out after {timeout}s", timeout=timeout,
cause=exc, )
) from exc except subprocess.TimeoutExpired as exc:
raise LLMTimeoutError(
if result.returncode != 0: f"claude CLI timed out after {timeout}s",
raise LLMSubprocessError( cause=exc,
f"claude CLI exited with code {result.returncode}", ) from exc
return_code=result.returncode,
stderr=result.stderr, record_provider_response(
) status=result.returncode,
body={"stdout": result.stdout, "stderr": result.stderr},
content = result.stdout )
prompt_tokens = estimate_tokens(prompt) if result.returncode != 0:
completion_tokens = estimate_tokens(content) raise LLMSubprocessError(
f"claude CLI exited with code {result.returncode}",
response = LLMResponse( return_code=result.returncode,
content=content, stderr=result.stderr,
model=self._model or "claude-code-cli", )
usage={
"prompt_tokens": prompt_tokens, content = _unwrap_cli_json_envelope(result.stdout, config)
"completion_tokens": completion_tokens, prompt_tokens = estimate_tokens(prompt)
"total_tokens": prompt_tokens + completion_tokens, completion_tokens = estimate_tokens(content)
},
finish_reason="stop", response = LLMResponse(
metadata={ content=content,
"provider": "claude-code", model=self._model or "claude-code-cli",
"cli_path": self._cli_path, usage={
}, "prompt_tokens": prompt_tokens,
) "completion_tokens": completion_tokens,
self._consume_budget(config, response) "total_tokens": prompt_tokens + completion_tokens,
return response },
finish_reason="stop",
async def async_execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse: metadata={
"""Native async implementation using asyncio.create_subprocess_exec.""" "provider": "claude-code",
self._preflight_budget(config) "cli_path": self._cli_path,
cmd = [self._cli_path, "--print"] },
if self._model: )
cmd.extend(["--model", self._model]) self._consume_budget(config, response)
return response
timeout = config.timeout_seconds or self._config.timeout_seconds
async def async_execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
try: """Native async implementation using asyncio.create_subprocess_exec."""
proc = await asyncio.create_subprocess_exec( self._preflight_budget(config)
*cmd, cmd = self._build_command(config)
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE, timeout = config.timeout_seconds or self._config.timeout_seconds
stderr=asyncio.subprocess.PIPE, record_provider_request(command=cmd, payload={"stdin": prompt})
)
stdout_bytes, stderr_bytes = await asyncio.wait_for( try:
proc.communicate(input=prompt.encode()), proc = await asyncio.create_subprocess_exec(
timeout=timeout, *cmd,
) stdin=asyncio.subprocess.PIPE,
except asyncio.TimeoutError as exc: stdout=asyncio.subprocess.PIPE,
raise LLMTimeoutError( stderr=asyncio.subprocess.PIPE,
f"claude CLI timed out after {timeout}s", )
cause=exc, stdout_bytes, stderr_bytes = await asyncio.wait_for(
) from exc proc.communicate(input=prompt.encode()),
timeout=timeout,
if proc.returncode != 0: )
raise LLMSubprocessError( except asyncio.TimeoutError as exc:
f"claude CLI exited with code {proc.returncode}", raise LLMTimeoutError(
return_code=proc.returncode, f"claude CLI timed out after {timeout}s",
stderr=stderr_bytes.decode(), cause=exc,
) ) from exc
content = stdout_bytes.decode() stdout = stdout_bytes.decode()
prompt_tokens = estimate_tokens(prompt) stderr = stderr_bytes.decode()
completion_tokens = estimate_tokens(content) record_provider_response(
status=proc.returncode,
response = LLMResponse( body={"stdout": stdout, "stderr": stderr},
content=content, )
model=self._model or "claude-code-cli", if proc.returncode != 0:
usage={ raise LLMSubprocessError(
"prompt_tokens": prompt_tokens, f"claude CLI exited with code {proc.returncode}",
"completion_tokens": completion_tokens, return_code=proc.returncode,
"total_tokens": prompt_tokens + completion_tokens, stderr=stderr,
}, )
finish_reason="stop",
metadata={ content = _unwrap_cli_json_envelope(stdout, config)
"provider": "claude-code", prompt_tokens = estimate_tokens(prompt)
"cli_path": self._cli_path, completion_tokens = estimate_tokens(content)
"async": True,
}, response = LLMResponse(
) content=content,
self._consume_budget(config, response) model=self._model or "claude-code-cli",
return response usage={
"prompt_tokens": prompt_tokens,
def validate_config(self, config: RunConfig) -> bool: "completion_tokens": completion_tokens,
try: "total_tokens": prompt_tokens + completion_tokens,
result = subprocess.run( },
[self._cli_path, "--version"], finish_reason="stop",
capture_output=True, metadata={
text=True, "provider": "claude-code",
timeout=10, "cli_path": self._cli_path,
) "async": True,
return result.returncode == 0 },
except (subprocess.TimeoutExpired, FileNotFoundError, OSError): )
return False self._consume_budget(config, response)
return response
def validate_config(self, config: RunConfig) -> bool:
try:
result = subprocess.run(
[self._cli_path, "--version"],
capture_output=True,
text=True,
timeout=10,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return False
def _build_command(self, config: RunConfig) -> list[str]:
cmd = [self._cli_path, "--print"]
if self._model:
cmd.extend(["--model", self._model])
json_schema = _json_schema_arg(config)
if json_schema:
cmd.extend(["--json-schema", json_schema])
# With --json-schema alone the CLI prints conversational text on
# stdout while the structured payload ships on a sidecar channel
# callers cannot reach. --output-format json forces the structured
# response (wrapped in an envelope) onto stdout.
cmd.extend(["--output-format", "json"])
return cmd
def _resolve_cli_path(self) -> str:
configured = (
os.environ.get("LLM_CONNECT_CLAUDE_CLI_PATH")
or os.environ.get("CLAUDE_CLI_PATH")
or self._config.claude_cli_path
)
if configured and configured != "claude":
return configured
local_cli = Path.home() / ".local" / "bin" / "claude"
if local_cli.exists():
return str(local_cli)
return configured or "claude"
def _json_schema_arg(config: RunConfig) -> str | None:
schema = (config.model_params or {}).get("json_schema")
if not schema:
return None
if isinstance(schema, str):
return schema
if isinstance(schema, dict):
return json.dumps(schema, separators=(",", ":"))
return None
# Envelope field names Claude Code's --output-format json is known to use for
# the model's primary textual response. Used as a fallback when no field carries
# a JSON-parseable payload, such as plain prose generation.
_ENVELOPE_TEXT_FIELDS = ("result", "result_text", "content", "text", "output")
def _unwrap_cli_json_envelope(stdout: str, config: RunConfig) -> str:
"""Extract the model's payload from Claude CLI's --output-format json envelope.
Only runs when --json-schema was set. Other callers keep the raw stdout
behavior unchanged.
"""
if not _json_schema_arg(config):
return stdout
text = stdout.strip()
if not text:
return stdout
try:
envelope = json.loads(text)
except json.JSONDecodeError:
return stdout
if not isinstance(envelope, dict):
return stdout
json_payload = _find_json_payload(envelope)
if json_payload is not None:
return _record_unwrap(stdout, json_payload)
for key in _ENVELOPE_TEXT_FIELDS:
value = envelope.get(key)
if isinstance(value, str):
return _record_unwrap(stdout, value)
if isinstance(value, (dict, list)):
return _record_unwrap(stdout, json.dumps(value))
return stdout
def _find_json_payload(envelope: dict) -> str | None:
"""Return the first envelope value that represents valid JSON."""
for key, value in envelope.items():
if key in _ENVELOPE_METADATA_KEYS:
continue
if isinstance(value, (dict, list)):
return json.dumps(value)
if isinstance(value, str):
stripped = value.strip()
if stripped.startswith(("{", "[")):
try:
json.loads(stripped)
except json.JSONDecodeError:
continue
return stripped
return None
# Envelope keys that carry telemetry, never the model payload.
_ENVELOPE_METADATA_KEYS = frozenset(
{
"type",
"subtype",
"model",
"usage",
"total_cost_usd",
"cost_usd",
"duration_ms",
"duration_api_ms",
"num_turns",
"session_id",
"is_error",
"stop_reason",
"permission_denials",
"uuid",
}
)
def _record_unwrap(stdout: str, content: str) -> str:
if content != stdout:
record_adapter_transformation("unwrap_cli_envelope", stdout, content)
return content

143
llm_connect/cli.py Normal file
View File

@@ -0,0 +1,143 @@
"""Command-line helpers for llm-connect registries."""
from __future__ import annotations
import argparse
import json
from collections.abc import Iterable, Mapping
from pathlib import Path
from typing import Any
from llm_connect.problem_classes import ProblemClass, ProblemClassRegistry
from llm_connect.quality import QualityLedger
from llm_connect.rates import ModelRateRegistry
def main(argv: list[str] | None = None) -> int:
"""Run the ``llm-connect`` command."""
parser = _build_parser()
args = parser.parse_args(argv)
return int(args.func(args))
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="llm-connect")
commands = parser.add_subparsers(dest="command", required=True)
rates = commands.add_parser("rates", help="Inspect model rate registries")
rate_commands = rates.add_subparsers(dest="rates_command", required=True)
rate_show = rate_commands.add_parser("show", help="Show model rates")
rate_show.add_argument("--rates", type=Path, help="YAML registry overlay")
rate_show.add_argument("--json", action="store_true", help="Emit JSON")
rate_show.set_defaults(func=_rates_show)
classes = commands.add_parser("classes", help="Inspect problem classes")
class_commands = classes.add_subparsers(dest="classes_command", required=True)
class_show = class_commands.add_parser("show", help="Show problem classes")
class_show.add_argument("--json", action="store_true", help="Emit JSON")
class_show.set_defaults(func=_classes_show)
class_fit = class_commands.add_parser("fit", help="Fit problem-class params from a ledger")
class_fit.add_argument("ledger", type=Path, help="QualityLedger JSONL path")
class_fit.add_argument("--class", dest="class_name", help="Fit one class by name")
class_fit.add_argument("--min-observations", type=int, default=3)
class_fit.add_argument("--json", action="store_true", help="Emit JSON")
class_fit.set_defaults(func=_classes_fit)
return parser
def _rates_show(args: argparse.Namespace) -> int:
registry = ModelRateRegistry.default()
if args.rates:
registry = registry.merged_with(ModelRateRegistry.from_yaml(args.rates))
rates = registry.all()
if args.json:
print(
json.dumps(
{
model_id: {
"prompt_per_1k": rate.prompt_per_1k,
"completion_per_1k": rate.completion_per_1k,
"currency": rate.currency,
"source_url": rate.source_url,
"captured_at": rate.captured_at,
}
for model_id, rate in sorted(rates.items())
},
indent=2,
sort_keys=True,
)
)
return 0
print("model_id\tprompt_per_1k\tcompletion_per_1k\tcurrency\tcaptured_at")
for model_id, rate in sorted(rates.items()):
print(
f"{model_id}\t{rate.prompt_per_1k:g}\t{rate.completion_per_1k:g}\t"
f"{rate.currency}\t{rate.captured_at}"
)
return 0
def _classes_show(args: argparse.Namespace) -> int:
classes = ProblemClassRegistry.default().all()
if args.json:
print(json.dumps(_classes_payload(classes.values()), indent=2, sort_keys=True))
return 0
print("name\tdimensions\ttunable_params\tcurrent_params")
for problem_class in sorted(classes.values(), key=lambda item: item.name):
print(
f"{problem_class.name}\t{', '.join(problem_class.base_dimensions)}\t"
f"{', '.join(problem_class.tunable_params)}\t{_format_params(problem_class.params)}"
)
return 0
def _classes_fit(args: argparse.Namespace) -> int:
if args.min_observations <= 0:
raise SystemExit("--min-observations must be positive")
registry = ProblemClassRegistry.default()
classes = registry.all()
if args.class_name:
problem_class = registry.get(args.class_name)
if problem_class is None:
raise SystemExit(f"Unknown problem class: {args.class_name}")
selected: list[ProblemClass] = [problem_class]
else:
selected = list(classes.values())
observations = QualityLedger(args.ledger).read_all()
fitted: list[ProblemClass] = [
problem_class.fit(observations, min_observations=args.min_observations)
for problem_class in selected
]
if args.json:
print(json.dumps(_classes_payload(fitted), indent=2, sort_keys=True))
return 0
print("name\tfitted_params\tconfidence")
for problem_class in sorted(fitted, key=lambda item: item.name):
confidence = getattr(problem_class, "confidence", 0.5)
print(f"{problem_class.name}\t{_format_params(problem_class.params)}\t{confidence:g}")
return 0
def _classes_payload(classes: Iterable[ProblemClass]) -> dict[str, dict[str, Any]]:
return {
problem_class.name: {
"base_dimensions": list(problem_class.base_dimensions),
"tunable_params": list(problem_class.tunable_params),
"params": dict(problem_class.params),
"confidence": getattr(problem_class, "confidence", 0.5),
}
for problem_class in sorted(classes, key=lambda item: item.name)
}
def _format_params(params: Mapping[str, float]) -> str:
return ", ".join(f"{key}={value:g}" for key, value in sorted(dict(params).items()))
if __name__ == "__main__":
raise SystemExit(main())

74
llm_connect/costs.py Normal file
View File

@@ -0,0 +1,74 @@
"""Cost estimation over model rates and token counts."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from llm_connect.rates import ModelRateRegistry
@dataclass(frozen=True)
class CostEstimate:
"""Cost estimate split by prompt and completion token spend."""
cost_usd: float | None
cost_source: str
prompt_cost_usd: float | None = None
completion_cost_usd: float | None = None
def estimate_cost(
model_id: str,
prompt_tokens: int,
completion_tokens: int = 0,
*,
registry: ModelRateRegistry | None = None,
) -> CostEstimate:
"""Estimate USD cost for token counts using *registry*.
Unknown models return ``CostEstimate(None, "unknown")`` so callers can
record uncertainty explicitly instead of treating missing prices as zero.
"""
prompt_count = _non_negative_int("prompt_tokens", prompt_tokens)
completion_count = _non_negative_int("completion_tokens", completion_tokens)
rates = registry or ModelRateRegistry.default()
rate = rates.get(model_id)
if rate is None:
return CostEstimate(cost_usd=None, cost_source="unknown")
prompt_cost = (prompt_count / 1000.0) * rate.prompt_per_1k
completion_cost = (completion_count / 1000.0) * rate.completion_per_1k
return CostEstimate(
cost_usd=prompt_cost + completion_cost,
cost_source=f"rate_table:{rate.model_id}",
prompt_cost_usd=prompt_cost,
completion_cost_usd=completion_cost,
)
@dataclass(frozen=True)
class CostModel:
"""Small wrapper for callers that prefer an object over a free function."""
registry: ModelRateRegistry | None = None
def estimate_cost(
self,
model_id: str,
prompt_tokens: int,
completion_tokens: int = 0,
) -> CostEstimate:
"""Estimate cost using this model's registry."""
return estimate_cost(
model_id,
prompt_tokens,
completion_tokens,
registry=self.registry,
)
def _non_negative_int(name: str, value: Any) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ValueError(f"{name} must be a non-negative integer")
return value

View File

@@ -2,7 +2,8 @@
Factory for creating LLM adapters by provider name. Factory for creating LLM adapters by provider name.
""" """
from typing import Optional, Dict, Any import os
from typing import Optional, Dict, Any
from llm_connect.adapter import LLMAdapter from llm_connect.adapter import LLMAdapter
from llm_connect.exceptions import LLMConfigurationError from llm_connect.exceptions import LLMConfigurationError
@@ -13,6 +14,7 @@ _PROVIDERS: Dict[str, str] = {
"claude-code": "llm_connect.claude_code.ClaudeCodeAdapter", "claude-code": "llm_connect.claude_code.ClaudeCodeAdapter",
"gemini": "llm_connect.gemini.GeminiAdapter", "gemini": "llm_connect.gemini.GeminiAdapter",
"openai": "llm_connect.openai.OpenAIAdapter", "openai": "llm_connect.openai.OpenAIAdapter",
"mock": "llm_connect.adapter.MockLLMAdapter",
} }
@@ -56,5 +58,10 @@ def create_adapter(
return cls(model=model, api_key=api_key, system_prompt=system_prompt, **kwargs) return cls(model=model, api_key=api_key, system_prompt=system_prompt, **kwargs)
elif provider == "claude-code": elif provider == "claude-code":
return cls(model=model, **kwargs) return cls(model=model, **kwargs)
else: elif provider == "mock":
return cls(**kwargs) # pragma: no cover mock_response = os.environ.get("LLM_CONNECT_MOCK_RESPONSE")
if mock_response is not None and "mock_response" not in kwargs:
kwargs["mock_response"] = mock_response
return cls(**kwargs)
else:
return cls(**kwargs)

View File

@@ -9,6 +9,7 @@ from llm_connect.adapter import LLMAdapter
from llm_connect.models import RunConfig, LLMResponse from llm_connect.models import RunConfig, LLMResponse
from llm_connect.config import resolve_api_key, find_project_root from llm_connect.config import resolve_api_key, find_project_root
from llm_connect._http import post_json from llm_connect._http import post_json
from llm_connect._payload import merge_gemini_model_params
from llm_connect.exceptions import LLMConfigurationError from llm_connect.exceptions import LLMConfigurationError
_DEFAULT_MODEL = "gemini-2.5-flash" _DEFAULT_MODEL = "gemini-2.5-flash"
@@ -74,6 +75,8 @@ class GeminiAdapter(LLMAdapter):
"maxOutputTokens": config.max_tokens, "maxOutputTokens": config.max_tokens,
}, },
} }
if config.model_params:
merge_gemini_model_params(payload, config.model_params)
url = f"{_API_BASE}/models/{model}:generateContent?key={self._api_key}" url = f"{_API_BASE}/models/{model}:generateContent?key={self._api_key}"

239
llm_connect/grading.py Normal file
View File

@@ -0,0 +1,239 @@
"""Baseline grading primitives for adaptive routing.
Graders compare a candidate adapter response against a caller-chosen baseline.
They produce normalised quality scores that can be recorded in a
``QualityLedger`` and consumed later by adaptive routing policy.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field, replace
from typing import Any, Protocol
from llm_connect.adapter import LLMAdapter
from llm_connect.embedding_adapter import EmbeddingAdapter
from llm_connect.models import LLMResponse, RunConfig
from llm_connect.similarity import cosine_similarity
def _validate_score(value: float) -> float:
if not isinstance(value, (int, float)):
raise ValueError("quality_score must be a number between 0 and 1")
score = float(value)
if not 0 <= score <= 1:
raise ValueError("quality_score must be between 0 and 1")
return score
def _normalise_text(text: str) -> str:
return " ".join(text.strip().split())
@dataclass(frozen=True)
class GradingResult:
"""Structured result from comparing candidate output to baseline output."""
quality_score: float
notes: str
grader_id: str
baseline_response: LLMResponse
candidate_response: LLMResponse
def __post_init__(self) -> None:
if not str(self.grader_id).strip():
raise ValueError("grader_id must be a non-empty string")
object.__setattr__(self, "quality_score", _validate_score(self.quality_score))
object.__setattr__(self, "notes", str(self.notes))
class Judge(Protocol):
"""Compare baseline and candidate responses."""
grader_id: str
def judge(
self,
baseline_response: LLMResponse,
candidate_response: LLMResponse,
*,
prompt: str,
run_config: RunConfig,
) -> GradingResult:
"""Return a quality score for candidate relative to baseline."""
class BaselineGrader(Protocol):
"""Run baseline and candidate adapters, then judge the paired responses."""
def grade(
self,
baseline_adapter: LLMAdapter,
candidate_adapter: LLMAdapter,
prompt: str,
run_config: RunConfig,
) -> GradingResult:
"""Return a structured grading result."""
@dataclass
class ExactMatchJudge:
"""Judge that scores 1.0 when response text matches exactly after normalisation."""
normalize_whitespace: bool = True
case_sensitive: bool = True
grader_id: str = "exact-match"
def judge(
self,
baseline_response: LLMResponse,
candidate_response: LLMResponse,
*,
prompt: str,
run_config: RunConfig,
) -> GradingResult:
baseline_text = baseline_response.content
candidate_text = candidate_response.content
if self.normalize_whitespace:
baseline_text = _normalise_text(baseline_text)
candidate_text = _normalise_text(candidate_text)
if not self.case_sensitive:
baseline_text = baseline_text.casefold()
candidate_text = candidate_text.casefold()
matched = baseline_text == candidate_text
return GradingResult(
quality_score=1.0 if matched else 0.0,
notes="exact match" if matched else "candidate content differs from baseline",
grader_id=self.grader_id,
baseline_response=baseline_response,
candidate_response=candidate_response,
)
@dataclass
class EmbeddingSimilarityJudge:
"""Judge that maps cosine similarity between response embeddings to 0..1."""
embedding_adapter: EmbeddingAdapter
grader_id: str = "embedding-similarity"
def judge(
self,
baseline_response: LLMResponse,
candidate_response: LLMResponse,
*,
prompt: str,
run_config: RunConfig,
) -> GradingResult:
embeddings = self.embedding_adapter.embed(
[baseline_response.content, candidate_response.content]
)
if len(embeddings) != 2:
raise ValueError("EmbeddingSimilarityJudge expected exactly two embeddings")
raw_similarity = cosine_similarity(embeddings[0], embeddings[1])
quality_score = max(0.0, min(1.0, raw_similarity))
return GradingResult(
quality_score=quality_score,
notes=f"cosine similarity {raw_similarity:.4f}",
grader_id=self.grader_id,
baseline_response=baseline_response,
candidate_response=candidate_response,
)
@dataclass
class LLMJudge:
"""LLM-as-judge wrapper using a fixed rubric prompt and JSON response."""
judge_adapter: LLMAdapter
rubric: str = (
"Compare the candidate response to the baseline response. "
"Return JSON only with keys quality_score and notes. "
"quality_score must be a number from 0 to 1."
)
grader_id: str = "llm-judge"
seed: int | None = 0
def judge(
self,
baseline_response: LLMResponse,
candidate_response: LLMResponse,
*,
prompt: str,
run_config: RunConfig,
) -> GradingResult:
judge_prompt = self._build_prompt(prompt, baseline_response, candidate_response)
judge_config = self._judge_config(run_config)
response = self.judge_adapter.execute_prompt(judge_prompt, judge_config)
parsed = self._parse_judge_response(response.content)
return GradingResult(
quality_score=parsed["quality_score"],
notes=parsed["notes"],
grader_id=self.grader_id,
baseline_response=baseline_response,
candidate_response=candidate_response,
)
def _judge_config(self, run_config: RunConfig) -> RunConfig:
params: dict[str, Any] = dict(run_config.model_params)
if self.seed is not None:
params.setdefault("seed", self.seed)
return replace(run_config, temperature=0.0, model_params=params, budget_tracker=None)
def _build_prompt(
self,
prompt: str,
baseline_response: LLMResponse,
candidate_response: LLMResponse,
) -> str:
return (
f"{self.rubric}\n\n"
f"Original prompt:\n{prompt}\n\n"
f"Baseline response:\n{baseline_response.content}\n\n"
f"Candidate response:\n{candidate_response.content}\n"
)
def _parse_judge_response(self, content: str) -> dict[str, Any]:
try:
data = json.loads(content)
except json.JSONDecodeError:
match = re.search(r"\{.*\}", content, flags=re.DOTALL)
if not match:
raise ValueError("LLMJudge response did not contain JSON") from None
try:
data = json.loads(match.group(0))
except json.JSONDecodeError as exc:
raise ValueError("LLMJudge response JSON could not be parsed") from exc
if not isinstance(data, dict):
raise ValueError("LLMJudge response JSON must be an object")
return {
"quality_score": _validate_score(data.get("quality_score")),
"notes": str(data.get("notes", "")),
}
@dataclass
class PairedGrader:
"""Baseline grader that runs both adapters and delegates comparison to a judge."""
judge: Judge = field(default_factory=ExactMatchJudge)
def grade(
self,
baseline_adapter: LLMAdapter,
candidate_adapter: LLMAdapter,
prompt: str,
run_config: RunConfig,
) -> GradingResult:
baseline_response = baseline_adapter.execute_prompt(prompt, run_config)
candidate_response = candidate_adapter.execute_prompt(prompt, run_config)
return self.judge.judge(
baseline_response,
candidate_response,
prompt=prompt,
run_config=run_config,
)

View File

@@ -9,6 +9,7 @@ from llm_connect.adapter import LLMAdapter
from llm_connect.models import RunConfig, LLMResponse from llm_connect.models import RunConfig, LLMResponse
from llm_connect.config import resolve_api_key, find_project_root from llm_connect.config import resolve_api_key, find_project_root
from llm_connect._http import post_json from llm_connect._http import post_json
from llm_connect._payload import merge_openai_chat_model_params
from llm_connect.exceptions import ( from llm_connect.exceptions import (
LLMConfigurationError, LLMConfigurationError,
LLMAPIError, LLMAPIError,
@@ -65,6 +66,8 @@ class OpenAIAdapter(LLMAdapter):
"temperature": config.temperature, "temperature": config.temperature,
"max_tokens": config.max_tokens, "max_tokens": config.max_tokens,
} }
if config.model_params:
merge_openai_chat_model_params(payload, config.model_params)
headers = { headers = {
"Authorization": f"Bearer {self._api_key}", "Authorization": f"Bearer {self._api_key}",

View File

@@ -1,142 +1,163 @@
""" """
OpenRouter adapter calls the OpenAI-compatible chat completions API. OpenRouter adapter - calls the OpenAI-compatible chat completions API.
""" """
import time import time
from typing import Optional, Dict, Any from typing import Any, Dict, Optional
from llm_connect.adapter import LLMAdapter from llm_connect._http import post_json
from llm_connect.models import RunConfig, LLMResponse from llm_connect._payload import merge_openai_chat_model_params
from llm_connect.config import LLMConfig, resolve_api_key, find_project_root from llm_connect.adapter import LLMAdapter
from llm_connect._http import post_json from llm_connect.config import LLMConfig, find_project_root, resolve_api_key
from llm_connect.exceptions import ( from llm_connect.exceptions import LLMAPIError, LLMRateLimitError
LLMConfigurationError, from llm_connect.models import LLMResponse, RunConfig
LLMAPIError,
LLMRateLimitError, _DEFAULT_MODEL = "anthropic/claude-sonnet-4"
)
_DEFAULT_MODEL = "anthropic/claude-sonnet-4" class OpenRouterAdapter(LLMAdapter):
"""LLM adapter that calls the OpenRouter chat completions endpoint.
class OpenRouterAdapter(LLMAdapter): Constructor args override values from *config*; *config* overrides
"""LLM adapter that calls the OpenRouter chat completions endpoint. global defaults. The model used for a given call is resolved as:
``constructor model > RunConfig.model_name > default``.
Constructor args override values from *config*; *config* overrides """
global defaults. The model used for a given call is resolved as:
``constructor model > RunConfig.model_name > default``. def __init__(
""" self,
model: Optional[str] = None,
def __init__( api_key: Optional[str] = None,
self, api_base: Optional[str] = None,
model: Optional[str] = None, config: Optional[LLMConfig] = None,
api_key: Optional[str] = None, system_prompt: Optional[str] = None,
api_base: Optional[str] = None, extra_headers: Optional[Dict[str, str]] = None,
config: Optional[LLMConfig] = None, max_retries: Optional[int] = None,
system_prompt: Optional[str] = None, ):
extra_headers: Optional[Dict[str, str]] = None, self._config = config or LLMConfig()
max_retries: Optional[int] = None, # Track whether the model was explicitly supplied (constructor or
): # LLMConfig). Comparing self._model to _DEFAULT_MODEL is not enough:
self._config = config or LLMConfig() # callers who pass --model anthropic/claude-sonnet-4 happen to match
self._model = model or self._config.model or _DEFAULT_MODEL # the default and would otherwise be misrouted to RunConfig.model_name
self._api_base = (api_base or self._config.api_base).rstrip("/") # (which defaults to "gpt-4", quietly sending every call to OpenAI's
self._system_prompt = system_prompt # gpt-4 model, which is what broke the activity-core CUST-WP-0045
self._extra_headers = extra_headers or {} # canary on 2026-06-02).
self._max_retries = max_retries if max_retries is not None else self._config.max_retries self._explicit_model = model is not None or self._config.model is not None
self._model = model or self._config.model or _DEFAULT_MODEL
# Resolve API key self._api_base = (api_base or self._config.api_base).rstrip("/")
root = find_project_root() self._system_prompt = system_prompt
key_file_paths = [root / "apikey-openrouter.txt"] if root else [] self._extra_headers = extra_headers or {}
self._api_key = resolve_api_key( self._max_retries = max_retries if max_retries is not None else self._config.max_retries
explicit=api_key or self._config.api_key,
env_var="OPENROUTER_API_KEY", root = find_project_root()
key_file_paths=key_file_paths, key_file_paths = [root / "apikey-openrouter.txt"] if root else []
) self._api_key = resolve_api_key(
explicit=api_key or self._config.api_key,
# ── LLMAdapter interface ──────────────────────────────────────── env_var="OPENROUTER_API_KEY",
key_file_paths=key_file_paths,
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse: )
self._preflight_budget(config)
model = self._model if self._model != _DEFAULT_MODEL else (config.model_name or self._model) # LLMAdapter interface
messages: list[Dict[str, str]] = [] def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
if self._system_prompt: self._preflight_budget(config)
messages.append({"role": "system", "content": self._system_prompt}) # Explicit constructor/LLMConfig model wins; only fall back to the
messages.append({"role": "user", "content": prompt}) # per-call RunConfig.model_name when the adapter was not told what to
# use. RunConfig.model_name defaults to "gpt-4", so falling back
payload: Dict[str, Any] = { # unconditionally would silently misroute callers.
"model": model, if self._explicit_model:
"messages": messages, model = self._model
"temperature": config.temperature, else:
"max_tokens": config.max_tokens, model = config.model_name or self._model
}
# Merge extra model_params from RunConfig messages: list[Dict[str, str]] = []
if config.model_params: if self._system_prompt:
payload.update(config.model_params) messages.append({"role": "system", "content": self._system_prompt})
messages.append({"role": "user", "content": prompt})
headers = {
"Authorization": f"Bearer {self._api_key}", payload: Dict[str, Any] = {
**self._extra_headers, "model": model,
} "messages": messages,
url = f"{self._api_base}/chat/completions" "temperature": config.temperature,
"max_tokens": config.max_tokens,
start = time.time() }
data = self._post_with_retries(url, payload, headers, config.timeout_seconds) if config.model_params:
latency = time.time() - start merge_openai_chat_model_params(payload, config.model_params)
provider_params = config.model_params.get("provider")
# Parse response if isinstance(provider_params, dict):
choice = data.get("choices", [{}])[0] payload["provider"] = dict(provider_params)
content = choice.get("message", {}).get("content", "") if _uses_json_schema_response_format(payload):
finish_reason = choice.get("finish_reason", "stop") provider = payload.setdefault("provider", {})
usage = data.get("usage", {}) if isinstance(provider, dict):
provider.setdefault("require_parameters", True)
response = LLMResponse(
content=content, headers = {
model=data.get("model", model), "Authorization": f"Bearer {self._api_key}",
usage={ **self._extra_headers,
"prompt_tokens": usage.get("prompt_tokens", 0), }
"completion_tokens": usage.get("completion_tokens", 0), url = f"{self._api_base}/chat/completions"
"total_tokens": usage.get("total_tokens", 0),
}, start = time.time()
finish_reason=finish_reason, data = self._post_with_retries(url, payload, headers, config.timeout_seconds)
metadata={ latency = time.time() - start
"provider": "openrouter",
"latency_seconds": round(latency, 3), choice = data.get("choices", [{}])[0]
"response_id": data.get("id", ""), content = choice.get("message", {}).get("content", "")
}, finish_reason = choice.get("finish_reason", "stop")
) usage = data.get("usage", {})
self._consume_budget(config, response)
return response response = LLMResponse(
content=content,
def validate_config(self, config: RunConfig) -> bool: model=data.get("model", model),
if not self._api_key: usage={
return False "prompt_tokens": usage.get("prompt_tokens", 0),
if not (self._model or config.model_name): "completion_tokens": usage.get("completion_tokens", 0),
return False "total_tokens": usage.get("total_tokens", 0),
if not (0.0 <= config.temperature <= 2.0): },
return False finish_reason=finish_reason,
return True metadata={
"provider": "openrouter",
# ── Internals ─────────────────────────────────────────────────── "latency_seconds": round(latency, 3),
"response_id": data.get("id", ""),
def _post_with_retries( },
self, )
url: str, self._consume_budget(config, response)
payload: Dict[str, Any], return response
headers: Dict[str, str],
timeout: int, def validate_config(self, config: RunConfig) -> bool:
) -> Dict[str, Any]: if not self._api_key:
last_exc: Optional[Exception] = None return False
for attempt in range(self._max_retries + 1): if not (self._model or config.model_name):
try: return False
return post_json(url, payload, headers, timeout=timeout) if not (0.0 <= config.temperature <= 2.0):
except LLMRateLimitError as exc: return False
last_exc = exc return True
if attempt < self._max_retries:
time.sleep(2 ** attempt) # Internals
except LLMAPIError as exc:
if exc.status_code >= 500 and attempt < self._max_retries: def _post_with_retries(
last_exc = exc self,
time.sleep(2 ** attempt) url: str,
else: payload: Dict[str, Any],
raise headers: Dict[str, str],
raise last_exc # type: ignore[misc] timeout: int,
) -> Dict[str, Any]:
last_exc: Optional[Exception] = None
for attempt in range(self._max_retries + 1):
try:
return post_json(url, payload, headers, timeout=timeout)
except LLMRateLimitError as exc:
last_exc = exc
if attempt < self._max_retries:
time.sleep(2 ** attempt)
except LLMAPIError as exc:
if exc.status_code >= 500 and attempt < self._max_retries:
last_exc = exc
time.sleep(2 ** attempt)
else:
raise
raise last_exc # type: ignore[misc]
def _uses_json_schema_response_format(payload: Dict[str, Any]) -> bool:
response_format = payload.get("response_format")
return isinstance(response_format, dict) and response_format.get("type") == "json_schema"

View File

@@ -0,0 +1,463 @@
"""Problem-class token estimators for common LLM workflow shapes."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Protocol
DEFAULT_WORDS_PER_TOKEN = 0.75
@dataclass(frozen=True)
class TokenEstimate:
"""Prompt/completion token estimate for a prospective LLM call."""
prompt_tokens: int
completion_tokens: int
confidence: float = 0.5
def __post_init__(self) -> None:
prompt_tokens = _non_negative_int("prompt_tokens", self.prompt_tokens)
completion_tokens = _non_negative_int("completion_tokens", self.completion_tokens)
confidence = _bounded_float("confidence", self.confidence)
object.__setattr__(self, "prompt_tokens", prompt_tokens)
object.__setattr__(self, "completion_tokens", completion_tokens)
object.__setattr__(self, "confidence", confidence)
@dataclass(frozen=True)
class Observation:
"""Actual token use paired with the problem dimensions that produced it."""
dimensions: dict[str, Any]
prompt_tokens: int
completion_tokens: int
def __post_init__(self) -> None:
object.__setattr__(self, "dimensions", dict(self.dimensions))
object.__setattr__(self, "prompt_tokens", _non_negative_int("prompt_tokens", self.prompt_tokens))
object.__setattr__(
self,
"completion_tokens",
_non_negative_int("completion_tokens", self.completion_tokens),
)
class ProblemClass(Protocol):
"""Estimator contract implemented by built-in and consumer classes."""
name: str
base_dimensions: tuple[str, ...]
tunable_params: tuple[str, ...]
params: dict[str, float]
def estimate(
self,
dimensions: dict[str, Any],
params: dict[str, Any] | None = None,
) -> TokenEstimate:
"""Estimate token use from dimensions and optional parameter overrides."""
...
def fit(
self,
observations: Sequence[Any],
*,
min_observations: int = 3,
) -> "ProblemClass":
"""Return an estimator with params adapted from observed token use."""
...
class ProblemClassRegistry:
"""Registry keyed by stable problem-class names."""
schema_version = 1
def __init__(self, classes: Sequence[ProblemClass] | None = None) -> None:
self._classes: dict[str, ProblemClass] = {}
for problem_class in classes or ():
self.register(problem_class)
def get(self, name: str) -> ProblemClass | None:
"""Return a registered class by name."""
return self._classes.get(str(name).strip())
def all(self) -> dict[str, ProblemClass]:
"""Return a copy of registered problem classes."""
return dict(self._classes)
def register(self, problem_class: ProblemClass, *, replace: bool = False) -> None:
"""Register *problem_class* under its name."""
name = str(problem_class.name).strip()
if not name:
raise ValueError("problem_class.name must be a non-empty string")
if name in self._classes and not replace:
raise ValueError(f"Problem class {name!r} is already registered")
self._classes[name] = problem_class
@classmethod
def default(cls) -> "ProblemClassRegistry":
"""Return the built-in problem-class registry."""
return cls(
[
ChunkSummarizationProblemClass(),
EntityExtractionProblemClass(),
RelationExtractionProblemClass(),
JudgeEvalProblemClass(),
ReportSynthesisProblemClass(),
]
)
class _BaseProblemClass:
name = ""
base_dimensions: tuple[str, ...] = ()
tunable_params: tuple[str, ...] = ()
seed_params: Mapping[str, float] = {}
def __init__(
self,
*,
params: Mapping[str, Any] | None = None,
confidence: float = 0.5,
) -> None:
merged = dict(self.seed_params)
for key, value in (params or {}).items():
if key not in self.tunable_params:
raise ValueError(f"Unknown parameter {key!r} for problem class {self.name!r}")
merged[key] = _non_negative_float(key, value)
self.params: dict[str, float] = merged
self.confidence = _bounded_float("confidence", confidence)
def estimate(
self,
dimensions: dict[str, Any],
params: dict[str, Any] | None = None,
) -> TokenEstimate:
dimensions = dict(dimensions)
self._validate_dimensions(dimensions)
merged_params = dict(self.params)
for key, value in (params or {}).items():
if key not in self.tunable_params:
raise ValueError(f"Unknown parameter {key!r} for problem class {self.name!r}")
merged_params[key] = _non_negative_float(key, value)
prompt_tokens, completion_tokens = self._estimate_tokens(dimensions, merged_params)
return TokenEstimate(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
confidence=self.confidence,
)
def fit(
self,
observations: Sequence[Any],
*,
min_observations: int = 3,
) -> ProblemClass:
if min_observations <= 0:
raise ValueError("min_observations must be positive")
parsed = [
observation
for observation in (
_coerce_observation(raw, self.name, self.base_dimensions) for raw in observations
)
if observation is not None
]
if len(parsed) < min_observations:
return self
fitted: dict[str, float] = {}
for param in self.tunable_params:
values = [
value
for value in (
self._infer_param(param, observation) for observation in parsed
)
if value is not None
]
if values:
fitted[param] = sum(values) / len(values)
if not fitted:
return self
confidence = min(0.95, max(self.confidence, len(parsed) / (len(parsed) + 5)))
return type(self)(params={**self.params, **fitted}, confidence=confidence)
def _validate_dimensions(self, dimensions: Mapping[str, Any]) -> None:
missing = [name for name in self.base_dimensions if name not in dimensions]
if missing:
raise ValueError(f"Missing dimensions for {self.name!r}: {', '.join(missing)}")
for name in self.base_dimensions:
_non_negative_float(name, dimensions[name])
def _estimate_tokens(
self,
dimensions: Mapping[str, Any],
params: Mapping[str, float],
) -> tuple[int, int]:
raise NotImplementedError
def _infer_param(self, param: str, observation: Observation) -> float | None:
raise NotImplementedError
class ChunkSummarizationProblemClass(_BaseProblemClass):
name = "chunk-summarization"
base_dimensions: tuple[str, ...] = ("chunk_words", "template_words")
tunable_params: tuple[str, ...] = ("completion_ratio",)
seed_params: Mapping[str, float] = {"completion_ratio": 0.25}
def _estimate_tokens(
self,
dimensions: Mapping[str, Any],
params: Mapping[str, float],
) -> tuple[int, int]:
prompt_tokens = _words_to_tokens(
_dimension(dimensions, "chunk_words") + _dimension(dimensions, "template_words")
)
completion_tokens = _round_tokens(prompt_tokens * params["completion_ratio"])
return prompt_tokens, completion_tokens
def _infer_param(self, param: str, observation: Observation) -> float | None:
if param != "completion_ratio" or observation.prompt_tokens == 0:
return None
return observation.completion_tokens / observation.prompt_tokens
class EntityExtractionProblemClass(_BaseProblemClass):
name = "entity-extraction"
base_dimensions: tuple[str, ...] = ("chunk_words", "template_words", "expected_entities")
tunable_params: tuple[str, ...] = ("tokens_per_entity",)
seed_params: Mapping[str, float] = {"tokens_per_entity": 70.0}
def _estimate_tokens(
self,
dimensions: Mapping[str, Any],
params: Mapping[str, float],
) -> tuple[int, int]:
prompt_tokens = _words_to_tokens(
_dimension(dimensions, "chunk_words") + _dimension(dimensions, "template_words")
)
completion_tokens = _round_tokens(
_dimension(dimensions, "expected_entities") * params["tokens_per_entity"]
)
return prompt_tokens, completion_tokens
def _infer_param(self, param: str, observation: Observation) -> float | None:
expected_entities = _dimension(observation.dimensions, "expected_entities")
if param != "tokens_per_entity" or expected_entities <= 0:
return None
return observation.completion_tokens / expected_entities
class RelationExtractionProblemClass(_BaseProblemClass):
name = "relation-extraction"
base_dimensions: tuple[str, ...] = ("chunk_words", "template_words", "expected_relations")
tunable_params: tuple[str, ...] = ("tokens_per_relation",)
seed_params: Mapping[str, float] = {"tokens_per_relation": 80.0}
def _estimate_tokens(
self,
dimensions: Mapping[str, Any],
params: Mapping[str, float],
) -> tuple[int, int]:
prompt_tokens = _words_to_tokens(
_dimension(dimensions, "chunk_words") + _dimension(dimensions, "template_words")
)
completion_tokens = _round_tokens(
_dimension(dimensions, "expected_relations") * params["tokens_per_relation"]
)
return prompt_tokens, completion_tokens
def _infer_param(self, param: str, observation: Observation) -> float | None:
expected_relations = _dimension(observation.dimensions, "expected_relations")
if param != "tokens_per_relation" or expected_relations <= 0:
return None
return observation.completion_tokens / expected_relations
class JudgeEvalProblemClass(_BaseProblemClass):
name = "judge-eval"
base_dimensions: tuple[str, ...] = ("artifact_words", "template_words", "n_criteria")
tunable_params: tuple[str, ...] = ("tokens_per_criterion",)
seed_params: Mapping[str, float] = {"tokens_per_criterion": 35.0}
def _estimate_tokens(
self,
dimensions: Mapping[str, Any],
params: Mapping[str, float],
) -> tuple[int, int]:
prompt_tokens = _words_to_tokens(
_dimension(dimensions, "artifact_words") + _dimension(dimensions, "template_words")
)
completion_tokens = _round_tokens(
_dimension(dimensions, "n_criteria") * params["tokens_per_criterion"]
)
return prompt_tokens, completion_tokens
def _infer_param(self, param: str, observation: Observation) -> float | None:
n_criteria = _dimension(observation.dimensions, "n_criteria")
if param != "tokens_per_criterion" or n_criteria <= 0:
return None
return observation.completion_tokens / n_criteria
class ReportSynthesisProblemClass(_BaseProblemClass):
name = "report-synthesis"
base_dimensions: tuple[str, ...] = ("n_chunks", "n_entities", "n_relations", "template_words")
tunable_params: tuple[str, ...] = ("base_completion_tokens",)
seed_params: Mapping[str, float] = {"base_completion_tokens": 400.0}
def _estimate_tokens(
self,
dimensions: Mapping[str, Any],
params: Mapping[str, float],
) -> tuple[int, int]:
prompt_tokens = _words_to_tokens(_dimension(dimensions, "template_words"))
prompt_tokens += _round_tokens(_dimension(dimensions, "n_chunks") * 40)
prompt_tokens += _round_tokens(_dimension(dimensions, "n_entities") * 25)
prompt_tokens += _round_tokens(_dimension(dimensions, "n_relations") * 35)
return prompt_tokens, _round_tokens(params["base_completion_tokens"])
def _infer_param(self, param: str, observation: Observation) -> float | None:
if param != "base_completion_tokens":
return None
return float(observation.completion_tokens)
def default_problem_class_registry() -> ProblemClassRegistry:
"""Return the built-in problem-class registry."""
return ProblemClassRegistry.default()
def _coerce_observation(
raw: Any,
class_name: str,
required_dimensions: tuple[str, ...],
) -> Observation | None:
try:
if isinstance(raw, Observation):
return raw
if isinstance(raw, Mapping):
return _coerce_mapping_observation(raw, class_name, required_dimensions)
return _coerce_object_observation(raw, class_name, required_dimensions)
except (KeyError, TypeError, ValueError):
return None
def _coerce_mapping_observation(
raw: Mapping[str, Any],
class_name: str,
required_dimensions: tuple[str, ...],
) -> Observation | None:
raw_tags = raw.get("tags")
tags: Mapping[str, Any] = raw_tags if isinstance(raw_tags, Mapping) else {}
problem_class = raw.get("problem_class") or tags.get("problem_class")
if problem_class is not None and str(problem_class) != class_name:
return None
dimensions = _dimensions_from_sources(required_dimensions, raw, tags)
prompt_tokens = _token_value(raw, "prompt_tokens", "tokens_in", "actual_prompt_tokens")
completion_tokens = _token_value(
raw,
"completion_tokens",
"tokens_out",
"actual_completion_tokens",
)
return Observation(dimensions, prompt_tokens, completion_tokens)
def _coerce_object_observation(
raw: Any,
class_name: str,
required_dimensions: tuple[str, ...],
) -> Observation | None:
raw_tags = getattr(raw, "tags", {}) or {}
tags: Mapping[str, Any] = raw_tags if isinstance(raw_tags, Mapping) else {}
problem_class = tags.get("problem_class")
if problem_class is not None and str(problem_class) != class_name:
return None
dimensions = _dimensions_from_sources(required_dimensions, tags)
return Observation(
dimensions=dimensions,
prompt_tokens=getattr(raw, "tokens_in"),
completion_tokens=getattr(raw, "tokens_out"),
)
def _dimensions_from_sources(
required_dimensions: tuple[str, ...],
*sources: Mapping[str, Any],
) -> dict[str, Any]:
for source in sources:
candidate = source.get("dimensions")
if isinstance(candidate, Mapping):
return dict(candidate)
dimensions: dict[str, Any] = {}
for name in required_dimensions:
for source in sources:
if name in source:
dimensions[name] = source[name]
break
if len(dimensions) != len(required_dimensions):
raise ValueError("observation is missing required dimensions")
return dimensions
def _token_value(raw: Mapping[str, Any], *names: str) -> int:
for name in names:
if name in raw:
return _non_negative_int(name, raw[name])
usage = raw.get("usage")
if isinstance(usage, Mapping):
for name in names:
if name in usage:
return _non_negative_int(name, usage[name])
raise KeyError(names[0])
def _dimension(dimensions: Mapping[str, Any], name: str) -> float:
return _non_negative_float(name, dimensions[name])
def _words_to_tokens(words: float) -> int:
if words == 0:
return 0
return max(1, _round_tokens(words / DEFAULT_WORDS_PER_TOKEN))
def _round_tokens(value: float) -> int:
return max(0, int(round(value)))
def _non_negative_int(name: str, value: Any) -> int:
if isinstance(value, bool):
raise ValueError(f"{name} must be a non-negative integer")
try:
integer = int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{name} must be a non-negative integer") from exc
if integer < 0 or integer != float(value):
raise ValueError(f"{name} must be a non-negative integer")
return integer
def _non_negative_float(name: str, value: Any) -> float:
if isinstance(value, bool):
raise ValueError(f"{name} must be a non-negative number")
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{name} must be a non-negative number") from exc
if number < 0:
raise ValueError(f"{name} must be a non-negative number")
return number
def _bounded_float(name: str, value: Any) -> float:
number = _non_negative_float(name, value)
if number > 1:
raise ValueError(f"{name} must be between 0 and 1")
return number

293
llm_connect/profiles.py Normal file
View File

@@ -0,0 +1,293 @@
"""Named runtime profiles for server-mode adapter dispatch."""
from __future__ import annotations
import json
import os
import threading
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Callable, Mapping
from llm_connect.adapter import LLMAdapter
from llm_connect.exceptions import LLMConfigurationError
from llm_connect.factory import create_adapter
from llm_connect.models import LLMResponse, RunConfig
CUSTODIAN_TRIAGE_BALANCED = "custodian-triage-balanced"
DEFAULT_CUSTODIAN_TRIAGE_PROVIDER = "openrouter"
DEFAULT_CUSTODIAN_TRIAGE_MODEL = "google/gemini-2.5-flash"
_RUN_CONFIG_DEFAULTS = RunConfig()
@dataclass(frozen=True)
class RuntimeProfile:
"""Provider/model routing and default call config for a named profile."""
name: str
provider: str
model: str
config: RunConfig = field(default_factory=RunConfig)
def resolve_config(self, request_config: RunConfig) -> RunConfig:
"""Merge profile defaults with request overrides.
`RunConfig` has value defaults rather than optional fields, so the
merge is intentionally conservative: provider/model identity comes from
the profile, scalar generation fields come from the request, and
`model_params` are shallow-merged with request keys winning.
"""
merged_params = {
**(self.config.model_params or {}),
**(request_config.model_params or {}),
}
return replace(
request_config,
model_name=self.model,
temperature=_profile_default_if_unchanged(
request_config.temperature,
_RUN_CONFIG_DEFAULTS.temperature,
self.config.temperature,
),
max_tokens=_profile_default_if_unchanged(
request_config.max_tokens,
_RUN_CONFIG_DEFAULTS.max_tokens,
self.config.max_tokens,
),
max_depth=_profile_default_if_unchanged(
request_config.max_depth,
_RUN_CONFIG_DEFAULTS.max_depth,
self.config.max_depth,
),
timeout_seconds=_profile_default_if_unchanged(
request_config.timeout_seconds,
_RUN_CONFIG_DEFAULTS.timeout_seconds,
self.config.timeout_seconds,
),
model_params=merged_params,
)
class ProfiledLLMAdapter(LLMAdapter):
"""Adapter wrapper that dispatches named profile requests to adapters."""
def __init__(
self,
default_adapter: LLMAdapter,
profiles: Mapping[str, RuntimeProfile],
*,
adapter_factory: Callable[[str, str], LLMAdapter] | None = None,
strict_profiles: bool = False,
profile_prefixes: tuple[str, ...] = ("custodian-",),
) -> None:
self.default_adapter = default_adapter
self.profiles = dict(profiles)
self.adapter_factory = adapter_factory or _default_adapter_factory
self.strict_profiles = strict_profiles
self.profile_prefixes = profile_prefixes
self._adapters: dict[tuple[str, str], LLMAdapter] = {}
self._lock = threading.Lock()
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
profile = self._resolve_profile(config.model_name)
if profile is None:
return self.default_adapter.execute_prompt(prompt, config)
adapter = self._adapter_for(profile)
resolved_config = profile.resolve_config(config)
response = adapter.execute_prompt(prompt, resolved_config)
response.metadata.setdefault("profile", profile.name)
response.metadata.setdefault("profile_provider", profile.provider)
response.metadata.setdefault("profile_model", profile.model)
return response
async def async_execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
profile = self._resolve_profile(config.model_name)
if profile is None:
return await self.default_adapter.async_execute_prompt(prompt, config)
adapter = self._adapter_for(profile)
resolved_config = profile.resolve_config(config)
response = await adapter.async_execute_prompt(prompt, resolved_config)
response.metadata.setdefault("profile", profile.name)
response.metadata.setdefault("profile_provider", profile.provider)
response.metadata.setdefault("profile_model", profile.model)
return response
def validate_config(self, config: RunConfig) -> bool:
profile = self._resolve_profile(config.model_name)
if profile is None:
return self.default_adapter.validate_config(config)
return self._adapter_for(profile).validate_config(profile.resolve_config(config))
def _resolve_profile(self, model_name: str) -> RuntimeProfile | None:
profile = self.profiles.get(model_name)
if profile is not None:
return profile
if self.strict_profiles or model_name.startswith(self.profile_prefixes):
known = ", ".join(sorted(self.profiles)) or "(none configured)"
raise LLMConfigurationError(
f"Unknown LLM runtime profile {model_name!r}. Known profiles: {known}",
context={"profile": model_name},
)
return None
def _adapter_for(self, profile: RuntimeProfile) -> LLMAdapter:
key = (profile.provider, profile.model)
with self._lock:
adapter = self._adapters.get(key)
if adapter is None:
adapter = self.adapter_factory(profile.provider, profile.model)
self._adapters[key] = adapter
return adapter
def default_runtime_profiles(
*,
provider: str | None = None,
model: str | None = None,
) -> dict[str, RuntimeProfile]:
"""Return built-in runtime profiles, with env/config overrides applied."""
triage_provider = (
os.environ.get("LLM_CONNECT_CUSTODIAN_TRIAGE_PROVIDER")
or provider
or DEFAULT_CUSTODIAN_TRIAGE_PROVIDER
)
triage_model = (
os.environ.get("LLM_CONNECT_CUSTODIAN_TRIAGE_MODEL")
or model
or DEFAULT_CUSTODIAN_TRIAGE_MODEL
)
profiles = {
CUSTODIAN_TRIAGE_BALANCED: RuntimeProfile(
name=CUSTODIAN_TRIAGE_BALANCED,
provider=triage_provider,
model=triage_model,
config=RunConfig(
model_name=triage_model,
temperature=_float_env("LLM_CONNECT_CUSTODIAN_TRIAGE_TEMPERATURE", 0.2),
max_tokens=_int_env("LLM_CONNECT_CUSTODIAN_TRIAGE_MAX_TOKENS", 1800),
max_depth=_int_env("LLM_CONNECT_CUSTODIAN_TRIAGE_MAX_DEPTH", 2),
timeout_seconds=_int_env("LLM_CONNECT_CUSTODIAN_TRIAGE_TIMEOUT_SECONDS", 300),
model_params={
"reasoning_effort": os.environ.get(
"LLM_CONNECT_CUSTODIAN_TRIAGE_REASONING_EFFORT",
"medium",
),
},
),
)
}
profiles.update(load_runtime_profiles_from_env())
return profiles
def load_runtime_profiles_from_env() -> dict[str, RuntimeProfile]:
"""Load optional profile overrides from JSON env/file config."""
raw = os.environ.get("LLM_CONNECT_PROFILES_JSON")
path = os.environ.get("LLM_CONNECT_PROFILE_FILE")
if raw and path:
raise LLMConfigurationError(
"Set only one of LLM_CONNECT_PROFILES_JSON or LLM_CONNECT_PROFILE_FILE",
context={"config": "runtime_profiles"},
)
if path:
try:
raw = Path(path).read_text(encoding="utf-8")
except OSError as exc:
raise LLMConfigurationError(
f"Could not read LLM runtime profile file {path!r}",
cause=exc,
context={"config": "runtime_profiles"},
) from exc
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise LLMConfigurationError(
"LLM runtime profile config must be valid JSON",
cause=exc,
context={"config": "runtime_profiles"},
) from exc
profiles_data = data.get("profiles", data) if isinstance(data, dict) else None
if not isinstance(profiles_data, dict):
raise LLMConfigurationError(
"LLM runtime profile config must be an object keyed by profile name",
context={"config": "runtime_profiles"},
)
return {
name: _profile_from_mapping(name, value)
for name, value in profiles_data.items()
}
def _profile_from_mapping(name: str, value: Any) -> RuntimeProfile:
if not isinstance(value, dict):
raise LLMConfigurationError(
f"Runtime profile {name!r} must be an object",
context={"profile": name},
)
provider = value.get("provider")
model = value.get("model")
if not isinstance(provider, str) or not provider:
raise LLMConfigurationError(
f"Runtime profile {name!r} requires a provider",
context={"profile": name},
)
if not isinstance(model, str) or not model:
raise LLMConfigurationError(
f"Runtime profile {name!r} requires a model",
context={"profile": name},
)
config_data = value.get("config", {})
if not isinstance(config_data, dict):
raise LLMConfigurationError(
f"Runtime profile {name!r} config must be an object",
context={"profile": name},
)
config = RunConfig.from_dict({"model_name": model, **config_data})
return RuntimeProfile(name=name, provider=provider, model=model, config=config)
def _default_adapter_factory(provider: str, model: str) -> LLMAdapter:
return create_adapter(provider, model=model)
def _profile_default_if_unchanged(value: Any, default: Any, profile_value: Any) -> Any:
return profile_value if value == default else value
def _int_env(name: str, default: int) -> int:
value = os.environ.get(name)
if value is None or value == "":
return default
try:
return int(value)
except ValueError as exc:
raise LLMConfigurationError(
f"{name} must be an integer",
cause=exc,
context={"env": name},
) from exc
def _float_env(name: str, default: float) -> float:
value = os.environ.get(name)
if value is None or value == "":
return default
try:
return float(value)
except ValueError as exc:
raise LLMConfigurationError(
f"{name} must be a number",
cause=exc,
context={"env": name},
) from exc

318
llm_connect/quality.py Normal file
View File

@@ -0,0 +1,318 @@
"""Quality observations and append-only ledger support.
These primitives let callers record observed quality/cost outcomes for a
task type without baking consumer-specific routing policy into llm-connect.
"""
from __future__ import annotations
import json
import os
import threading
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterator, TextIO
_PATH_LOCKS: dict[Path, threading.Lock] = {}
_PATH_LOCKS_GUARD = threading.Lock()
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _normalise_datetime(value: datetime | str) -> datetime:
if isinstance(value, datetime):
dt = value
elif isinstance(value, str):
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
else:
raise TypeError(f"Expected datetime or ISO string, got {type(value).__name__}")
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _serialise_datetime(value: datetime) -> str:
return _normalise_datetime(value).isoformat().replace("+00:00", "Z")
def _validate_non_negative_int(name: str, value: int) -> None:
if not isinstance(value, int) or value < 0:
raise ValueError(f"{name} must be a non-negative integer")
def _validate_non_negative_float(name: str, value: float) -> None:
if not isinstance(value, (int, float)) or float(value) < 0:
raise ValueError(f"{name} must be a non-negative number")
def _path_lock(path: Path) -> threading.Lock:
resolved = path.resolve()
with _PATH_LOCKS_GUARD:
lock = _PATH_LOCKS.get(resolved)
if lock is None:
lock = threading.Lock()
_PATH_LOCKS[resolved] = lock
return lock
def _lock_file(handle: TextIO) -> None:
if os.name == "nt":
import msvcrt
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
def _unlock_file(handle: TextIO) -> None:
if os.name == "nt":
import msvcrt
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
@contextmanager
def _locked_file(path: Path, mode: str) -> Iterator[TextIO]:
path.parent.mkdir(parents=True, exist_ok=True)
local_lock = _path_lock(path)
with local_lock:
with path.open(mode, encoding="utf-8") as handle:
_lock_file(handle)
try:
yield handle
finally:
_unlock_file(handle)
@dataclass(frozen=True)
class QualityObservation:
"""Observed quality/cost outcome for one adapter on one task type."""
task_type: str
adapter_id: str
model_id: str
cost_usd: float
quality_score: float
latency_ms: float
tokens_in: int
tokens_out: int
baseline_adapter_id: str | None = None
recorded_at: datetime = field(default_factory=_utc_now)
tags: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
for name in ("task_type", "adapter_id", "model_id"):
if not str(getattr(self, name)).strip():
raise ValueError(f"{name} must be a non-empty string")
_validate_non_negative_float("cost_usd", self.cost_usd)
_validate_non_negative_float("latency_ms", self.latency_ms)
_validate_non_negative_int("tokens_in", self.tokens_in)
_validate_non_negative_int("tokens_out", self.tokens_out)
if not isinstance(self.quality_score, (int, float)):
raise ValueError("quality_score must be a number between 0 and 1")
if not 0 <= float(self.quality_score) <= 1:
raise ValueError("quality_score must be between 0 and 1")
object.__setattr__(self, "task_type", str(self.task_type))
object.__setattr__(self, "adapter_id", str(self.adapter_id))
object.__setattr__(self, "model_id", str(self.model_id))
object.__setattr__(self, "cost_usd", float(self.cost_usd))
object.__setattr__(self, "quality_score", float(self.quality_score))
object.__setattr__(self, "latency_ms", float(self.latency_ms))
object.__setattr__(self, "recorded_at", _normalise_datetime(self.recorded_at))
object.__setattr__(self, "tags", dict(self.tags))
@property
def total_tokens(self) -> int:
"""Return input plus output tokens."""
return self.tokens_in + self.tokens_out
def to_dict(self) -> dict[str, Any]:
"""Convert to a JSON-serialisable dictionary."""
return {
"task_type": self.task_type,
"adapter_id": self.adapter_id,
"model_id": self.model_id,
"cost_usd": self.cost_usd,
"quality_score": self.quality_score,
"latency_ms": self.latency_ms,
"tokens_in": self.tokens_in,
"tokens_out": self.tokens_out,
"baseline_adapter_id": self.baseline_adapter_id,
"recorded_at": _serialise_datetime(self.recorded_at),
"tags": dict(self.tags),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "QualityObservation":
"""Create an observation from a JSON-decoded dictionary."""
return cls(
task_type=data["task_type"],
adapter_id=data["adapter_id"],
model_id=data["model_id"],
cost_usd=data["cost_usd"],
quality_score=data["quality_score"],
latency_ms=data["latency_ms"],
tokens_in=data["tokens_in"],
tokens_out=data["tokens_out"],
baseline_adapter_id=data.get("baseline_adapter_id"),
recorded_at=data.get("recorded_at", _utc_now()),
tags=data.get("tags") or {},
)
def is_stale(
observation: QualityObservation,
max_age: timedelta,
*,
now: datetime | None = None,
) -> bool:
"""Return whether *observation* is older than *max_age*."""
if max_age.total_seconds() < 0:
raise ValueError("max_age must be non-negative")
reference = _normalise_datetime(now or _utc_now())
return observation.recorded_at < reference - max_age
class QualityLedger:
"""Append-only JSONL store for :class:`QualityObservation` records."""
def __init__(self, path: str | Path):
self._path = Path(path)
@property
def path(self) -> Path:
"""Ledger file path."""
return self._path
def append(self, observation: QualityObservation) -> None:
"""Append one observation as a locked JSONL record."""
line = json.dumps(observation.to_dict(), sort_keys=True, separators=(",", ":"))
with _locked_file(self._path, "a") as handle:
handle.write(line + "\n")
handle.flush()
os.fsync(handle.fileno())
def read_all(self) -> list[QualityObservation]:
"""Return all parseable observations, skipping malformed lines."""
observations, _ = self._read_with_malformed_count()
return observations
def malformed_count(self) -> int:
"""Return the number of malformed lines currently skipped by reads."""
_, malformed = self._read_with_malformed_count()
return malformed
def by_task_type(self, task_type: str) -> list[QualityObservation]:
"""Return observations matching *task_type*."""
return [obs for obs in self.read_all() if obs.task_type == task_type]
def recent(
self,
limit: int | None = None,
*,
task_type: str | None = None,
adapter_id: str | None = None,
since: datetime | None = None,
) -> list[QualityObservation]:
"""Return newest observations first, optionally filtered."""
if limit is not None and limit < 0:
raise ValueError("limit must be non-negative")
cutoff = _normalise_datetime(since) if since is not None else None
observations = self.read_all()
if task_type is not None:
observations = [obs for obs in observations if obs.task_type == task_type]
if adapter_id is not None:
observations = [obs for obs in observations if obs.adapter_id == adapter_id]
if cutoff is not None:
observations = [obs for obs in observations if obs.recorded_at >= cutoff]
observations.sort(key=lambda obs: obs.recorded_at, reverse=True)
if limit is None:
return observations
return observations[:limit]
def mean_quality(
self,
task_type: str,
*,
adapter_id: str | None = None,
model_id: str | None = None,
max_age: timedelta | None = None,
min_observations: int = 1,
) -> float | None:
"""Return mean quality for matching observations, or ``None`` if absent."""
if min_observations <= 0:
raise ValueError("min_observations must be positive")
observations = self.by_task_type(task_type)
if adapter_id is not None:
observations = [obs for obs in observations if obs.adapter_id == adapter_id]
if model_id is not None:
observations = [obs for obs in observations if obs.model_id == model_id]
if max_age is not None:
observations = [obs for obs in observations if not is_stale(obs, max_age)]
if len(observations) < min_observations:
return None
return sum(obs.quality_score for obs in observations) / len(observations)
def prune_before(self, timestamp: datetime) -> int:
"""Remove valid observations recorded before *timestamp*.
Malformed lines are preserved because their timestamp cannot be trusted.
Returns the number of valid observation records removed.
"""
cutoff = _normalise_datetime(timestamp)
removed = 0
with _locked_file(self._path, "a+") as handle:
handle.seek(0)
lines = handle.readlines()
kept: list[str] = []
for line in lines:
try:
obs = QualityObservation.from_dict(json.loads(line))
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
kept.append(line)
continue
if obs.recorded_at < cutoff:
removed += 1
else:
kept.append(line)
handle.seek(0)
handle.truncate()
handle.writelines(kept)
handle.flush()
os.fsync(handle.fileno())
return removed
def _read_with_malformed_count(self) -> tuple[list[QualityObservation], int]:
if not self._path.is_file():
return [], 0
observations: list[QualityObservation] = []
malformed = 0
with _locked_file(self._path, "r") as handle:
for line in handle:
if not line.strip():
continue
try:
observations.append(QualityObservation.from_dict(json.loads(line)))
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
malformed += 1
return observations, malformed

273
llm_connect/rates.py Normal file
View File

@@ -0,0 +1,273 @@
"""Model rate registry for preview and post-hoc cost estimation."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
DEFAULT_RATE_SOURCE_URL = "https://openrouter.ai/models"
DEFAULT_RATE_CAPTURED_AT = "2026-05-17"
DEFAULT_RATE_CURRENCY = "USD"
@dataclass(frozen=True)
class ModelRate:
"""USD-denominated list price for one model."""
model_id: str
prompt_per_1k: float
completion_per_1k: float
currency: str = DEFAULT_RATE_CURRENCY
source_url: str = ""
captured_at: str = ""
def __post_init__(self) -> None:
model_id = str(self.model_id).strip()
currency = str(self.currency or DEFAULT_RATE_CURRENCY).strip().upper()
if not model_id:
raise ValueError("model_id must be a non-empty string")
if not currency:
raise ValueError("currency must be a non-empty string")
prompt_rate = _non_negative_float("prompt_per_1k", self.prompt_per_1k)
completion_rate = _non_negative_float("completion_per_1k", self.completion_per_1k)
object.__setattr__(self, "model_id", model_id)
object.__setattr__(self, "prompt_per_1k", prompt_rate)
object.__setattr__(self, "completion_per_1k", completion_rate)
object.__setattr__(self, "currency", currency)
object.__setattr__(self, "source_url", str(self.source_url or ""))
object.__setattr__(self, "captured_at", str(self.captured_at or ""))
class ModelRateRegistry:
"""Lookup table for model list prices."""
def __init__(self, rates: Mapping[str, ModelRate | Mapping[str, Any]] | None = None) -> None:
self._rates: dict[str, ModelRate] = {}
for model_id, rate in (rates or {}).items():
model_rate = _coerce_rate(model_id, rate)
self._rates[model_rate.model_id] = model_rate
def get(self, model_id: str) -> ModelRate | None:
"""Return the rate for *model_id*, or ``None`` when absent."""
return self._rates.get(str(model_id).strip())
def all(self) -> dict[str, ModelRate]:
"""Return a copy of the registry mapping."""
return dict(self._rates)
@classmethod
def default(cls) -> "ModelRateRegistry":
"""Return the bundled OpenRouter list-price snapshot."""
return cls(_default_rate_payload())
@classmethod
def from_yaml(cls, path: Path | str) -> "ModelRateRegistry":
"""Load rates from a YAML file.
The expected shape matches the historic infospace-bench table::
currency: USD
source_url: https://openrouter.ai/models
captured_at: "2026-05-17"
rates:
openai/gpt-4o-mini:
prompt_per_1k: 0.00015
completion_per_1k: 0.00060
PyYAML is used when installed; otherwise a small parser handles this
schema so llm-connect keeps its current lightweight dependency surface.
"""
payload = _load_yaml_mapping(Path(path))
return cls(_rates_from_payload(payload))
def merged_with(self, override: "ModelRateRegistry") -> "ModelRateRegistry":
"""Return a new registry where *override* entries win by model id."""
merged = self.all()
merged.update(override.all())
return ModelRateRegistry(merged)
_DEFAULT_RATES: dict[str, tuple[float, float]] = {
"openai/gpt-4o-mini": (0.00015, 0.00060),
"openai/gpt-4o": (0.0025, 0.01),
"openai/gpt-4-turbo": (0.01, 0.03),
"anthropic/claude-3.5-sonnet": (0.003, 0.015),
"anthropic/claude-3.5-haiku": (0.0008, 0.004),
"anthropic/claude-3-opus": (0.015, 0.075),
"google/gemini-1.5-flash": (0.000075, 0.0003),
"google/gemini-1.5-pro": (0.00125, 0.005),
"meta-llama/llama-3.1-70b-instruct": (0.00059, 0.00079),
}
def _default_rate_payload() -> dict[str, ModelRate]:
return {
model_id: ModelRate(
model_id=model_id,
prompt_per_1k=prompt_rate,
completion_per_1k=completion_rate,
currency=DEFAULT_RATE_CURRENCY,
source_url=DEFAULT_RATE_SOURCE_URL,
captured_at=DEFAULT_RATE_CAPTURED_AT,
)
for model_id, (prompt_rate, completion_rate) in _DEFAULT_RATES.items()
}
def _coerce_rate(model_id: str, rate: ModelRate | Mapping[str, Any]) -> ModelRate:
if isinstance(rate, ModelRate):
return rate
if not isinstance(rate, Mapping):
raise TypeError(f"Rate for {model_id!r} must be a ModelRate or mapping")
return ModelRate(
model_id=str(model_id),
prompt_per_1k=rate["prompt_per_1k"],
completion_per_1k=rate["completion_per_1k"],
currency=str(rate.get("currency") or DEFAULT_RATE_CURRENCY),
source_url=str(rate.get("source_url") or ""),
captured_at=str(rate.get("captured_at") or ""),
)
def _rates_from_payload(payload: Mapping[str, Any]) -> dict[str, ModelRate]:
rates_payload = payload.get("rates")
if not isinstance(rates_payload, Mapping):
raise ValueError("Rate YAML must contain a 'rates' mapping")
currency = str(payload.get("currency") or DEFAULT_RATE_CURRENCY)
source_url = str(payload.get("source_url") or "")
captured_at = str(payload.get("captured_at") or "")
rates: dict[str, ModelRate] = {}
for model_id, raw_rate in rates_payload.items():
if not isinstance(raw_rate, Mapping):
raise ValueError(f"Rate entry for {model_id!r} must be a mapping")
rates[str(model_id)] = ModelRate(
model_id=str(model_id),
prompt_per_1k=raw_rate["prompt_per_1k"],
completion_per_1k=raw_rate["completion_per_1k"],
currency=str(raw_rate.get("currency") or currency),
source_url=str(raw_rate.get("source_url") or source_url),
captured_at=str(raw_rate.get("captured_at") or captured_at),
)
return rates
def _non_negative_float(name: str, value: Any) -> float:
if isinstance(value, bool):
raise ValueError(f"{name} must be a non-negative number")
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{name} must be a non-negative number") from exc
if number < 0:
raise ValueError(f"{name} must be a non-negative number")
return number
def _load_yaml_mapping(path: Path) -> Mapping[str, Any]:
try:
import yaml
except ImportError:
return _parse_rate_yaml(path.read_text(encoding="utf-8"))
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, Mapping):
raise ValueError("Rate YAML root must be a mapping")
return data
def _parse_rate_yaml(text: str) -> dict[str, Any]:
lines: list[tuple[int, str]] = []
for raw_line in text.splitlines():
line = _normalise_yaml_line(raw_line)
if line is not None:
lines.append(line)
data: dict[str, Any] = {}
index = 0
while index < len(lines):
indent, content = lines[index]
if indent != 0:
raise ValueError("Only top-level mappings are supported in rate YAML")
key, raw_value = _split_yaml_key_value(content)
if key == "rates" and raw_value == "":
rates, index = _parse_rates_block(lines, index + 1)
data["rates"] = rates
continue
data[key] = _parse_yaml_scalar(raw_value)
index += 1
return data
def _parse_rates_block(
lines: list[tuple[int, str]],
index: int,
) -> tuple[dict[str, dict[str, Any]], int]:
rates: dict[str, dict[str, Any]] = {}
while index < len(lines):
indent, content = lines[index]
if indent == 0:
break
if indent != 2:
raise ValueError("Rate model entries must be indented by two spaces")
model_id, raw_value = _split_yaml_key_value(content)
if raw_value:
raise ValueError(f"Rate entry for {model_id!r} must be a nested mapping")
entry: dict[str, Any] = {}
index += 1
while index < len(lines):
child_indent, child_content = lines[index]
if child_indent <= indent:
break
if child_indent != 4:
raise ValueError("Rate fields must be indented by four spaces")
child_key, child_value = _split_yaml_key_value(child_content)
entry[child_key] = _parse_yaml_scalar(child_value)
index += 1
rates[model_id] = entry
return rates, index
def _normalise_yaml_line(line: str) -> tuple[int, str] | None:
stripped = _strip_yaml_comment(line.rstrip())
if not stripped.strip():
return None
indent = len(stripped) - len(stripped.lstrip(" "))
return indent, stripped.strip()
def _strip_yaml_comment(line: str) -> str:
quote: str | None = None
for index, char in enumerate(line):
if char in {"'", '"'}:
quote = None if quote == char else char if quote is None else quote
elif char == "#" and quote is None:
return line[:index]
return line
def _split_yaml_key_value(content: str) -> tuple[str, str]:
key, separator, value = content.partition(":")
if not separator:
raise ValueError(f"Invalid YAML mapping line: {content!r}")
return key.strip().strip("'\""), value.strip()
def _parse_yaml_scalar(value: str) -> Any:
if value == "":
return ""
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
):
return value[1:-1]
if value.lower() in {"null", "none", "~"}:
return None
try:
if any(char in value for char in (".", "e", "E")):
return float(value)
return int(value)
except ValueError:
return value

121
llm_connect/replay.py Normal file
View File

@@ -0,0 +1,121 @@
"""Replay llm-connect audit records without making provider calls."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
from llm_connect.claude_code import _unwrap_cli_json_envelope
from llm_connect.models import RunConfig
def parse_audit_record(record: dict[str, Any]) -> dict[str, Any]:
"""Parse the recorded provider response and compare it to saved content."""
config = RunConfig.from_dict(record.get("config", {}))
provider = record.get("provider") or _infer_provider(record)
provider_response = record.get("provider_response") or {}
body = provider_response.get("body")
parsed_content = _parse_provider_response(provider, body, config)
recorded_content = record.get("parsed_content")
schema_check = _check_structured_output(parsed_content, config.model_params.get("json_schema"))
return {
"provider": provider,
"parsed_content": parsed_content,
"matches_recorded_content": parsed_content == recorded_content,
"structured_output": schema_check,
}
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(
prog="python -m llm_connect.replay",
description="Replay parsing for a llm-connect audit JSON file.",
)
parser.add_argument("audit_file", help="Path to an audit JSON file")
parser.add_argument("--json", action="store_true", help="Print the full replay report")
args = parser.parse_args(argv)
record = json.loads(Path(args.audit_file).read_text(encoding="utf-8"))
report = parse_audit_record(record)
if args.json:
print(json.dumps(report, indent=2, sort_keys=True))
else:
print(report["parsed_content"])
def _parse_provider_response(provider: str | None, body: Any, config: RunConfig) -> str:
if provider in {"openai", "openrouter"}:
if isinstance(body, dict):
choice = (body.get("choices") or [{}])[0]
return choice.get("message", {}).get("content", "")
return ""
if provider == "gemini":
if isinstance(body, dict):
candidates = body.get("candidates") or []
if not candidates:
return ""
parts = candidates[0].get("content", {}).get("parts", [])
return "".join(part.get("text", "") for part in parts)
return ""
if provider == "claude-code":
if isinstance(body, dict):
return _unwrap_cli_json_envelope(body.get("stdout", ""), config)
return ""
if isinstance(body, str):
return body
if body is None:
return ""
return json.dumps(body)
def _infer_provider(record: dict[str, Any]) -> str | None:
request = record.get("provider_request") or {}
url = request.get("url", "")
if "openrouter.ai" in url:
return "openrouter"
if "api.openai.com" in url:
return "openai"
if "generativelanguage.googleapis.com" in url:
return "gemini"
if request.get("command"):
return "claude-code"
return None
def _check_structured_output(content: str, schema: Any) -> dict[str, Any]:
if not schema:
return {"checked": False}
if isinstance(schema, str):
try:
schema = json.loads(schema)
except ValueError as exc:
return {"checked": True, "valid": False, "error": f"invalid schema JSON: {exc}"}
if not isinstance(schema, dict):
return {"checked": True, "valid": False, "error": "schema must be an object"}
try:
parsed = json.loads(content)
except ValueError as exc:
return {"checked": True, "valid": False, "error": f"invalid output JSON: {exc}"}
missing = []
if schema.get("type") == "object":
if not isinstance(parsed, dict):
return {"checked": True, "valid": False, "error": "output is not an object"}
for key in schema.get("required", []):
if key not in parsed:
missing.append(key)
if missing:
return {"checked": True, "valid": False, "missing_required": missing}
return {"checked": True, "valid": True}
if __name__ == "__main__":
main()

260
llm_connect/routing.py Normal file
View File

@@ -0,0 +1,260 @@
"""
RoutingPolicy — task-type-aware adapter selection (FR-2).
Maps task types to preferred adapters with optional cost-cap fallback.
"""
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import List, Mapping, Optional
from llm_connect.adapter import LLMAdapter
from llm_connect.quality import QualityLedger, QualityObservation
@dataclass
class RoutingRule:
"""Single routing rule binding a task type to an adapter.
Attributes:
task_type: Logical task identifier (e.g. ``"triage"``, ``"summarise"``).
prefer: Adapter to use when this rule matches.
max_cost_per_1k: Optional cost ceiling (USD per 1 000 tokens). When the
caller supplies ``estimated_cost_per_1k`` to :meth:`RoutingPolicy.resolve`
and it exceeds this cap, *fallback* is returned instead of *prefer*.
fallback: Adapter to use when the cost cap is breached.
"""
task_type: str
prefer: LLMAdapter
max_cost_per_1k: Optional[float] = None
fallback: Optional[LLMAdapter] = None
@dataclass
class RoutingPolicy:
"""Route task types to LLM adapters.
Rules are evaluated in order; the first match wins. When no rule matches,
*default* is returned. If *default* is also absent, ``LookupError`` is raised.
Example::
policy = RoutingPolicy(
rules=[
RoutingRule("triage", prefer=fast_adapter, max_cost_per_1k=0.5, fallback=cheap_adapter),
RoutingRule("analysis", prefer=smart_adapter),
],
default=cheap_adapter,
)
adapter = policy.resolve("triage")
"""
rules: List[RoutingRule] = field(default_factory=list)
default: Optional[LLMAdapter] = None
def resolve(
self,
task_type: str,
estimated_cost_per_1k: Optional[float] = None,
) -> LLMAdapter:
"""Return the adapter for *task_type*.
Args:
task_type: Logical task identifier.
estimated_cost_per_1k: Caller-supplied cost estimate (USD / 1k tokens).
When provided and a matching rule has ``max_cost_per_1k`` set, the
rule's ``fallback`` is returned if the estimate exceeds the cap.
Returns:
The selected :class:`~llm_connect.adapter.LLMAdapter`.
Raises:
LookupError: No matching rule and no *default* configured.
"""
for rule in self.rules:
if rule.task_type == task_type:
if (
estimated_cost_per_1k is not None
and rule.max_cost_per_1k is not None
and estimated_cost_per_1k > rule.max_cost_per_1k
and rule.fallback is not None
):
return rule.fallback
return rule.prefer
if self.default is not None:
return self.default
raise LookupError(
f"No routing rule for task_type={task_type!r} and no default configured"
)
@dataclass(frozen=True)
class _CandidateMetrics:
adapter_id: str
adapter: LLMAdapter
mean_quality: float
mean_cost_usd: float
order: int
is_static_prefer: bool
@dataclass
class AdaptiveRoutingPolicy(RoutingPolicy):
"""Route to the cheapest adapter whose observed quality clears a floor.
The policy consults a :class:`~llm_connect.quality.QualityLedger` for
observations matching ``task_type`` and adapter id. When the ledger has no
qualifying observations, resolution falls through to ``RoutingPolicy`` so a
caller can use the same policy on day zero and after observations accrue.
"""
ledger: Optional[QualityLedger] = None
adapters_by_id: Mapping[str, LLMAdapter] = field(default_factory=dict)
window_size: int = 20
min_observations: int = 1
max_age: Optional[timedelta] = None
def __post_init__(self) -> None:
if self.window_size <= 0:
raise ValueError("window_size must be positive")
if self.min_observations <= 0:
raise ValueError("min_observations must be positive")
if self.max_age is not None and self.max_age.total_seconds() < 0:
raise ValueError("max_age must be non-negative")
def resolve(
self,
task_type: str,
estimated_cost_per_1k: Optional[float] = None,
*,
quality_floor: Optional[float] = None,
) -> LLMAdapter:
"""Return the adaptive adapter for *task_type*.
Args:
task_type: Logical task identifier.
estimated_cost_per_1k: Passed through to static routing fallback.
quality_floor: Minimum observed mean quality required for adaptive
selection. When omitted, static routing is used.
Returns:
The selected :class:`~llm_connect.adapter.LLMAdapter`.
"""
if quality_floor is None or self.ledger is None:
return super().resolve(task_type, estimated_cost_per_1k)
if not 0 <= quality_floor <= 1:
raise ValueError("quality_floor must be between 0 and 1")
metrics = self._qualifying_candidates(task_type, quality_floor)
if not metrics:
return super().resolve(task_type, estimated_cost_per_1k)
best = min(
metrics,
key=lambda candidate: (
candidate.mean_cost_usd,
0 if candidate.is_static_prefer else 1,
candidate.order,
),
)
return best.adapter
def _qualifying_candidates(
self,
task_type: str,
quality_floor: float,
) -> list[_CandidateMetrics]:
static_prefer = self._static_preferred_adapter(task_type)
candidates: list[_CandidateMetrics] = []
for order, (adapter_id, adapter) in enumerate(self._candidate_entries(task_type)):
observations = self._windowed_observations(task_type, adapter_id)
if len(observations) < self.min_observations:
continue
mean_quality = sum(obs.quality_score for obs in observations) / len(observations)
if mean_quality < quality_floor:
continue
mean_cost = sum(obs.cost_usd for obs in observations) / len(observations)
candidates.append(
_CandidateMetrics(
adapter_id=adapter_id,
adapter=adapter,
mean_quality=mean_quality,
mean_cost_usd=mean_cost,
order=order,
is_static_prefer=adapter is static_prefer,
)
)
return candidates
def _windowed_observations(
self,
task_type: str,
adapter_id: str,
) -> list[QualityObservation]:
if self.ledger is None:
return []
since = None
if self.max_age is not None:
since = datetime.now(timezone.utc) - self.max_age
return self.ledger.recent(
limit=self.window_size,
task_type=task_type,
adapter_id=adapter_id,
since=since,
)
def _candidate_entries(self, task_type: str) -> list[tuple[str, LLMAdapter]]:
entries: list[tuple[str, LLMAdapter]] = []
seen_ids: set[str] = set()
def add(adapter_id: str | None, adapter: LLMAdapter | None) -> None:
if adapter is None or adapter_id is None or adapter_id in seen_ids:
return
seen_ids.add(adapter_id)
entries.append((adapter_id, adapter))
for adapter_id, adapter in self.adapters_by_id.items():
add(adapter_id, adapter)
for adapter in self._static_candidate_adapters(task_type):
add(self._adapter_id_for(adapter), adapter)
return entries
def _static_candidate_adapters(self, task_type: str) -> list[LLMAdapter]:
for rule in self.rules:
if rule.task_type == task_type:
candidates = [rule.prefer]
if rule.fallback is not None:
candidates.append(rule.fallback)
if self.default is not None:
candidates.append(self.default)
return candidates
if self.default is not None:
return [self.default]
return []
def _static_preferred_adapter(self, task_type: str) -> LLMAdapter | None:
for rule in self.rules:
if rule.task_type == task_type:
return rule.prefer
return None
def _adapter_id_for(self, adapter: LLMAdapter) -> str | None:
for adapter_id, candidate in self.adapters_by_id.items():
if candidate is adapter:
return adapter_id
for attribute in ("adapter_id", "id", "name"):
value = getattr(adapter, attribute, None)
if isinstance(value, str) and value.strip():
return value
return None

366
llm_connect/server.py Normal file
View File

@@ -0,0 +1,366 @@
"""
Minimal HTTP server for llm_connect — serve mode (FR-1).
Exposes:
POST /execute — run a prompt through the configured adapter
GET /health — liveness probe
Usage (programmatic)::
from llm_connect import MockLLMAdapter
from llm_connect.server import LLMServer
server = LLMServer(adapter=MockLLMAdapter(), port=8080)
server.start() # background thread
# ...
server.stop()
Usage (CLI)::
python -m llm_connect.server --port 8080 --provider openrouter --model google/gemini-2.5-flash
"""
import argparse
import datetime as _dt
import json
import os
import re
import threading
import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Optional
from urllib.parse import parse_qs, urlsplit
from llm_connect._diagnostics import capture_diagnostics
from llm_connect.adapter import LLMAdapter
from llm_connect.exceptions import (
LLMBudgetExceededError,
LLMAPIError,
LLMConfigurationError,
LLMError,
LLMRateLimitError,
LLMTimeoutError,
)
from llm_connect.models import LLMResponse, RunConfig
from llm_connect.profiles import ProfiledLLMAdapter, default_runtime_profiles
class _Handler(BaseHTTPRequestHandler):
"""Request handler — adapter injected via server.adapter."""
def log_message(self, format, *args): # suppress default access log
pass
# ── GET ────────────────────────────────────────────────────────
def do_GET(self):
parsed = urlsplit(self.path)
if parsed.path == "/health":
self._respond(200, {"status": "ok"})
else:
self._respond(404, {"error": "not found"})
# ── POST ───────────────────────────────────────────────────────
def do_POST(self):
parsed = urlsplit(self.path)
if parsed.path != "/execute":
self._respond(404, {"error": "not found"})
return
debug_enabled = _debug_requested(parsed.query)
audit_dir = os.environ.get("LLM_CONNECT_AUDIT_DIR")
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length)
try:
data = json.loads(raw)
except (json.JSONDecodeError, ValueError):
self._respond(400, {"error": "invalid JSON body"})
return
prompt = data.get("prompt")
if not prompt:
self._respond(400, {"error": "missing required field: 'prompt'"})
return
cfg = data.get("config", {})
if not isinstance(cfg, dict):
self._respond(400, {"error": "field 'config' must be an object"})
return
config = RunConfig.from_dict(cfg)
start = time.time()
diagnostics_enabled = debug_enabled or bool(audit_dir)
try:
with capture_diagnostics(diagnostics_enabled) as diagnostics:
adapter = self.server.adapter # type: ignore[attr-defined]
if not adapter.validate_config(config):
raise LLMConfigurationError(
"Adapter rejected RunConfig",
context={"model_name": config.model_name},
)
response = adapter.execute_prompt(prompt, config)
latency = time.time() - start
body = response.to_dict()
debug = diagnostics.to_dict() if diagnostics is not None else None
if debug_enabled and debug is not None:
body["debug"] = debug
if audit_dir:
_write_audit_record(audit_dir, prompt, config, response, debug, latency)
self._respond(200, body)
except Exception as exc:
status, body = _error_response(exc)
self._respond(status, body)
# ── helpers ────────────────────────────────────────────────────
def _respond(self, status: int, body: dict) -> None:
payload = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
class LLMServer:
"""HTTP server wrapping an :class:`~llm_connect.adapter.LLMAdapter`.
Args:
adapter: The adapter that handles ``POST /execute`` requests.
host: Bind address (default ``"127.0.0.1"``).
port: TCP port (default ``8080``; ``0`` picks a free port).
"""
def __init__(
self,
adapter: LLMAdapter,
host: str = "127.0.0.1",
port: int = 8080,
) -> None:
self._httpd = ThreadingHTTPServer((host, port), _Handler)
self._httpd.adapter = adapter # type: ignore[attr-defined]
self._thread: Optional[threading.Thread] = None
@property
def port(self) -> int:
"""Actual bound port (useful when ``port=0`` was requested)."""
return self._httpd.server_address[1]
@property
def host(self) -> str:
return self._httpd.server_address[0]
def start(self) -> None:
"""Start serving in a daemon background thread."""
self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
self._thread.start()
def stop(self) -> None:
"""Shut down the server and join the background thread."""
self._httpd.shutdown()
if self._thread is not None:
self._thread.join()
def serve_forever(self) -> None:
"""Block the calling thread until interrupted."""
self._httpd.serve_forever()
# ── CLI entry point ────────────────────────────────────────────────────────────
def _build_adapter(
provider: str,
model: Optional[str],
*,
enable_profiles: bool = True,
strict_profiles: bool = False,
) -> LLMAdapter:
from llm_connect.factory import create_adapter
adapter = create_adapter(provider, model=model)
if not enable_profiles:
return adapter
return ProfiledLLMAdapter(
adapter,
default_runtime_profiles(provider=provider, model=model),
strict_profiles=strict_profiles,
)
def _debug_requested(query: str) -> bool:
env = os.environ.get("LLM_CONNECT_DEBUG", "")
if _truthy(env):
return True
values = parse_qs(query).get("debug", [])
return any(_truthy(value) for value in values)
def _truthy(value: str) -> bool:
return value.strip().lower() in {"1", "true", "yes", "on"}
def _error_response(exc: Exception) -> tuple[int, dict]:
"""Map exceptions to operator-useful, secret-safe server responses."""
if isinstance(exc, LLMRateLimitError):
body = _error_body("provider_rate_limited", exc)
body["provider_status"] = exc.status_code
return 429, body
if isinstance(exc, LLMTimeoutError):
return 504, _error_body("provider_timeout", exc)
if isinstance(exc, LLMAPIError):
body = _error_body("provider_api_error", exc)
if exc.status_code:
body["provider_status"] = exc.status_code
return 502, body
if isinstance(exc, LLMBudgetExceededError):
return 400, _error_body("budget_exceeded", exc)
if isinstance(exc, LLMConfigurationError):
if _message(exc).startswith("Unknown LLM runtime profile"):
return 400, _error_body("unknown_profile", exc)
return 500, _error_body("configuration_error", exc)
if isinstance(exc, LLMError):
return 500, _error_body("llm_error", exc)
return 500, _error_body("internal_error", exc)
def _error_body(code: str, exc: Exception) -> dict:
body = {
"error": code,
"message": _sanitize_text(_message(exc)),
"type": exc.__class__.__name__,
}
context = getattr(exc, "context", None)
if isinstance(context, dict):
safe_context = _safe_context(context)
if safe_context:
body["context"] = safe_context
return body
def _message(exc: Exception) -> str:
if exc.args:
return str(exc.args[0])
return str(exc)
def _safe_context(context: dict) -> dict:
safe = {}
for key, value in context.items():
lowered = str(key).lower()
if any(secret_word in lowered for secret_word in ("key", "secret", "token", "password")):
safe[key] = "<redacted>"
elif isinstance(value, (str, int, float, bool)) or value is None:
safe[key] = _sanitize_text(str(value)) if isinstance(value, str) else value
else:
safe[key] = _sanitize_text(str(value))
return safe
def _sanitize_text(value: str) -> str:
value = re.sub(r"Bearer\s+[A-Za-z0-9._~+/=-]+", "Bearer <redacted>", value)
value = re.sub(r"([?&]key=)[^&\s]+", r"\1<redacted>", value)
value = re.sub(r"\bsk-[A-Za-z0-9_-]{8,}", "sk-<redacted>", value)
value = re.sub(
r"(?i)(api[_-]?key|token|secret|password)=([^,\s\]]+)",
r"\1=<redacted>",
value,
)
return value
def _write_audit_record(
audit_dir: str,
prompt: str,
config: RunConfig,
response: LLMResponse,
debug: dict | None,
latency_seconds: float,
) -> None:
target_dir = Path(audit_dir)
target_dir.mkdir(parents=True, exist_ok=True)
now = _dt.datetime.now(_dt.timezone.utc)
response_id = str(response.metadata.get("response_id") or uuid.uuid4().hex)
filename = f"{now.strftime('%Y%m%dT%H%M%S%fZ')}-{_safe_filename(response_id)}.json"
diagnostics = debug or {}
record = {
"timestamp": now.isoformat().replace("+00:00", "Z"),
"prompt": prompt,
"config": config.to_dict(),
"provider": response.metadata.get("provider"),
"provider_request": diagnostics.get("provider_request"),
"provider_response": diagnostics.get("provider_response"),
"adapter_transformations": diagnostics.get("adapter_transformations", []),
"parsed_content": response.content,
"latency_seconds": round(latency_seconds, 3),
"response": response.to_dict(),
}
(target_dir / filename).write_text(
json.dumps(record, indent=2, sort_keys=True),
encoding="utf-8",
)
def _safe_filename(value: str) -> str:
return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-") or "response"
def main(argv=None) -> None:
parser = argparse.ArgumentParser(
prog="python -m llm_connect.server",
description="Start llm_connect HTTP serve mode.",
)
parser.add_argument(
"--port",
type=int,
default=int(os.environ.get("LLM_CONNECT_PORT", "8080")),
help="TCP port (default: env LLM_CONNECT_PORT or 8080)",
)
parser.add_argument(
"--host",
default=os.environ.get("LLM_CONNECT_HOST", "127.0.0.1"),
help="Bind address (default: env LLM_CONNECT_HOST or 127.0.0.1)",
)
parser.add_argument(
"--provider",
default=os.environ.get("LLM_CONNECT_PROVIDER", "mock"),
help="Provider name passed to create_adapter (default: env LLM_CONNECT_PROVIDER or mock)",
)
parser.add_argument(
"--model",
default=os.environ.get("LLM_CONNECT_MODEL") or None,
help="Model name (default: env LLM_CONNECT_MODEL, optional)",
)
parser.add_argument(
"--disable-profiles",
action="store_true",
help="Disable server runtime profile dispatch.",
)
parser.add_argument(
"--strict-profiles",
action="store_true",
default=_truthy(os.environ.get("LLM_CONNECT_STRICT_PROFILES", "")),
help="Reject non-profile model_name values instead of passing them through.",
)
args = parser.parse_args(argv)
adapter = _build_adapter(
args.provider,
args.model,
enable_profiles=not args.disable_profiles,
strict_profiles=args.strict_profiles,
)
server = LLMServer(adapter=adapter, host=args.host, port=args.port)
print(f"llm_connect server listening on http://{args.host}:{args.port}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down.")
if __name__ == "__main__":
main()

177
llm_connect/shadowing.py Normal file
View File

@@ -0,0 +1,177 @@
"""Shadow-mode observation adapter for adaptive routing."""
from __future__ import annotations
import asyncio
import random
import threading
from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import dataclass, field, replace
from typing import Any, Callable, Mapping
from llm_connect.adapter import LLMAdapter
from llm_connect.grading import BaselineGrader
from llm_connect.models import LLMResponse, RunConfig
from llm_connect.quality import QualityLedger, QualityObservation
def _default_cost_estimator(response: LLMResponse) -> float:
for key in ("cost_usd", "estimated_cost_usd", "cost"):
value = response.metadata.get(key)
if isinstance(value, (int, float)) and value >= 0:
return float(value)
return 0.0
class _StaticResponseAdapter(LLMAdapter):
"""Adapter shim that lets a BaselineGrader reuse an existing response."""
def __init__(self, response: LLMResponse):
self._response = response
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
return self._response
def validate_config(self, config: RunConfig) -> bool:
return True
@dataclass
class ShadowingAdapter(LLMAdapter):
"""Return candidate responses while recording sampled baseline grades.
Shadow work is best-effort: baseline, grading, or ledger failures are
reported to ``on_shadow_error`` when provided, but never alter the candidate
response returned to the caller.
"""
candidate_adapter: LLMAdapter
baseline_adapter: LLMAdapter
grader: BaselineGrader
ledger: QualityLedger
task_type: str
adapter_id: str
model_id: str | None = None
baseline_adapter_id: str | None = None
shadow_rate: float = 1.0
async_shadow: bool = False
random_source: random.Random = field(default_factory=random.Random, repr=False)
cost_estimator: Callable[[LLMResponse], float] = _default_cost_estimator
tags: Mapping[str, Any] = field(default_factory=dict)
on_shadow_error: Callable[[Exception], None] | None = None
_executor: ThreadPoolExecutor | None = field(default=None, init=False, repr=False)
_futures: list[Future[None]] = field(default_factory=list, init=False, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
def __post_init__(self) -> None:
if not str(self.task_type).strip():
raise ValueError("task_type must be a non-empty string")
if not str(self.adapter_id).strip():
raise ValueError("adapter_id must be a non-empty string")
if not 0 <= self.shadow_rate <= 1:
raise ValueError("shadow_rate must be between 0 and 1")
if self.async_shadow:
self._executor = ThreadPoolExecutor(max_workers=1)
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
response = self.candidate_adapter.execute_prompt(prompt, config)
if self._should_shadow():
self._handle_shadow(prompt, config, response)
return response
async def async_execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
response = await self.candidate_adapter.async_execute_prompt(prompt, config)
if self._should_shadow():
if self.async_shadow:
self._schedule_shadow(prompt, config, response)
else:
await asyncio.to_thread(self._run_shadow, prompt, config, response)
return response
def validate_config(self, config: RunConfig) -> bool:
return self.candidate_adapter.validate_config(config)
def flush(self, timeout: float | None = None) -> None:
"""Wait for currently queued async shadow work to finish."""
with self._lock:
futures = list(self._futures)
self._futures.clear()
for future in futures:
future.result(timeout=timeout)
def shutdown(self, wait: bool = True) -> None:
"""Shut down the background shadow executor if one was created."""
if self._executor is not None:
self._executor.shutdown(wait=wait)
self._executor = None
def _should_shadow(self) -> bool:
if self.shadow_rate <= 0:
return False
if self.shadow_rate >= 1:
return True
with self._lock:
return self.random_source.random() < self.shadow_rate
def _handle_shadow(
self,
prompt: str,
config: RunConfig,
candidate_response: LLMResponse,
) -> None:
if self.async_shadow:
self._schedule_shadow(prompt, config, candidate_response)
else:
self._run_shadow(prompt, config, candidate_response)
def _schedule_shadow(
self,
prompt: str,
config: RunConfig,
candidate_response: LLMResponse,
) -> None:
if self._executor is None:
self._executor = ThreadPoolExecutor(max_workers=1)
future = self._executor.submit(self._run_shadow, prompt, config, candidate_response)
with self._lock:
self._futures = [item for item in self._futures if not item.done()]
self._futures.append(future)
def _run_shadow(
self,
prompt: str,
config: RunConfig,
candidate_response: LLMResponse,
) -> None:
try:
shadow_config = replace(config, budget_tracker=None)
result = self.grader.grade(
self.baseline_adapter,
_StaticResponseAdapter(candidate_response),
prompt,
shadow_config,
)
self.ledger.append(
QualityObservation(
task_type=self.task_type,
adapter_id=self.adapter_id,
model_id=self.model_id or candidate_response.model or config.model_name,
cost_usd=self.cost_estimator(candidate_response),
quality_score=result.quality_score,
latency_ms=float(candidate_response.metadata.get("latency_ms", 0.0)),
tokens_in=int(candidate_response.usage.get("prompt_tokens", 0)),
tokens_out=int(candidate_response.usage.get("completion_tokens", 0)),
baseline_adapter_id=self.baseline_adapter_id,
tags=dict(self.tags),
)
)
except Exception as exc:
self._report_shadow_error(exc)
def _report_shadow_error(self, exc: Exception) -> None:
if self.on_shadow_error is None:
return
try:
self.on_shadow_error(exc)
except Exception:
pass

View File

@@ -11,12 +11,17 @@ dependencies = [
"toml", "toml",
] ]
[project.scripts]
llm-connect = "llm_connect.cli:main"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=7.0", "pytest>=7.0",
"ruff>=0.4", "ruff>=0.4",
"mypy>=1.10", "mypy>=1.10",
] ]
# serve mode uses stdlib http.server — no additional runtime dependency required
server = []
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]

12
registry/README.md Normal file
View File

@@ -0,0 +1,12 @@
# Capability Registry
Markdown-first capability index for federation and reuse planning.
## Authoring
1. Copy a capability entry template (see reuse-surface `templates/capability-entry.template.md`).
2. Add the row to `indexes/capabilities.yaml`.
3. Run `reuse-surface validate` from a checkout with the CLI installed.
4. Merge to `main` and verify publish with `reuse-surface establish --publish-check`.
Federation contract: reuse-surface `docs/RegistryFederation.md`.

View File

View File

@@ -0,0 +1,123 @@
---
id: capability.agents.llm-connector
name: Pluggable LLM Adapter Library (llm-connect)
summary: Provider-neutral Python/CLI LLM adapter library supporting OpenRouter, Gemini, OpenAI, and the
Claude Code CLI out of the box, with a clean abstract interface for adding new providers.
owner: llm-connect
status: draft
domain: agents
tags:
- llm
- adapter
- provider-neutral
maturity:
discovery:
current: D3
target: D5
confidence: medium
rationale: README documents a working quick-start (`create_adapter`, `RunConfig`, `execute_prompt`),
the supported provider list, and both local-editable and future-published install paths.
availability:
current: A2
target: A3
confidence: medium
rationale: Installable today via `pip install -e` (local) with a documented future `pip install llm-connect`
path once published; consumed today by sibling repos including `cya` (can-you-assist) and referenced
as the intended backend for reuse-surface's own `establish --discover`.
external_evidence:
completeness:
level: C2
confidence: low
basis: scope_vs_intent_and_consumer_expectations
satisfied_expectations:
- four providers implemented (OpenRouter, Gemini, OpenAI, Claude Code CLI)
- abstract adapter interface for adding new providers
- already a named integration point for other repos (can-you-assist's LLMAdapter seam, reuse-surface's
establish --discover)
broken_expectations: []
out_of_scope_expectations: []
reliability:
level: R1
confidence: low
basis: consumer_quality_signals
known_reliability_risks:
- no llm-connect instance was running on this workstation during the REUSE-WP-0017 sweep, so establish
--discover fell back to manual authoring for this campaign
discovery:
intent: Give Python and CLI consumers a single, provider-neutral way to call LLMs, decoupling agent/tool
code from any one vendor's SDK.
includes:
- provider-neutral adapter interface
- OpenRouter, Gemini, OpenAI, Claude Code CLI adapters
- RunConfig-based execution
excludes:
- hosting or credential custody for providers (routes to OpenBao/operator paths per credential-routing
conventions)
assumptions: []
use_cases: []
research_memos: []
availability:
current_level: A2
target_level: A3
current_artifacts:
- Python package (`llm_connect`)
target_artifacts: []
consumption_modes:
- library import
- cli
relations:
depends_on: []
supports: []
related_to: []
evidence:
documentation:
- README.md
tests:
- tests/
consumer_feedback: []
bug_reports: []
incidents: []
consumer_guidance:
recommended_for:
- any repo needing provider-neutral LLM calls instead of a vendor-specific SDK
not_recommended_for:
- needs requiring a provider not yet adapted (extend via the abstract interface first)
known_limitations:
- not yet published to a package index; local editable install only
promotion_history: []
---
# Pluggable LLM Adapter Library (llm-connect)
## Overview
`llm-connect` provides pluggable, provider-neutral LLM adapters for Python and the command line, supporting OpenRouter, Gemini, OpenAI, and the Claude Code CLI out of the box behind a clean abstract interface, and is already the intended backend for several sibling repos including `can-you-assist` and `reuse-surface`'s own drafting tooling.
## Assessment notes
### Discovery
README documents a working quick-start (`create_adapter`, `RunConfig`, `execute_prompt`), the supported provider list, and both local-editable and future-published install paths.
### Availability
Installable today via `pip install -e` (local) with a documented future `pip install llm-connect` path once published; consumed today by sibling repos including `cya` (can-you-assist) and referenced as the intended backend for reuse-surface's own `establish --discover`.
### Completeness
First-pass honest assessment from the REUSE-WP-0017 coverage campaign
(reuse-surface). No external consumer feedback exists yet; levels reflect
scope-vs-intent documentation quality, not internal code quality.
### Reliability
No production consumer telemetry exists yet; reliability level is
intentionally conservative pending REUSE-WP-0019 reuse-telemetry evidence.
## Promotion checklist
- [x] ID follows `capability.<domain>.<name>` pattern
- [x] Maturity enums match `specs/CapabilityMaturityStandard.md`
- [x] `external_evidence` is populated separately from `maturity`
- [ ] Relations reference valid capability IDs (none yet)
- [x] Index entry added in `registry/indexes/capabilities.yaml`

View File

@@ -0,0 +1,20 @@
version: 1
updated: '2026-07-06'
domain: helix_forge
capabilities:
- id: capability.agents.llm-connector
name: Pluggable LLM Adapter Library (llm-connect)
summary: Provider-neutral Python/CLI LLM adapter library supporting OpenRouter, Gemini, OpenAI, and
the Claude Code CLI out of the box, with a clean abstract interface for adding new providers.
vector: D3 / A2 / C2 / R1
domain: agents
status: draft
owner: llm-connect
path: registry/capabilities/capability.agents.llm-connector.md
tags:
- llm
- adapter
- provider-neutral
consumption_modes:
- library import
- cli

View File

@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""Smoke-test the activity-core llm-connect endpoint contract."""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_REQUEST = ROOT / "fixtures" / "activity_core" / "daily-triage-execute-request.json"
DEFAULT_SCHEMA = ROOT / "fixtures" / "activity_core" / "daily-triage-report.schema.json"
class SmokeError(RuntimeError):
pass
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Validate /health, /execute, and daily triage JSON content.",
)
parser.add_argument(
"--url",
default=os.environ.get("LLM_CONNECT_URL", "http://127.0.0.1:8080"),
help="Base llm-connect URL (default: env LLM_CONNECT_URL or localhost:8080)",
)
parser.add_argument("--request", type=Path, default=DEFAULT_REQUEST)
parser.add_argument("--schema", type=Path, default=DEFAULT_SCHEMA)
parser.add_argument(
"--timeout",
type=float,
default=float(os.environ.get("LLM_CONNECT_TIMEOUT_SECONDS", "300")),
help="HTTP timeout in seconds (default: env LLM_CONNECT_TIMEOUT_SECONDS or 300)",
)
parser.add_argument("--skip-health", action="store_true")
args = parser.parse_args(argv)
try:
result = run_smoke(
base_url=args.url,
request_path=args.request,
schema_path=args.schema,
timeout=args.timeout,
check_health=not args.skip_health,
)
except SmokeError as exc:
print(f"smoke: fail: {exc}", file=sys.stderr)
return 1
print(
"smoke: pass "
f"health={result['health']} "
f"latency_seconds={result['latency_seconds']:.3f} "
f"recommendations={result['recommendations']}"
)
return 0
def run_smoke(
*,
base_url: str,
request_path: Path,
schema_path: Path,
timeout: float,
check_health: bool = True,
) -> dict[str, Any]:
base = base_url.rstrip("/")
if check_health:
health = _get_json(f"{base}/health", timeout=timeout)
if health.get("status") != "ok":
raise SmokeError("/health did not return status=ok")
health_status = "ok"
else:
health_status = "skipped"
request_body = _load_json(request_path)
schema = _load_json(schema_path)
start = time.monotonic()
response = _post_json(f"{base}/execute", request_body, timeout=timeout)
latency = time.monotonic() - start
content = response.get("content")
if not isinstance(content, str):
raise SmokeError("/execute response did not include a string content field")
try:
content_json = json.loads(content)
except json.JSONDecodeError as exc:
raise SmokeError(f"content was not valid JSON: {exc}") from exc
errors = validate_json_schema(content_json, schema)
if errors:
raise SmokeError("content schema validation failed: " + "; ".join(errors[:5]))
return {
"health": health_status,
"latency_seconds": latency,
"recommendations": len(content_json.get("recommendations", [])),
}
def validate_json_schema(instance: Any, schema: dict[str, Any]) -> list[str]:
"""Validate the subset of JSON Schema used by the activity-core fixture."""
errors: list[str] = []
_validate(instance, schema, "$", errors)
return errors
def _validate(instance: Any, schema: dict[str, Any], path: str, errors: list[str]) -> None:
expected_type = schema.get("type")
if expected_type and not _matches_type(instance, expected_type):
errors.append(f"{path}: expected {expected_type}, got {type(instance).__name__}")
return
if "enum" in schema and instance not in schema["enum"]:
errors.append(f"{path}: value {instance!r} not in enum")
if expected_type == "object":
assert isinstance(instance, dict)
required = schema.get("required", [])
for key in required:
if key not in instance:
errors.append(f"{path}: missing required property {key!r}")
properties = schema.get("properties", {})
if schema.get("additionalProperties") is False:
for key in instance:
if key not in properties:
errors.append(f"{path}: unexpected property {key!r}")
for key, subschema in properties.items():
if key in instance and isinstance(subschema, dict):
_validate(instance[key], subschema, f"{path}.{key}", errors)
return
if expected_type == "array":
assert isinstance(instance, list)
min_items = schema.get("minItems")
max_items = schema.get("maxItems")
if isinstance(min_items, int) and len(instance) < min_items:
errors.append(f"{path}: expected at least {min_items} items")
if isinstance(max_items, int) and len(instance) > max_items:
errors.append(f"{path}: expected at most {max_items} items")
item_schema = schema.get("items")
if isinstance(item_schema, dict):
for index, item in enumerate(instance):
_validate(item, item_schema, f"{path}[{index}]", errors)
return
if expected_type in {"integer", "number"}:
minimum = schema.get("minimum")
maximum = schema.get("maximum")
if isinstance(minimum, (int, float)) and instance < minimum:
errors.append(f"{path}: expected >= {minimum}")
if isinstance(maximum, (int, float)) and instance > maximum:
errors.append(f"{path}: expected <= {maximum}")
def _matches_type(instance: Any, expected_type: str) -> bool:
if expected_type == "object":
return isinstance(instance, dict)
if expected_type == "array":
return isinstance(instance, list)
if expected_type == "string":
return isinstance(instance, str)
if expected_type == "integer":
return isinstance(instance, int) and not isinstance(instance, bool)
if expected_type == "number":
return isinstance(instance, (int, float)) and not isinstance(instance, bool)
if expected_type == "boolean":
return isinstance(instance, bool)
if expected_type == "null":
return instance is None
return True
def _load_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise SmokeError(f"could not load JSON from {path}: {exc}") from exc
def _get_json(url: str, *, timeout: float) -> dict[str, Any]:
try:
with urllib.request.urlopen(url, timeout=timeout) as response:
return _decode_json(response.read())
except urllib.error.HTTPError as exc:
raise SmokeError(f"GET /health returned HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise SmokeError(f"GET /health failed: {exc.reason}") from exc
def _post_json(url: str, body: dict[str, Any], *, timeout: float) -> dict[str, Any]:
request = urllib.request.Request(
url,
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return _decode_json(response.read())
except urllib.error.HTTPError as exc:
try:
error_body = _decode_json(exc.read())
code = error_body.get("error", "unknown_error")
message = error_body.get("message", "")
detail = f"{code}: {message}" if message else code
except SmokeError:
detail = "non-JSON error body"
raise SmokeError(f"POST /execute returned HTTP {exc.code}: {detail}") from exc
except urllib.error.URLError as exc:
raise SmokeError(f"POST /execute failed: {exc.reason}") from exc
def _decode_json(data: bytes) -> dict[str, Any]:
try:
decoded = json.loads(data.decode())
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise SmokeError(f"response was not JSON: {exc}") from exc
if not isinstance(decoded, dict):
raise SmokeError("response JSON was not an object")
return decoded
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,92 @@
import importlib.util
import json
from pathlib import Path
from llm_connect.adapter import MockLLMAdapter
from llm_connect.models import RunConfig
from llm_connect.profiles import CUSTODIAN_TRIAGE_BALANCED, ProfiledLLMAdapter, RuntimeProfile
from llm_connect.server import LLMServer
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "smoke_activity_core_endpoint.py"
FIXTURE_DIR = ROOT / "fixtures" / "activity_core"
def _load_smoke_module():
spec = importlib.util.spec_from_file_location("smoke_activity_core_endpoint", SCRIPT)
assert spec is not None
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def test_daily_triage_fixture_content_matches_schema():
smoke = _load_smoke_module()
schema = json.loads((FIXTURE_DIR / "daily-triage-report.schema.json").read_text())
content = json.loads((FIXTURE_DIR / "daily-triage-valid-content.json").read_text())
assert smoke.validate_json_schema(content, schema) == []
def test_daily_triage_execute_request_embeds_schema_and_profile_config():
request = json.loads((FIXTURE_DIR / "daily-triage-execute-request.json").read_text())
schema = json.loads((FIXTURE_DIR / "daily-triage-report.schema.json").read_text())
config = request["config"]
assert request["prompt"]
assert config["model_name"] == "custodian-triage-balanced"
assert config["temperature"] == 0.2
assert config["max_tokens"] == 1800
assert config["max_depth"] == 2
assert config["timeout_seconds"] == 300
assert config["model_params"]["reasoning_effort"] == "medium"
assert config["model_params"]["json_schema"] == schema
def test_schema_validator_reports_missing_required_field():
smoke = _load_smoke_module()
schema = json.loads((FIXTURE_DIR / "daily-triage-report.schema.json").read_text())
invalid = {"summary": "missing recommendations"}
errors = smoke.validate_json_schema(invalid, schema)
assert "$: missing required property 'recommendations'" in errors
def test_run_smoke_against_profiled_mock_server():
smoke = _load_smoke_module()
valid_content = (FIXTURE_DIR / "daily-triage-valid-content.json").read_text()
def factory(provider: str, model: str) -> MockLLMAdapter:
assert provider == "mock"
assert model == "triage-model"
return MockLLMAdapter(mock_response=valid_content)
adapter = ProfiledLLMAdapter(
MockLLMAdapter(mock_response=valid_content),
{
CUSTODIAN_TRIAGE_BALANCED: RuntimeProfile(
name=CUSTODIAN_TRIAGE_BALANCED,
provider="mock",
model="triage-model",
config=RunConfig(model_name="triage-model"),
)
},
adapter_factory=factory,
)
server = LLMServer(adapter=adapter, port=0)
server.start()
try:
result = smoke.run_smoke(
base_url=f"http://127.0.0.1:{server.port}",
request_path=FIXTURE_DIR / "daily-triage-execute-request.json",
schema_path=FIXTURE_DIR / "daily-triage-report.schema.json",
timeout=3,
)
finally:
server.stop()
assert result["health"] == "ok"
assert result["recommendations"] == 1

View File

@@ -0,0 +1,109 @@
"""
Integration coverage for the adaptive routing workplan flow.
"""
from datetime import datetime, timezone
from examples.adaptive_routing_fixture_batch import populate_ledger
from llm_connect.adapter import MockLLMAdapter
from llm_connect.quality import QualityLedger, QualityObservation
from llm_connect.routing import AdaptiveRoutingPolicy, RoutingRule
def append_quality(
ledger: QualityLedger,
adapter_id: str,
quality_score: float,
cost_usd: float,
*,
recorded_at: datetime,
) -> None:
ledger.append(
QualityObservation(
task_type="summarize",
adapter_id=adapter_id,
model_id=f"{adapter_id}-model",
cost_usd=cost_usd,
quality_score=quality_score,
latency_ms=100,
tokens_in=100,
tokens_out=50,
recorded_at=recorded_at,
baseline_adapter_id="baseline",
)
)
def test_adaptive_policy_converges_to_cheapest_qualifying_adapter(tmp_path):
cheap = MockLLMAdapter("cheap")
mid = MockLLMAdapter("mid")
smart = MockLLMAdapter("smart")
ledger = QualityLedger(tmp_path / "quality.jsonl")
policy = AdaptiveRoutingPolicy(
rules=[
RoutingRule(
"summarize",
prefer=smart,
max_cost_per_1k=1.0,
fallback=mid,
)
],
ledger=ledger,
adapters_by_id={"cheap": cheap, "mid": mid, "smart": smart},
window_size=2,
)
assert policy.resolve("summarize", quality_floor=0.8) is smart
assert policy.resolve("summarize", 2.0, quality_floor=0.8) is mid
append_quality(
ledger,
"cheap",
quality_score=0.7,
cost_usd=0.01,
recorded_at=datetime(2026, 5, 17, 10, tzinfo=timezone.utc),
)
append_quality(
ledger,
"mid",
quality_score=0.86,
cost_usd=0.02,
recorded_at=datetime(2026, 5, 17, 10, tzinfo=timezone.utc),
)
append_quality(
ledger,
"smart",
quality_score=0.95,
cost_usd=0.05,
recorded_at=datetime(2026, 5, 17, 10, tzinfo=timezone.utc),
)
assert policy.resolve("summarize", quality_floor=0.8) is mid
append_quality(
ledger,
"cheap",
quality_score=0.95,
cost_usd=0.01,
recorded_at=datetime(2026, 5, 17, 11, tzinfo=timezone.utc),
)
assert policy.resolve("summarize", quality_floor=0.8) is cheap
def test_fixture_batch_populates_three_candidate_observations_per_task(tmp_path):
ledger = QualityLedger(tmp_path / "quality.jsonl")
populate_ledger(ledger)
observations = ledger.read_all()
by_task_type: dict[str, set[str]] = {}
for observation in observations:
by_task_type.setdefault(observation.task_type, set()).add(observation.adapter_id)
assert set(by_task_type) == {
"summarize-source",
"extract-relations",
"evaluate-entity",
}
assert all(len(adapter_ids) == 3 for adapter_ids in by_task_type.values())

View File

@@ -0,0 +1,181 @@
"""
Tests for AdaptiveRoutingPolicy.
"""
from datetime import datetime, timedelta, timezone
from llm_connect.adapter import MockLLMAdapter
from llm_connect.quality import QualityLedger, QualityObservation
from llm_connect.routing import AdaptiveRoutingPolicy, RoutingRule
def append_observation(
ledger: QualityLedger,
*,
adapter_id: str,
quality_score: float,
cost_usd: float,
task_type: str = "summarize",
recorded_at: datetime | None = None,
) -> None:
ledger.append(
QualityObservation(
task_type=task_type,
adapter_id=adapter_id,
model_id=f"{adapter_id}-model",
cost_usd=cost_usd,
quality_score=quality_score,
latency_ms=100,
tokens_in=100,
tokens_out=50,
baseline_adapter_id="baseline",
recorded_at=recorded_at or datetime(2026, 5, 17, tzinfo=timezone.utc),
)
)
class TestAdaptiveRoutingPolicy:
def _adapter(self, name: str) -> MockLLMAdapter:
return MockLLMAdapter(mock_response=name)
def test_selects_cheapest_adapter_that_clears_quality_floor(self, tmp_path):
cheap = self._adapter("cheap")
smart = self._adapter("smart")
ledger = QualityLedger(tmp_path / "quality.jsonl")
append_observation(ledger, adapter_id="cheap", quality_score=0.7, cost_usd=0.01)
append_observation(ledger, adapter_id="smart", quality_score=0.9, cost_usd=0.03)
policy = AdaptiveRoutingPolicy(
rules=[RoutingRule("summarize", prefer=cheap)],
ledger=ledger,
adapters_by_id={"cheap": cheap, "smart": smart},
)
assert policy.resolve("summarize", quality_floor=0.8) is smart
def test_prefers_lower_observed_cost_when_multiple_adapters_clear_floor(self, tmp_path):
cheap = self._adapter("cheap")
smart = self._adapter("smart")
ledger = QualityLedger(tmp_path / "quality.jsonl")
append_observation(ledger, adapter_id="cheap", quality_score=0.9, cost_usd=0.01)
append_observation(ledger, adapter_id="smart", quality_score=0.95, cost_usd=0.03)
policy = AdaptiveRoutingPolicy(
rules=[RoutingRule("summarize", prefer=smart)],
ledger=ledger,
adapters_by_id={"cheap": cheap, "smart": smart},
)
assert policy.resolve("summarize", quality_floor=0.8) is cheap
def test_equal_cost_tie_prefers_static_rule_prefer(self, tmp_path):
candidate = self._adapter("candidate")
preferred = self._adapter("preferred")
ledger = QualityLedger(tmp_path / "quality.jsonl")
append_observation(ledger, adapter_id="candidate", quality_score=0.9, cost_usd=0.01)
append_observation(ledger, adapter_id="preferred", quality_score=0.9, cost_usd=0.01)
policy = AdaptiveRoutingPolicy(
rules=[RoutingRule("summarize", prefer=preferred)],
ledger=ledger,
adapters_by_id={"candidate": candidate, "preferred": preferred},
)
assert policy.resolve("summarize", quality_floor=0.8) is preferred
def test_cold_start_falls_through_to_static_policy(self, tmp_path):
preferred = self._adapter("preferred")
fallback = self._adapter("fallback")
policy = AdaptiveRoutingPolicy(
rules=[RoutingRule("summarize", prefer=preferred, fallback=fallback)],
ledger=QualityLedger(tmp_path / "quality.jsonl"),
adapters_by_id={"preferred": preferred, "fallback": fallback},
)
assert policy.resolve("summarize", quality_floor=0.8) is preferred
def test_window_size_changes_observed_mean_quality(self, tmp_path):
cheap = self._adapter("cheap")
smart = self._adapter("smart")
ledger = QualityLedger(tmp_path / "quality.jsonl")
append_observation(
ledger,
adapter_id="cheap",
quality_score=0.9,
cost_usd=0.01,
recorded_at=datetime(2026, 5, 16, tzinfo=timezone.utc),
)
append_observation(
ledger,
adapter_id="cheap",
quality_score=0.7,
cost_usd=0.01,
recorded_at=datetime(2026, 5, 17, tzinfo=timezone.utc),
)
append_observation(ledger, adapter_id="smart", quality_score=0.9, cost_usd=0.03)
recent_only = AdaptiveRoutingPolicy(
rules=[RoutingRule("summarize", prefer=smart)],
ledger=ledger,
adapters_by_id={"cheap": cheap, "smart": smart},
window_size=1,
)
wider_window = AdaptiveRoutingPolicy(
rules=[RoutingRule("summarize", prefer=smart)],
ledger=ledger,
adapters_by_id={"cheap": cheap, "smart": smart},
window_size=2,
)
assert recent_only.resolve("summarize", quality_floor=0.8) is smart
assert wider_window.resolve("summarize", quality_floor=0.8) is cheap
def test_stale_observations_are_ignored_by_max_age(self, tmp_path):
stale = self._adapter("stale")
fresh = self._adapter("fresh")
ledger = QualityLedger(tmp_path / "quality.jsonl")
append_observation(
ledger,
adapter_id="stale",
quality_score=1.0,
cost_usd=0.01,
recorded_at=datetime(2020, 1, 1, tzinfo=timezone.utc),
)
append_observation(
ledger,
adapter_id="fresh",
quality_score=0.9,
cost_usd=0.03,
recorded_at=datetime.now(timezone.utc),
)
policy = AdaptiveRoutingPolicy(
rules=[RoutingRule("summarize", prefer=stale)],
ledger=ledger,
adapters_by_id={"stale": stale, "fresh": fresh},
max_age=timedelta(days=1),
)
assert policy.resolve("summarize", quality_floor=0.8) is fresh
def test_static_fallback_chain_is_preserved_when_no_candidate_qualifies(self, tmp_path):
preferred = self._adapter("preferred")
fallback = self._adapter("fallback")
ledger = QualityLedger(tmp_path / "quality.jsonl")
append_observation(ledger, adapter_id="preferred", quality_score=0.6, cost_usd=0.01)
append_observation(ledger, adapter_id="fallback", quality_score=0.7, cost_usd=0.005)
policy = AdaptiveRoutingPolicy(
rules=[
RoutingRule(
"summarize",
prefer=preferred,
max_cost_per_1k=1.0,
fallback=fallback,
)
],
ledger=ledger,
adapters_by_id={"preferred": preferred, "fallback": fallback},
)
assert policy.resolve("summarize", 2.0, quality_floor=0.8) is fallback

153
tests/test_claude_code.py Normal file
View File

@@ -0,0 +1,153 @@
from __future__ import annotations
from types import SimpleNamespace
from llm_connect.claude_code import ClaudeCodeAdapter
from llm_connect.config import LLMConfig
from llm_connect.models import RunConfig
def test_execute_prompt_passes_json_schema_to_claude_cli(monkeypatch):
calls: dict[str, object] = {}
def fake_run(cmd, input, capture_output, text, timeout): # noqa: ANN001
calls["cmd"] = cmd
calls["input"] = input
calls["capture_output"] = capture_output
calls["text"] = text
calls["timeout"] = timeout
# With --output-format json the CLI returns an envelope.
envelope = {
"type": "result",
"result": '{"summary":"ok","recommendations":[]}',
}
import json as _json
return SimpleNamespace(returncode=0, stdout=_json.dumps(envelope), stderr="")
monkeypatch.setattr("llm_connect.claude_code.subprocess.run", fake_run)
adapter = ClaudeCodeAdapter(cli_path="/custom/claude")
response = adapter.execute_prompt(
"Produce a report.",
RunConfig(
timeout_seconds=42,
model_params={"json_schema": {"type": "object"}},
),
)
assert calls["cmd"] == [
"/custom/claude",
"--print",
"--json-schema",
'{"type":"object"}',
"--output-format",
"json",
]
assert calls["input"] == "Produce a report."
assert calls["timeout"] == 42
# Envelope's result field carries the schema-enforced JSON; the adapter
# unwraps it before returning to the caller.
assert response.content == '{"summary":"ok","recommendations":[]}'
def test_execute_prompt_unwraps_cli_json_envelope_result_field(monkeypatch):
"""With --output-format json the CLI wraps the model payload in an
envelope. The adapter unwraps the textual result so the caller still
sees the model's structured-output JSON, not the envelope."""
def fake_run(cmd, input, capture_output, text, timeout): # noqa: ANN001
envelope = {
"type": "result",
"result": '{"summary":"ok","recommendations":[]}',
"total_cost_usd": 0.001,
}
import json as _json
return SimpleNamespace(
returncode=0,
stdout=_json.dumps(envelope),
stderr="",
)
monkeypatch.setattr("llm_connect.claude_code.subprocess.run", fake_run)
adapter = ClaudeCodeAdapter(cli_path="/custom/claude")
response = adapter.execute_prompt(
"Produce a report.",
RunConfig(model_params={"json_schema": {"type": "object"}}),
)
assert response.content == '{"summary":"ok","recommendations":[]}'
def test_execute_prompt_prefers_json_field_over_prose_preamble(monkeypatch):
"""When the model adds a prose preamble in the envelope's primary text
field but the schema-enforced JSON is in a different field, the adapter
must find and return the JSON, not the preamble."""
def fake_run(cmd, input, capture_output, text, timeout): # noqa: ANN001
envelope = {
"type": "result",
"result": "Triage report generated and returned via structured output. Key signals: healthy.",
"structured_result": '{"summary":"healthy","recommendations":[]}',
"total_cost_usd": 0.002,
}
import json as _json
return SimpleNamespace(returncode=0, stdout=_json.dumps(envelope), stderr="")
monkeypatch.setattr("llm_connect.claude_code.subprocess.run", fake_run)
adapter = ClaudeCodeAdapter(cli_path="/custom/claude")
response = adapter.execute_prompt(
"Long triage prompt.",
RunConfig(model_params={"json_schema": {"type": "object"}}),
)
assert response.content == '{"summary":"healthy","recommendations":[]}'
def test_execute_prompt_skips_envelope_metadata_keys(monkeypatch):
"""Metadata keys like `type`, `model`, `usage` must never be returned as
the model payload, even if their values look JSON-like."""
def fake_run(cmd, input, capture_output, text, timeout): # noqa: ANN001
envelope = {
"type": '{"this":"is_metadata"}', # decoy
"usage": {"input_tokens": 5}, # decoy dict
"result": '{"summary":"ok"}',
}
import json as _json
return SimpleNamespace(returncode=0, stdout=_json.dumps(envelope), stderr="")
monkeypatch.setattr("llm_connect.claude_code.subprocess.run", fake_run)
adapter = ClaudeCodeAdapter(cli_path="/custom/claude")
response = adapter.execute_prompt(
"Prompt.", RunConfig(model_params={"json_schema": {"type": "object"}})
)
assert response.content == '{"summary":"ok"}'
def test_execute_prompt_no_unwrap_without_json_schema(monkeypatch):
"""Without --json-schema we do not pass --output-format json, so the
envelope unwrap path stays inert and raw stdout passes through."""
def fake_run(cmd, input, capture_output, text, timeout): # noqa: ANN001
return SimpleNamespace(
returncode=0,
stdout='{"result":"this is just stdout, not an envelope"}',
stderr="",
)
monkeypatch.setattr("llm_connect.claude_code.subprocess.run", fake_run)
adapter = ClaudeCodeAdapter(cli_path="/custom/claude")
response = adapter.execute_prompt("Plain prompt.", RunConfig())
assert response.content == '{"result":"this is just stdout, not an envelope"}'
def test_claude_code_adapter_prefers_env_cli_path(monkeypatch):
monkeypatch.setenv("LLM_CONNECT_CLAUDE_CLI_PATH", "/home/me/bin/claude")
adapter = ClaudeCodeAdapter(
config=LLMConfig(provider="claude-code", claude_cli_path="claude")
)
assert adapter._cli_path == "/home/me/bin/claude"

54
tests/test_cli.py Normal file
View File

@@ -0,0 +1,54 @@
import json
from datetime import datetime, timezone
from llm_connect.cli import main
from llm_connect.quality import QualityLedger, QualityObservation
def test_rates_show_json_outputs_default_registry(capsys):
assert main(["rates", "show", "--json"]) == 0
payload = json.loads(capsys.readouterr().out)
assert payload["openai/gpt-4o-mini"]["prompt_per_1k"] == 0.00015
def test_classes_show_lists_builtins(capsys):
assert main(["classes", "show"]) == 0
output = capsys.readouterr().out
assert "chunk-summarization" in output
assert "entity-extraction" in output
def test_classes_fit_reads_quality_ledger(tmp_path, capsys):
ledger = QualityLedger(tmp_path / "quality.jsonl")
for _ in range(3):
ledger.append(
QualityObservation(
task_type="extract",
adapter_id="openrouter",
model_id="openai/gpt-4o-mini",
cost_usd=0.001,
quality_score=0.9,
latency_ms=100,
tokens_in=500,
tokens_out=350,
recorded_at=datetime(2026, 5, 19, tzinfo=timezone.utc),
tags={
"problem_class": "entity-extraction",
"dimensions": {
"chunk_words": 300,
"template_words": 100,
"expected_entities": 5,
},
},
)
)
assert main(["classes", "fit", str(ledger.path), "--class", "entity-extraction", "--json"]) == 0
payload = json.loads(capsys.readouterr().out)
assert payload["entity-extraction"]["params"]["tokens_per_entity"] == 70

49
tests/test_costs.py Normal file
View File

@@ -0,0 +1,49 @@
import pytest
from llm_connect.costs import CostEstimate, CostModel, estimate_cost
from llm_connect.rates import ModelRate, ModelRateRegistry
def test_known_model_cost_matches_lefevre_smoke_budget():
estimate = estimate_cost("openai/gpt-4o-mini", 28_000, 7_500)
assert estimate.cost_source == "rate_table:openai/gpt-4o-mini"
assert estimate.cost_usd == pytest.approx(0.0087)
assert estimate.cost_usd == pytest.approx(0.009, rel=0.2)
def test_unknown_model_returns_unknown_without_zeroing_cost():
estimate = estimate_cost("unknown/model", 100, 50)
assert estimate == CostEstimate(cost_usd=None, cost_source="unknown")
def test_registry_override_controls_estimate():
registry = ModelRateRegistry(
{
"vendor/model": ModelRate(
"vendor/model",
prompt_per_1k=1.0,
completion_per_1k=2.0,
)
}
)
estimate = estimate_cost("vendor/model", 1_000, 500, registry=registry)
assert estimate.cost_usd == pytest.approx(2.0)
assert estimate.prompt_cost_usd == pytest.approx(1.0)
assert estimate.completion_cost_usd == pytest.approx(1.0)
def test_zero_tokens_are_valid_and_cost_zero_for_known_model():
estimate = CostModel().estimate_cost("openai/gpt-4o-mini", 0, 0)
assert estimate.cost_usd == 0
assert estimate.prompt_cost_usd == 0
assert estimate.completion_cost_usd == 0
def test_negative_tokens_are_rejected():
with pytest.raises(ValueError, match="prompt_tokens"):
estimate_cost("openai/gpt-4o-mini", -1, 0)

View File

@@ -66,7 +66,7 @@ class TestCreateAdapter:
assert isinstance(adapter, ClaudeCodeAdapter) assert isinstance(adapter, ClaudeCodeAdapter)
def test_all_known_providers_are_reachable(self): def test_all_known_providers_are_reachable(self):
known = {"openrouter", "openai", "gemini", "claude-code"} known = {"openrouter", "openai", "gemini", "claude-code", "mock"}
# Just verify each key is in the factory registry (no construction needed) # Just verify each key is in the factory registry (no construction needed)
from llm_connect.factory import _PROVIDERS from llm_connect.factory import _PROVIDERS
assert known == set(_PROVIDERS.keys()) assert known == set(_PROVIDERS.keys())

198
tests/test_grading.py Normal file
View File

@@ -0,0 +1,198 @@
"""
Tests for baseline grading and built-in judges.
"""
import pytest
from llm_connect.adapter import MockLLMAdapter
from llm_connect.embedding_adapter import EmbeddingAdapter
from llm_connect.grading import (
EmbeddingSimilarityJudge,
ExactMatchJudge,
GradingResult,
LLMJudge,
PairedGrader,
)
from llm_connect.models import LLMResponse, RunConfig
class StaticEmbeddingAdapter(EmbeddingAdapter):
def __init__(self, embeddings: list[list[float]]):
self.embeddings = embeddings
self.seen_texts: list[str] | None = None
def embed(self, texts: list[str]) -> list[list[float]]:
self.seen_texts = texts
return self.embeddings
def validate(self) -> bool:
return True
def response(content: str, model: str = "m") -> LLMResponse:
return LLMResponse(content=content, model=model)
class TestGradingResult:
def test_score_must_be_between_zero_and_one(self):
with pytest.raises(ValueError, match="quality_score"):
GradingResult(
quality_score=1.5,
notes="bad",
grader_id="g",
baseline_response=response("a"),
candidate_response=response("b"),
)
def test_grader_id_must_be_non_empty(self):
with pytest.raises(ValueError, match="grader_id"):
GradingResult(
quality_score=1.0,
notes="ok",
grader_id="",
baseline_response=response("a"),
candidate_response=response("a"),
)
class TestExactMatchJudge:
def test_scores_one_for_normalised_match(self):
judge = ExactMatchJudge()
result = judge.judge(
response("hello world"),
response("hello world"),
prompt="p",
run_config=RunConfig(),
)
assert result.quality_score == 1.0
assert result.baseline_response.content == "hello world"
assert result.candidate_response.content == "hello world"
def test_scores_zero_for_difference(self):
result = ExactMatchJudge().judge(
response("hello"),
response("goodbye"),
prompt="p",
run_config=RunConfig(),
)
assert result.quality_score == 0.0
def test_case_insensitive_mode(self):
result = ExactMatchJudge(case_sensitive=False).judge(
response("Hello"),
response("hello"),
prompt="p",
run_config=RunConfig(),
)
assert result.quality_score == 1.0
class TestEmbeddingSimilarityJudge:
def test_scores_cosine_similarity(self):
embedding_adapter = StaticEmbeddingAdapter([[1.0, 0.0], [0.5, 0.0]])
result = EmbeddingSimilarityJudge(embedding_adapter).judge(
response("baseline"),
response("candidate"),
prompt="p",
run_config=RunConfig(),
)
assert result.quality_score == 1.0
assert embedding_adapter.seen_texts == ["baseline", "candidate"]
def test_negative_similarity_clamps_to_zero(self):
embedding_adapter = StaticEmbeddingAdapter([[1.0, 0.0], [-1.0, 0.0]])
result = EmbeddingSimilarityJudge(embedding_adapter).judge(
response("baseline"),
response("candidate"),
prompt="p",
run_config=RunConfig(),
)
assert result.quality_score == 0.0
def test_wrong_embedding_count_raises(self):
embedding_adapter = StaticEmbeddingAdapter([[1.0, 0.0]])
with pytest.raises(ValueError, match="two embeddings"):
EmbeddingSimilarityJudge(embedding_adapter).judge(
response("baseline"),
response("candidate"),
prompt="p",
run_config=RunConfig(),
)
class TestLLMJudge:
def test_parses_json_judge_response(self):
judge_adapter = MockLLMAdapter(
mock_response='{"quality_score": 0.75, "notes": "mostly equivalent"}'
)
run_config = RunConfig(model_params={"existing": True})
result = LLMJudge(judge_adapter).judge(
response("baseline answer"),
response("candidate answer"),
prompt="original prompt",
run_config=run_config,
)
assert result.quality_score == 0.75
assert result.notes == "mostly equivalent"
assert "baseline answer" in judge_adapter.last_prompt
assert "candidate answer" in judge_adapter.last_prompt
assert judge_adapter.last_config.temperature == 0.0
assert judge_adapter.last_config.model_params["existing"] is True
assert judge_adapter.last_config.model_params["seed"] == 0
assert judge_adapter.last_config.budget_tracker is None
def test_extracts_json_from_wrapped_response(self):
judge_adapter = MockLLMAdapter(
mock_response='Here is the result: {"quality_score": 1, "notes": "same"}'
)
result = LLMJudge(judge_adapter).judge(
response("a"),
response("a"),
prompt="p",
run_config=RunConfig(),
)
assert result.quality_score == 1.0
assert result.notes == "same"
def test_invalid_judge_response_raises(self):
judge_adapter = MockLLMAdapter(mock_response="not json")
with pytest.raises(ValueError, match="JSON"):
LLMJudge(judge_adapter).judge(
response("a"),
response("b"),
prompt="p",
run_config=RunConfig(),
)
class TestPairedGrader:
def test_runs_both_adapters_and_preserves_responses(self):
baseline = MockLLMAdapter(mock_response="same")
candidate = MockLLMAdapter(mock_response="same")
result = PairedGrader(ExactMatchJudge()).grade(
baseline,
candidate,
"prompt",
RunConfig(model_name="mock-model"),
)
assert result.quality_score == 1.0
assert result.baseline_response.content == "same"
assert result.candidate_response.content == "same"
assert baseline.call_count == 1
assert candidate.call_count == 1
assert baseline.last_prompt == "prompt"
assert candidate.last_prompt == "prompt"
def test_uses_custom_judge(self):
baseline = MockLLMAdapter(mock_response="a")
candidate = MockLLMAdapter(mock_response="b")
result = PairedGrader(ExactMatchJudge()).grade(
baseline,
candidate,
"prompt",
RunConfig(),
)
assert result.quality_score == 0.0

View File

@@ -0,0 +1,63 @@
"""
Tests for the public llm_connect package surface.
"""
import llm_connect
def test_wp_0004_primitives_are_exported_from_package_root():
expected_names = [
"AdaptiveRoutingPolicy",
"BaselineGrader",
"EmbeddingSimilarityJudge",
"ExactMatchJudge",
"GradingResult",
"Judge",
"LLMJudge",
"PairedGrader",
"QualityLedger",
"QualityObservation",
"ShadowingAdapter",
"is_stale",
]
for name in expected_names:
assert hasattr(llm_connect, name)
assert name in llm_connect.__all__
def test_wp_0005_primitives_are_exported_from_package_root():
expected_names = [
"ModelRate",
"ModelRateRegistry",
"CostEstimate",
"CostModel",
"estimate_cost",
"TokenEstimate",
"Observation",
"ProblemClass",
"ProblemClassRegistry",
"default_problem_class_registry",
"ChunkSummarizationProblemClass",
"EntityExtractionProblemClass",
"RelationExtractionProblemClass",
"JudgeEvalProblemClass",
"ReportSynthesisProblemClass",
]
for name in expected_names:
assert hasattr(llm_connect, name)
assert name in llm_connect.__all__
def test_wp_0006_profile_primitives_are_exported_from_package_root():
expected_names = [
"CUSTODIAN_TRIAGE_BALANCED",
"RuntimeProfile",
"ProfiledLLMAdapter",
"default_runtime_profiles",
]
for name in expected_names:
assert hasattr(llm_connect, name)
assert name in llm_connect.__all__

81
tests/test_payload.py Normal file
View File

@@ -0,0 +1,81 @@
from llm_connect._payload import merge_gemini_model_params, merge_openai_chat_model_params
STRUCTURED_SCHEMA = {
"type": "object",
"properties": {
"summary": {"type": "string"},
"recommendations": {"type": "array", "items": {"type": "string"}},
},
"required": ["summary", "recommendations"],
}
ACTIVITY_CORE_MODEL_PARAMS = {
"reasoning_effort": "medium",
"max_depth": 4,
"json_schema": STRUCTURED_SCHEMA,
"top_p": 0.8,
}
def test_openai_chat_model_params_translate_activity_core_shape():
payload = {
"model": "gpt-4.1-mini",
"messages": [{"role": "user", "content": "triage"}],
"temperature": 0.2,
"max_tokens": 200,
}
merge_openai_chat_model_params(payload, ACTIVITY_CORE_MODEL_PARAMS)
assert payload["response_format"] == {
"type": "json_schema",
"json_schema": {
"name": "structured_output",
"schema": STRUCTURED_SCHEMA,
"strict": True,
},
}
assert payload["top_p"] == 0.8
assert "reasoning_effort" not in payload
assert "max_depth" not in payload
assert "json_schema" not in payload
def test_openai_chat_model_params_preserve_explicit_response_format():
explicit = {
"type": "json_schema",
"json_schema": {
"name": "custom",
"schema": STRUCTURED_SCHEMA,
"strict": True,
},
}
payload = {"model": "gpt-4.1-mini", "messages": []}
merge_openai_chat_model_params(
payload,
{"json_schema": STRUCTURED_SCHEMA, "response_format": explicit},
)
assert payload["response_format"] == explicit
def test_gemini_model_params_translate_activity_core_shape():
payload = {
"contents": [{"role": "user", "parts": [{"text": "triage"}]}],
"generationConfig": {
"temperature": 0.2,
"maxOutputTokens": 200,
},
}
merge_gemini_model_params(payload, ACTIVITY_CORE_MODEL_PARAMS)
assert payload["generationConfig"]["responseMimeType"] == "application/json"
assert payload["generationConfig"]["responseSchema"] == STRUCTURED_SCHEMA
assert payload["generationConfig"]["topP"] == 0.8
assert "reasoning_effort" not in payload
assert "max_depth" not in payload
assert "json_schema" not in payload

View File

@@ -0,0 +1,137 @@
from datetime import datetime, timezone
import pytest
from llm_connect.problem_classes import (
EntityExtractionProblemClass,
Observation,
ProblemClassRegistry,
TokenEstimate,
)
from llm_connect.quality import QualityObservation
DIMENSIONS_BY_CLASS = {
"chunk-summarization": [
{"chunk_words": 900, "template_words": 150},
{"chunk_words": 400, "template_words": 125},
{"chunk_words": 1200, "template_words": 200},
],
"entity-extraction": [
{"chunk_words": 900, "template_words": 200, "expected_entities": 4},
{"chunk_words": 450, "template_words": 180, "expected_entities": 6},
{"chunk_words": 1200, "template_words": 220, "expected_entities": 8},
],
"relation-extraction": [
{"chunk_words": 900, "template_words": 200, "expected_relations": 3},
{"chunk_words": 450, "template_words": 180, "expected_relations": 5},
{"chunk_words": 1200, "template_words": 220, "expected_relations": 7},
],
"judge-eval": [
{"artifact_words": 700, "template_words": 180, "n_criteria": 4},
{"artifact_words": 300, "template_words": 160, "n_criteria": 5},
{"artifact_words": 1100, "template_words": 200, "n_criteria": 6},
],
"report-synthesis": [
{"n_chunks": 5, "n_entities": 20, "n_relations": 8, "template_words": 250},
{"n_chunks": 8, "n_entities": 30, "n_relations": 12, "template_words": 250},
{"n_chunks": 2, "n_entities": 10, "n_relations": 3, "template_words": 180},
],
}
def test_default_registry_exposes_builtin_classes():
registry = ProblemClassRegistry.default()
assert set(registry.all()) == set(DIMENSIONS_BY_CLASS)
assert registry.schema_version == 1
@pytest.mark.parametrize("name,dimensions_list", DIMENSIONS_BY_CLASS.items())
def test_builtin_estimators_produce_token_estimates(name, dimensions_list):
problem_class = ProblemClassRegistry.default().get(name)
estimate = problem_class.estimate(dimensions_list[0])
assert isinstance(estimate, TokenEstimate)
assert estimate.prompt_tokens >= 0
assert estimate.completion_tokens >= 0
assert 0 <= estimate.confidence <= 1
@pytest.mark.parametrize("name,dimensions_list", DIMENSIONS_BY_CLASS.items())
def test_fit_recovers_seeded_params_from_synthetic_observations(name, dimensions_list):
seeded = ProblemClassRegistry.default().get(name)
param_name = seeded.tunable_params[0]
off_seed = type(seeded)(params={param_name: seeded.params[param_name] * 2})
observations = []
for dimensions in dimensions_list:
estimate = seeded.estimate(dimensions)
observations.append(
Observation(
dimensions=dimensions,
prompt_tokens=estimate.prompt_tokens,
completion_tokens=estimate.completion_tokens,
)
)
fitted = off_seed.fit(observations, min_observations=3)
assert fitted.params[param_name] == pytest.approx(seeded.params[param_name], rel=0.1)
def test_fit_uses_quality_ledger_observation_shape():
problem_class = EntityExtractionProblemClass(params={"tokens_per_entity": 10})
observations = [
QualityObservation(
task_type="extract",
adapter_id="openrouter",
model_id="openai/gpt-4o-mini",
cost_usd=0.001,
quality_score=0.9,
latency_ms=100,
tokens_in=500,
tokens_out=350,
recorded_at=datetime(2026, 5, 19, tzinfo=timezone.utc),
tags={
"problem_class": "entity-extraction",
"dimensions": {
"chunk_words": 300,
"template_words": 100,
"expected_entities": 5,
},
},
)
for _ in range(3)
]
fitted = problem_class.fit(observations)
assert fitted.params["tokens_per_entity"] == pytest.approx(70)
def test_fit_keeps_seed_when_sample_is_too_small():
problem_class = EntityExtractionProblemClass()
estimate = problem_class.estimate(
{"chunk_words": 300, "template_words": 100, "expected_entities": 5}
)
fitted = problem_class.fit(
[
Observation(
dimensions={"chunk_words": 300, "template_words": 100, "expected_entities": 5},
prompt_tokens=estimate.prompt_tokens,
completion_tokens=estimate.completion_tokens,
)
],
min_observations=3,
)
assert fitted is problem_class
def test_missing_dimensions_are_rejected():
problem_class = ProblemClassRegistry.default().get("chunk-summarization")
with pytest.raises(ValueError, match="Missing dimensions"):
problem_class.estimate({"chunk_words": 100})

151
tests/test_profiles.py Normal file
View File

@@ -0,0 +1,151 @@
import json
import pytest
from llm_connect.adapter import MockLLMAdapter
from llm_connect.exceptions import LLMConfigurationError
from llm_connect.models import RunConfig
from llm_connect.profiles import (
CUSTODIAN_TRIAGE_BALANCED,
ProfiledLLMAdapter,
RuntimeProfile,
default_runtime_profiles,
)
def test_profile_dispatch_merges_defaults_and_request_params():
created: list[MockLLMAdapter] = []
def factory(provider: str, model: str) -> MockLLMAdapter:
created.append(MockLLMAdapter(mock_response=f"{provider}:{model}"))
return created[-1]
profile = RuntimeProfile(
name=CUSTODIAN_TRIAGE_BALANCED,
provider="mock",
model="triage-model",
config=RunConfig(
model_name="triage-model",
temperature=0.2,
max_tokens=1800,
max_depth=2,
timeout_seconds=300,
model_params={"reasoning_effort": "medium"},
),
)
adapter = ProfiledLLMAdapter(
MockLLMAdapter(mock_response="default"),
{profile.name: profile},
adapter_factory=factory,
)
response = adapter.execute_prompt(
"Return JSON.",
RunConfig(
model_name=CUSTODIAN_TRIAGE_BALANCED,
model_params={"json_schema": {"type": "object"}},
),
)
assert response.model == "triage-model"
assert response.metadata["profile"] == CUSTODIAN_TRIAGE_BALANCED
assert response.metadata["profile_provider"] == "mock"
assert len(created) == 1
resolved = created[0].last_config
assert resolved.model_name == "triage-model"
assert resolved.temperature == 0.2
assert resolved.max_tokens == 1800
assert resolved.max_depth == 2
assert resolved.model_params == {
"reasoning_effort": "medium",
"json_schema": {"type": "object"},
}
def test_profile_dispatch_preserves_explicit_request_scalars():
created: list[MockLLMAdapter] = []
def factory(provider: str, model: str) -> MockLLMAdapter:
created.append(MockLLMAdapter())
return created[-1]
profile = RuntimeProfile(
name=CUSTODIAN_TRIAGE_BALANCED,
provider="mock",
model="triage-model",
config=RunConfig(model_name="triage-model", temperature=0.2, max_tokens=1800),
)
adapter = ProfiledLLMAdapter(
MockLLMAdapter(),
{profile.name: profile},
adapter_factory=factory,
)
adapter.execute_prompt(
"Prompt.",
RunConfig(
model_name=CUSTODIAN_TRIAGE_BALANCED,
temperature=0.4,
max_tokens=123,
),
)
assert created[0].last_config.temperature == 0.4
assert created[0].last_config.max_tokens == 123
def test_non_profile_model_passes_through_to_default_adapter():
default = MockLLMAdapter(mock_response="direct")
adapter = ProfiledLLMAdapter(default, {})
response = adapter.execute_prompt("Prompt.", RunConfig(model_name="gpt-4"))
assert response.content == "direct"
assert default.call_count == 1
assert default.last_config.model_name == "gpt-4"
def test_unknown_custodian_profile_fails_without_secret_context():
adapter = ProfiledLLMAdapter(MockLLMAdapter(), {})
with pytest.raises(LLMConfigurationError) as excinfo:
adapter.execute_prompt("Prompt.", RunConfig(model_name="custodian-missing"))
assert "Unknown LLM runtime profile" in str(excinfo.value)
assert excinfo.value.context == {"profile": "custodian-missing"}
def test_default_custodian_profile_uses_structured_output_capable_model():
profiles = default_runtime_profiles()
profile = profiles[CUSTODIAN_TRIAGE_BALANCED]
assert profile.provider == "openrouter"
assert profile.model == "google/gemini-2.5-flash"
def test_default_profiles_can_be_overridden_from_json_env(monkeypatch):
monkeypatch.setenv(
"LLM_CONNECT_PROFILES_JSON",
json.dumps(
{
CUSTODIAN_TRIAGE_BALANCED: {
"provider": "gemini",
"model": "gemini-2.5-flash",
"config": {
"temperature": 0.1,
"max_tokens": 900,
"model_params": {"reasoning_effort": "low"},
},
}
}
),
)
profiles = default_runtime_profiles(provider="mock", model="fallback")
profile = profiles[CUSTODIAN_TRIAGE_BALANCED]
assert profile.provider == "gemini"
assert profile.model == "gemini-2.5-flash"
assert profile.config.temperature == 0.1
assert profile.config.max_tokens == 900
assert profile.config.model_params == {"reasoning_effort": "low"}

164
tests/test_quality.py Normal file
View File

@@ -0,0 +1,164 @@
"""
Tests for quality observations and the append-only quality ledger.
"""
import threading
from datetime import datetime, timedelta, timezone
import pytest
from llm_connect.quality import QualityLedger, QualityObservation, is_stale
def observation(
*,
task_type: str = "summarize",
adapter_id: str = "openrouter:cheap",
model_id: str = "cheap-model",
quality_score: float = 0.8,
recorded_at: datetime | None = None,
tag: str | None = None,
) -> QualityObservation:
tags = {"tag": tag} if tag is not None else {}
return QualityObservation(
task_type=task_type,
adapter_id=adapter_id,
model_id=model_id,
cost_usd=0.01,
quality_score=quality_score,
latency_ms=123.4,
tokens_in=100,
tokens_out=50,
baseline_adapter_id="claude-code",
recorded_at=recorded_at or datetime(2026, 5, 17, tzinfo=timezone.utc),
tags=tags,
)
class TestQualityObservation:
def test_round_trip_dict(self):
obs = observation(tag="a")
restored = QualityObservation.from_dict(obs.to_dict())
assert restored == obs
assert restored.total_tokens == 150
assert restored.recorded_at.tzinfo is not None
def test_naive_recorded_at_is_interpreted_as_utc(self):
obs = observation(recorded_at=datetime(2026, 5, 17, 12, 0, 0))
assert obs.recorded_at.tzinfo == timezone.utc
@pytest.mark.parametrize("score", [-0.1, 1.1])
def test_quality_score_must_be_between_zero_and_one(self, score):
with pytest.raises(ValueError, match="quality_score"):
observation(quality_score=score)
def test_required_ids_must_be_non_empty(self):
with pytest.raises(ValueError, match="task_type"):
observation(task_type="")
def test_non_negative_fields_are_enforced(self):
with pytest.raises(ValueError, match="tokens_in"):
QualityObservation(
task_type="x",
adapter_id="a",
model_id="m",
cost_usd=0,
quality_score=1,
latency_ms=0,
tokens_in=-1,
tokens_out=0,
)
class TestQualityLedger:
def test_append_and_read_round_trip(self, tmp_path):
ledger = QualityLedger(tmp_path / "quality.jsonl")
obs = observation()
ledger.append(obs)
assert ledger.read_all() == [obs]
def test_by_task_type_filters_observations(self, tmp_path):
ledger = QualityLedger(tmp_path / "quality.jsonl")
ledger.append(observation(task_type="summarize"))
ledger.append(observation(task_type="extract"))
assert [obs.task_type for obs in ledger.by_task_type("summarize")] == ["summarize"]
def test_recent_returns_newest_first_with_filters(self, tmp_path):
ledger = QualityLedger(tmp_path / "quality.jsonl")
older = observation(recorded_at=datetime(2026, 5, 1, tzinfo=timezone.utc), tag="older")
newer = observation(recorded_at=datetime(2026, 5, 2, tzinfo=timezone.utc), tag="newer")
other = observation(
task_type="extract",
recorded_at=datetime(2026, 5, 3, tzinfo=timezone.utc),
tag="other",
)
ledger.append(older)
ledger.append(newer)
ledger.append(other)
recent = ledger.recent(limit=1, task_type="summarize")
assert [obs.tags["tag"] for obs in recent] == ["newer"]
def test_mean_quality_filters_by_adapter_and_minimum_count(self, tmp_path):
ledger = QualityLedger(tmp_path / "quality.jsonl")
ledger.append(observation(adapter_id="a", quality_score=0.5))
ledger.append(observation(adapter_id="a", quality_score=1.0))
ledger.append(observation(adapter_id="b", quality_score=0.1))
assert ledger.mean_quality("summarize", adapter_id="a") == 0.75
assert ledger.mean_quality("summarize", adapter_id="a", min_observations=3) is None
def test_is_stale_uses_utc_reference(self):
obs = observation(recorded_at=datetime(2026, 5, 1, tzinfo=timezone.utc))
now = datetime(2026, 5, 3, tzinfo=timezone.utc)
assert is_stale(obs, timedelta(days=1), now=now) is True
assert is_stale(obs, timedelta(days=3), now=now) is False
def test_prune_before_removes_old_valid_observations(self, tmp_path):
ledger = QualityLedger(tmp_path / "quality.jsonl")
old = observation(recorded_at=datetime(2026, 5, 1, tzinfo=timezone.utc), tag="old")
keep = observation(recorded_at=datetime(2026, 5, 2, tzinfo=timezone.utc), tag="keep")
ledger.append(old)
ledger.append(keep)
removed = ledger.prune_before(datetime(2026, 5, 2, tzinfo=timezone.utc))
assert removed == 1
assert [obs.tags["tag"] for obs in ledger.read_all()] == ["keep"]
def test_malformed_lines_are_skipped_and_counted(self, tmp_path):
path = tmp_path / "quality.jsonl"
path.write_text("{not json}\n", encoding="utf-8")
ledger = QualityLedger(path)
ledger.append(observation())
assert len(ledger.read_all()) == 1
assert ledger.malformed_count() == 1
def test_prune_preserves_malformed_lines(self, tmp_path):
path = tmp_path / "quality.jsonl"
path.write_text("{not json}\n", encoding="utf-8")
ledger = QualityLedger(path)
ledger.append(observation(recorded_at=datetime(2026, 5, 1, tzinfo=timezone.utc)))
removed = ledger.prune_before(datetime(2026, 5, 2, tzinfo=timezone.utc))
assert removed == 1
assert ledger.malformed_count() == 1
assert ledger.read_all() == []
def test_concurrent_writes_round_trip(self, tmp_path):
ledger = QualityLedger(tmp_path / "quality.jsonl")
def append_one(index: int) -> None:
ledger.append(observation(tag=str(index)))
threads = [threading.Thread(target=append_one, args=(i,)) for i in range(25)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
observations = ledger.read_all()
assert len(observations) == 25
assert {obs.tags["tag"] for obs in observations} == {str(i) for i in range(25)}

65
tests/test_rates.py Normal file
View File

@@ -0,0 +1,65 @@
import pytest
from llm_connect.rates import ModelRate, ModelRateRegistry
def test_default_registry_contains_openrouter_seed_models():
registry = ModelRateRegistry.default()
rates = registry.all()
assert len(rates) >= 9
assert rates["openai/gpt-4o-mini"].captured_at == "2026-05-17"
assert rates["openai/gpt-4o-mini"].source_url == "https://openrouter.ai/models"
def test_from_yaml_loads_package_shape(tmp_path):
path = tmp_path / "model-rates.yaml"
path.write_text(
"""
schema_version: 1
currency: USD
source_url: https://example.test/rates
captured_at: "2026-05-19"
rates:
vendor/model:
prompt_per_1k: 0.1
completion_per_1k: 0.2
""",
encoding="utf-8",
)
registry = ModelRateRegistry.from_yaml(path)
rate = registry.get("vendor/model")
assert rate == ModelRate(
model_id="vendor/model",
prompt_per_1k=0.1,
completion_per_1k=0.2,
currency="USD",
source_url="https://example.test/rates",
captured_at="2026-05-19",
)
def test_merged_with_overrides_matching_model():
base = ModelRateRegistry.default()
override = ModelRateRegistry(
{
"openai/gpt-4o-mini": ModelRate(
"openai/gpt-4o-mini",
prompt_per_1k=1,
completion_per_1k=2,
captured_at="override",
)
}
)
merged = base.merged_with(override)
assert merged.get("openai/gpt-4o-mini").prompt_per_1k == 1
assert merged.get("openai/gpt-4o-mini").captured_at == "override"
def test_negative_rates_are_rejected():
with pytest.raises(ValueError, match="prompt_per_1k"):
ModelRate("bad/model", prompt_per_1k=-1, completion_per_1k=0)

62
tests/test_replay.py Normal file
View File

@@ -0,0 +1,62 @@
from llm_connect.replay import parse_audit_record
STRUCTURED_SCHEMA = {
"type": "object",
"properties": {
"summary": {"type": "string"},
"recommendations": {"type": "array", "items": {"type": "string"}},
},
"required": ["summary", "recommendations"],
}
def test_replay_parses_openai_style_provider_response():
record = {
"provider": "openrouter",
"config": {"model_params": {"json_schema": STRUCTURED_SCHEMA}},
"provider_response": {
"status": 200,
"body": {
"choices": [
{
"message": {
"content": '{"summary":"ok","recommendations":[]}'
}
}
]
},
},
"parsed_content": '{"summary":"ok","recommendations":[]}',
}
report = parse_audit_record(record)
assert report["parsed_content"] == '{"summary":"ok","recommendations":[]}'
assert report["matches_recorded_content"] is True
assert report["structured_output"] == {"checked": True, "valid": True}
def test_replay_reuses_claude_code_envelope_unwrapper():
record = {
"provider": "claude-code",
"config": {"model_params": {"json_schema": STRUCTURED_SCHEMA}},
"provider_response": {
"status": 0,
"body": {
"stdout": (
'{"type":"result","result":"prose",'
'"structured_result":"{\\"summary\\":\\"ok\\",'
'\\"recommendations\\":[]}"}'
),
"stderr": "",
},
},
"parsed_content": '{"summary":"ok","recommendations":[]}',
}
report = parse_audit_record(record)
assert report["parsed_content"] == '{"summary":"ok","recommendations":[]}'
assert report["matches_recorded_content"] is True
assert report["structured_output"] == {"checked": True, "valid": True}

91
tests/test_routing.py Normal file
View File

@@ -0,0 +1,91 @@
"""
Tests for RoutingPolicy (FR-2).
"""
import pytest
from llm_connect.routing import RoutingPolicy, RoutingRule
from llm_connect.adapter import MockLLMAdapter
class TestRoutingPolicy:
def _adapters(self, n: int = 3):
return [MockLLMAdapter(mock_response=f"resp-{i}") for i in range(n)]
def test_rule_match_returns_prefer(self):
prefer, *_ = self._adapters()
policy = RoutingPolicy(rules=[RoutingRule("triage", prefer=prefer)])
assert policy.resolve("triage") is prefer
def test_first_matching_rule_wins(self):
a, b = self._adapters(2)
policy = RoutingPolicy(rules=[
RoutingRule("triage", prefer=a),
RoutingRule("triage", prefer=b),
])
assert policy.resolve("triage") is a
def test_cost_cap_within_limit_returns_prefer(self):
prefer, fallback = self._adapters(2)
policy = RoutingPolicy(rules=[
RoutingRule("triage", prefer=prefer, max_cost_per_1k=1.0, fallback=fallback)
])
assert policy.resolve("triage", estimated_cost_per_1k=0.5) is prefer
def test_cost_cap_exceeded_returns_fallback(self):
prefer, fallback = self._adapters(2)
policy = RoutingPolicy(rules=[
RoutingRule("triage", prefer=prefer, max_cost_per_1k=1.0, fallback=fallback)
])
assert policy.resolve("triage", estimated_cost_per_1k=2.0) is fallback
def test_cost_cap_exceeded_no_fallback_returns_prefer(self):
"""When cost exceeds cap but no fallback is set, still return prefer."""
prefer, *_ = self._adapters()
policy = RoutingPolicy(rules=[
RoutingRule("triage", prefer=prefer, max_cost_per_1k=0.1)
])
assert policy.resolve("triage", estimated_cost_per_1k=5.0) is prefer
def test_no_estimated_cost_ignores_cap(self):
prefer, fallback = self._adapters(2)
policy = RoutingPolicy(rules=[
RoutingRule("triage", prefer=prefer, max_cost_per_1k=0.01, fallback=fallback)
])
# No cost estimate → cap not applied
assert policy.resolve("triage") is prefer
def test_unknown_task_type_returns_default(self):
prefer, default = self._adapters(2)
policy = RoutingPolicy(
rules=[RoutingRule("triage", prefer=prefer)],
default=default,
)
assert policy.resolve("unknown") is default
def test_no_match_no_default_raises_lookup_error(self):
prefer, *_ = self._adapters()
policy = RoutingPolicy(rules=[RoutingRule("triage", prefer=prefer)])
with pytest.raises(LookupError, match="unknown"):
policy.resolve("unknown")
def test_empty_rules_with_default_returns_default(self):
default, *_ = self._adapters()
policy = RoutingPolicy(default=default)
assert policy.resolve("anything") is default
def test_empty_policy_raises(self):
policy = RoutingPolicy()
with pytest.raises(LookupError):
policy.resolve("triage")
def test_multiple_task_types(self):
a, b, c = self._adapters(3)
policy = RoutingPolicy(rules=[
RoutingRule("fast", prefer=a),
RoutingRule("smart", prefer=b),
RoutingRule("cheap", prefer=c),
])
assert policy.resolve("fast") is a
assert policy.resolve("smart") is b
assert policy.resolve("cheap") is c

412
tests/test_server.py Normal file
View File

@@ -0,0 +1,412 @@
"""
Tests for LLMServer HTTP serve mode (FR-1).
"""
import threading
import time
from concurrent.futures import ThreadPoolExecutor
import json
import urllib.error
import urllib.request
import pytest
from llm_connect._diagnostics import (
record_adapter_transformation,
record_provider_request,
record_provider_response,
)
from llm_connect.adapter import MockLLMAdapter, ErrorLLMAdapter
from llm_connect.exceptions import LLMAPIError, LLMConfigurationError, LLMTimeoutError
from llm_connect.models import LLMResponse, RunConfig
from llm_connect.profiles import CUSTODIAN_TRIAGE_BALANCED, ProfiledLLMAdapter, RuntimeProfile
from llm_connect.server import LLMServer
@pytest.fixture()
def server():
"""Start a server on a free port; stop after each test."""
s = LLMServer(adapter=MockLLMAdapter(mock_response="hello world"), port=0)
s.start()
yield s
s.stop()
def _get(url: str) -> tuple[int, dict]:
try:
with urllib.request.urlopen(url) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as exc:
return exc.code, json.loads(exc.read())
def _post(url: str, body: dict) -> tuple[int, dict]:
payload = json.dumps(body).encode()
req = urllib.request.Request(
url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as exc:
return exc.code, json.loads(exc.read())
class DiagnosticLLMAdapter(MockLLMAdapter):
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
record_provider_request(
url="https://provider.example/v1/chat",
payload={"prompt": prompt, "model": config.model_name},
headers={"Authorization": "Bearer secret-token"},
)
response = super().execute_prompt(prompt, config)
response.metadata["provider"] = "diagnostic"
response.metadata["response_id"] = "diag-response"
record_provider_response(status=200, body={"id": "diag-response", "content": response.content})
record_adapter_transformation(
"diagnostic_transform",
{"before": prompt},
{"after": response.content},
)
return response
class BarrierLLMAdapter(MockLLMAdapter):
def __init__(self):
super().__init__(mock_response="parallel")
self._barrier = threading.Barrier(2)
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
self._barrier.wait(timeout=2.0)
return super().execute_prompt(prompt, config)
class TestHealth:
def test_health_returns_200(self, server):
status, body = _get(f"http://127.0.0.1:{server.port}/health")
assert status == 200
assert body["status"] == "ok"
def test_unknown_get_returns_404(self, server):
status, body = _get(f"http://127.0.0.1:{server.port}/nope")
assert status == 404
class TestExecute:
def test_post_execute_round_trip(self, server):
status, body = _post(
f"http://127.0.0.1:{server.port}/execute",
{"prompt": "say hello"},
)
assert status == 200
assert body["content"] == "hello world"
assert body["finish_reason"] == "stop"
assert "debug" not in body
def test_response_includes_usage(self, server):
status, body = _post(
f"http://127.0.0.1:{server.port}/execute",
{"prompt": "count tokens"},
)
assert status == 200
assert "usage" in body
assert body["usage"]["total_tokens"] > 0
def test_missing_prompt_returns_400(self, server):
status, body = _post(
f"http://127.0.0.1:{server.port}/execute",
{"config": {}},
)
assert status == 400
assert "prompt" in body["error"]
def test_invalid_json_returns_400(self, server):
req = urllib.request.Request(
f"http://127.0.0.1:{server.port}/execute",
data=b"not json",
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req) as resp:
status, body = resp.status, json.loads(resp.read())
except urllib.error.HTTPError as exc:
status, body = exc.code, json.loads(exc.read())
assert status == 400
def test_unknown_post_path_returns_404(self, server):
status, body = _post(
f"http://127.0.0.1:{server.port}/wrong",
{"prompt": "hi"},
)
assert status == 404
def test_adapter_error_returns_500(self):
s = LLMServer(adapter=ErrorLLMAdapter("boom"), port=0)
s.start()
try:
status, body = _post(
f"http://127.0.0.1:{s.port}/execute",
{"prompt": "hello"},
)
assert status == 500
assert body["error"] == "internal_error"
assert "boom" in body["message"]
finally:
s.stop()
def test_config_fields_forwarded(self):
"""Config fields in request body reach the adapter via RunConfig."""
adapter = MockLLMAdapter(mock_response="x")
s = LLMServer(adapter=adapter, port=0)
s.start()
try:
status, body = _post(
f"http://127.0.0.1:{s.port}/execute",
{
"prompt": "hi",
"config": {
"model_name": "gpt-3.5-turbo",
"max_tokens": 100,
"max_depth": 2,
"model_params": {"reasoning_effort": "medium"},
},
},
)
assert status == 200
assert adapter.last_config.model_name == "gpt-3.5-turbo"
assert adapter.last_config.max_tokens == 100
assert adapter.last_config.max_depth == 2
assert adapter.last_config.model_params == {"reasoning_effort": "medium"}
finally:
s.stop()
def test_config_must_be_object(self, server):
status, body = _post(
f"http://127.0.0.1:{server.port}/execute",
{"prompt": "hi", "config": "not an object"},
)
assert status == 400
assert "config" in body["error"]
def test_profile_execute_resolves_model_and_metadata(self):
created: list[MockLLMAdapter] = []
def factory(provider: str, model: str) -> MockLLMAdapter:
created.append(MockLLMAdapter(mock_response="profile response"))
return created[-1]
adapter = ProfiledLLMAdapter(
MockLLMAdapter(mock_response="default"),
{
CUSTODIAN_TRIAGE_BALANCED: RuntimeProfile(
name=CUSTODIAN_TRIAGE_BALANCED,
provider="mock",
model="triage-model",
config=RunConfig(
model_name="triage-model",
temperature=0.2,
max_tokens=1800,
max_depth=2,
model_params={"reasoning_effort": "medium"},
),
)
},
adapter_factory=factory,
)
s = LLMServer(adapter=adapter, port=0)
s.start()
try:
status, body = _post(
f"http://127.0.0.1:{s.port}/execute",
{
"prompt": "Return JSON.",
"config": {
"model_name": CUSTODIAN_TRIAGE_BALANCED,
"model_params": {"json_schema": {"type": "object"}},
},
},
)
finally:
s.stop()
assert status == 200
assert body["model"] == "triage-model"
assert body["metadata"]["profile"] == CUSTODIAN_TRIAGE_BALANCED
assert body["metadata"]["profile_provider"] == "mock"
assert len(created) == 1
assert created[0].last_config.model_name == "triage-model"
assert created[0].last_config.temperature == 0.2
assert created[0].last_config.max_tokens == 1800
assert created[0].last_config.max_depth == 2
assert created[0].last_config.model_params == {
"reasoning_effort": "medium",
"json_schema": {"type": "object"},
}
def test_unknown_profile_returns_400(self):
s = LLMServer(adapter=ProfiledLLMAdapter(MockLLMAdapter(), {}), port=0)
s.start()
try:
status, body = _post(
f"http://127.0.0.1:{s.port}/execute",
{"prompt": "hello", "config": {"model_name": "custodian-missing"}},
)
finally:
s.stop()
assert status == 400
assert body["error"] == "unknown_profile"
assert body["context"]["profile"] == "custodian-missing"
def test_configuration_error_is_sanitized(self):
class SecretConfigAdapter(MockLLMAdapter):
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
raise LLMConfigurationError(
"Bad api_key=sk-supersecret with Bearer secret-token",
context={"api_key": "sk-supersecret", "provider": "openai"},
)
s = LLMServer(adapter=SecretConfigAdapter(), port=0)
s.start()
try:
status, body = _post(
f"http://127.0.0.1:{s.port}/execute",
{"prompt": "hello"},
)
finally:
s.stop()
assert status == 500
assert body["error"] == "configuration_error"
assert "sk-supersecret" not in json.dumps(body)
assert "secret-token" not in json.dumps(body)
assert body["context"]["api_key"] == "<redacted>"
assert body["context"]["provider"] == "openai"
def test_provider_errors_are_categorized_and_sanitized(self):
class ProviderErrorAdapter(MockLLMAdapter):
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
raise LLMAPIError(
"HTTP 500 from https://provider.example/v1?key=gemini-secret",
status_code=500,
)
s = LLMServer(adapter=ProviderErrorAdapter(), port=0)
s.start()
try:
status, body = _post(
f"http://127.0.0.1:{s.port}/execute",
{"prompt": "hello"},
)
finally:
s.stop()
assert status == 502
assert body["error"] == "provider_api_error"
assert body["provider_status"] == 500
assert "gemini-secret" not in body["message"]
def test_timeout_error_returns_504(self):
class TimeoutAdapter(MockLLMAdapter):
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
raise LLMTimeoutError("Request timed out after 300s")
s = LLMServer(adapter=TimeoutAdapter(), port=0)
s.start()
try:
status, body = _post(
f"http://127.0.0.1:{s.port}/execute",
{"prompt": "hello"},
)
finally:
s.stop()
assert status == 504
assert body["error"] == "provider_timeout"
def test_debug_query_returns_diagnostics(self):
s = LLMServer(adapter=DiagnosticLLMAdapter(mock_response="debug body"), port=0)
s.start()
try:
status, body = _post(
f"http://127.0.0.1:{s.port}/execute?debug=1",
{"prompt": "inspect", "config": {"model_name": "diagnostic-model"}},
)
finally:
s.stop()
assert status == 200
assert body["content"] == "debug body"
debug = body["debug"]
assert debug["provider_request"]["payload"] == {
"prompt": "inspect",
"model": "diagnostic-model",
}
assert debug["provider_request"]["headers_redacted"]["Authorization"] == "Bearer <redacted>"
assert debug["provider_response"]["status"] == 200
assert debug["adapter_transformations"][0]["step"] == "diagnostic_transform"
def test_debug_env_returns_diagnostics(self, monkeypatch):
monkeypatch.setenv("LLM_CONNECT_DEBUG", "1")
s = LLMServer(adapter=DiagnosticLLMAdapter(mock_response="debug body"), port=0)
s.start()
try:
status, body = _post(
f"http://127.0.0.1:{s.port}/execute",
{"prompt": "inspect"},
)
finally:
s.stop()
assert status == 200
assert "debug" in body
def test_audit_dir_records_replayable_call(self, monkeypatch, tmp_path):
monkeypatch.setenv("LLM_CONNECT_AUDIT_DIR", str(tmp_path))
s = LLMServer(adapter=DiagnosticLLMAdapter(mock_response="audit body"), port=0)
s.start()
try:
status, body = _post(
f"http://127.0.0.1:{s.port}/execute",
{"prompt": "audit me", "config": {"model_name": "audit-model"}},
)
finally:
s.stop()
assert status == 200
assert "debug" not in body
files = list(tmp_path.glob("*.json"))
assert len(files) == 1
record = json.loads(files[0].read_text(encoding="utf-8"))
assert record["prompt"] == "audit me"
assert record["config"]["model_name"] == "audit-model"
assert record["parsed_content"] == "audit body"
assert record["provider_request"]["headers_redacted"]["Authorization"] == "Bearer <redacted>"
assert record["provider_response"]["body"]["id"] == "diag-response"
assert record["latency_seconds"] >= 0
def test_execute_requests_run_concurrently(self):
s = LLMServer(adapter=BarrierLLMAdapter(), port=0)
s.start()
try:
start = time.monotonic()
with ThreadPoolExecutor(max_workers=2) as pool:
futures = [
pool.submit(
_post,
f"http://127.0.0.1:{s.port}/execute",
{"prompt": f"request {idx}"},
)
for idx in range(2)
]
results = [future.result(timeout=3.0) for future in futures]
elapsed = time.monotonic() - start
finally:
s.stop()
assert [status for status, _body in results] == [200, 200]
assert elapsed < 1.5

149
tests/test_shadowing.py Normal file
View File

@@ -0,0 +1,149 @@
"""
Tests for ShadowingAdapter.
"""
import asyncio
import time
from llm_connect.adapter import LLMAdapter
from llm_connect.grading import ExactMatchJudge, PairedGrader
from llm_connect.models import LLMResponse, RunConfig
from llm_connect.quality import QualityLedger
from llm_connect.shadowing import ShadowingAdapter
class StaticAdapter(LLMAdapter):
def __init__(
self,
content: str,
*,
model: str = "model",
cost_usd: float = 0.0,
fail: bool = False,
delay_seconds: float = 0.0,
):
self.content = content
self.model = model
self.cost_usd = cost_usd
self.fail = fail
self.delay_seconds = delay_seconds
self.calls = 0
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
self.calls += 1
if self.delay_seconds:
time.sleep(self.delay_seconds)
if self.fail:
raise RuntimeError("adapter failed")
return LLMResponse(
content=self.content,
model=self.model,
usage={
"prompt_tokens": len(prompt.split()),
"completion_tokens": len(self.content.split()),
"total_tokens": len(prompt.split()) + len(self.content.split()),
},
metadata={"cost_usd": self.cost_usd, "latency_ms": 42.0},
)
def validate_config(self, config: RunConfig) -> bool:
return True
def shadowing_adapter(
tmp_path,
*,
candidate: StaticAdapter | None = None,
baseline: StaticAdapter | None = None,
shadow_rate: float = 1.0,
async_shadow: bool = False,
errors: list[Exception] | None = None,
) -> ShadowingAdapter:
return ShadowingAdapter(
candidate_adapter=candidate or StaticAdapter("same", model="candidate", cost_usd=0.02),
baseline_adapter=baseline or StaticAdapter("same", model="baseline"),
grader=PairedGrader(ExactMatchJudge()),
ledger=QualityLedger(tmp_path / "quality.jsonl"),
task_type="summarize",
adapter_id="candidate",
baseline_adapter_id="baseline",
shadow_rate=shadow_rate,
async_shadow=async_shadow,
tags={"prompt_fingerprint": "fixture"},
on_shadow_error=errors.append if errors is not None else None,
)
class TestShadowingAdapter:
def test_sync_shadow_appends_quality_observation(self, tmp_path):
adapter = shadowing_adapter(tmp_path)
response = adapter.execute_prompt("hello world", RunConfig(model_name="candidate-model"))
observations = adapter.ledger.read_all()
assert response.content == "same"
assert len(observations) == 1
assert observations[0].quality_score == 1.0
assert observations[0].cost_usd == 0.02
assert observations[0].tokens_in == 2
assert observations[0].tokens_out == 1
assert observations[0].baseline_adapter_id == "baseline"
assert observations[0].tags["prompt_fingerprint"] == "fixture"
def test_candidate_response_survives_baseline_failure(self, tmp_path):
candidate = StaticAdapter("candidate", model="candidate")
baseline = StaticAdapter("baseline", fail=True)
errors: list[Exception] = []
adapter = shadowing_adapter(
tmp_path,
candidate=candidate,
baseline=baseline,
errors=errors,
)
response = adapter.execute_prompt("prompt", RunConfig())
assert response.content == "candidate"
assert candidate.calls == 1
assert baseline.calls == 1
assert adapter.ledger.read_all() == []
assert len(errors) == 1
def test_shadow_rate_zero_skips_baseline_and_ledger(self, tmp_path):
baseline = StaticAdapter("same")
adapter = shadowing_adapter(tmp_path, baseline=baseline, shadow_rate=0.0)
for _ in range(3):
adapter.execute_prompt("prompt", RunConfig())
assert baseline.calls == 0
assert adapter.ledger.read_all() == []
def test_shadow_rate_one_records_each_call(self, tmp_path):
baseline = StaticAdapter("same")
adapter = shadowing_adapter(tmp_path, baseline=baseline, shadow_rate=1.0)
for _ in range(3):
adapter.execute_prompt("prompt", RunConfig())
assert baseline.calls == 3
assert len(adapter.ledger.read_all()) == 3
def test_async_shadow_mode_flushes_background_work(self, tmp_path):
baseline = StaticAdapter("same", delay_seconds=0.02)
adapter = shadowing_adapter(tmp_path, baseline=baseline, async_shadow=True)
response = adapter.execute_prompt("prompt", RunConfig())
adapter.flush(timeout=1)
adapter.shutdown()
assert response.content == "same"
assert len(adapter.ledger.read_all()) == 1
def test_async_execute_prompt_records_shadow(self, tmp_path):
adapter = shadowing_adapter(tmp_path)
response = asyncio.run(adapter.async_execute_prompt("prompt", RunConfig()))
assert response.content == "same"
assert len(adapter.ledger.read_all()) == 1

View File

@@ -0,0 +1,187 @@
import json
from llm_connect.gemini import GeminiAdapter
from llm_connect.models import RunConfig
from llm_connect.openai import OpenAIAdapter
from llm_connect.openrouter import OpenRouterAdapter
STRUCTURED_SCHEMA = {
"type": "object",
"properties": {
"summary": {"type": "string"},
"recommendations": {"type": "array", "items": {"type": "string"}},
},
"required": ["summary", "recommendations"],
}
OPENROUTER_STRUCTURED_MODEL = "google/gemini-2.5-flash"
SMOKE_CONFIG = RunConfig(
model_name="gpt-4",
temperature=0.1,
max_tokens=300,
model_params={
"reasoning_effort": "medium",
"max_depth": 3,
"json_schema": STRUCTURED_SCHEMA,
},
)
def test_openrouter_structured_output_payload_and_model_routing(monkeypatch):
captured: dict[str, object] = {}
def fake_post_json(url, payload, headers=None, timeout=300): # noqa: ANN001
captured["url"] = url
captured["payload"] = payload
captured["headers"] = headers
captured["timeout"] = timeout
return {
"id": "or-response",
"model": payload["model"],
"choices": [
{
"message": {
"content": json.dumps(
{"summary": "ok", "recommendations": ["keep payload clean"]}
)
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3},
}
monkeypatch.setattr("llm_connect.openrouter.post_json", fake_post_json)
adapter = OpenRouterAdapter(
model=OPENROUTER_STRUCTURED_MODEL,
api_key="or-test",
api_base="https://openrouter.example/api/v1",
)
response = adapter.execute_prompt("Return JSON.", SMOKE_CONFIG)
payload = captured["payload"]
assert response.model == OPENROUTER_STRUCTURED_MODEL
assert payload["model"] == OPENROUTER_STRUCTURED_MODEL
assert payload["response_format"]["json_schema"]["schema"] == STRUCTURED_SCHEMA
assert payload["response_format"]["json_schema"]["strict"] is True
assert payload["provider"]["require_parameters"] is True
assert "reasoning_effort" not in payload
assert "max_depth" not in payload
assert "json_schema" not in payload
def test_openrouter_structured_output_preserves_provider_options(monkeypatch):
captured: dict[str, object] = {}
def fake_post_json(url, payload, headers=None, timeout=300): # noqa: ANN001
captured["payload"] = payload
return {
"id": "or-response",
"model": payload["model"],
"choices": [
{
"message": {
"content": json.dumps({"summary": "ok", "recommendations": []})
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3},
}
config = RunConfig(
model_name="gpt-4",
temperature=0.1,
max_tokens=300,
model_params={
"json_schema": STRUCTURED_SCHEMA,
"provider": {"order": ["Anthropic"]},
},
)
monkeypatch.setattr("llm_connect.openrouter.post_json", fake_post_json)
adapter = OpenRouterAdapter(
model=OPENROUTER_STRUCTURED_MODEL,
api_key="or-test",
api_base="https://openrouter.example/api/v1",
)
adapter.execute_prompt("Return JSON.", config)
payload = captured["payload"]
assert payload["provider"]["order"] == ["Anthropic"]
assert payload["provider"]["require_parameters"] is True
def test_openai_structured_output_payload(monkeypatch):
captured: dict[str, object] = {}
def fake_post_json(url, payload, headers=None, timeout=300): # noqa: ANN001
captured["payload"] = payload
return {
"id": "oa-response",
"model": payload["model"],
"choices": [
{
"message": {
"content": json.dumps({"summary": "ok", "recommendations": []})
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3},
}
monkeypatch.setattr("llm_connect.openai.post_json", fake_post_json)
adapter = OpenAIAdapter(model="gpt-4.1-mini", api_key="sk-test")
response = adapter.execute_prompt("Return JSON.", SMOKE_CONFIG)
payload = captured["payload"]
assert response.model == "gpt-4.1-mini"
assert payload["model"] == "gpt-4.1-mini"
assert payload["response_format"]["json_schema"]["schema"] == STRUCTURED_SCHEMA
assert "reasoning_effort" not in payload
assert "max_depth" not in payload
assert "json_schema" not in payload
def test_gemini_structured_output_payload(monkeypatch):
captured: dict[str, object] = {}
def fake_post_json(url, payload, headers=None, timeout=300): # noqa: ANN001
captured["url"] = url
captured["payload"] = payload
return {
"candidates": [
{
"content": {
"parts": [
{"text": json.dumps({"summary": "ok", "recommendations": []})}
]
},
"finishReason": "STOP",
}
],
"usageMetadata": {
"promptTokenCount": 1,
"candidatesTokenCount": 2,
"totalTokenCount": 3,
},
}
monkeypatch.setattr("llm_connect.gemini.post_json", fake_post_json)
adapter = GeminiAdapter(model="gemini-2.5-flash", api_key="gemini-test")
response = adapter.execute_prompt("Return JSON.", SMOKE_CONFIG)
payload = captured["payload"]
assert response.model == "gemini-2.5-flash"
assert payload["generationConfig"]["responseMimeType"] == "application/json"
assert payload["generationConfig"]["responseSchema"] == STRUCTURED_SCHEMA
assert "reasoning_effort" not in payload
assert "max_depth" not in payload
assert "json_schema" not in payload

243
uv.lock generated
View File

@@ -1,5 +1,49 @@
version = 1 version = 1
requires-python = ">=3.10" requires-python = ">=3.10"
resolution-markers = [
"python_full_version < '3.15'",
"python_full_version >= '3.15'",
]
[[package]]
name = "ast-serialize"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520 },
{ url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779 },
{ url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750 },
{ url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942 },
{ url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517 },
{ url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081 },
{ url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910 },
{ url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678 },
{ url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603 },
{ url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332 },
{ url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979 },
{ url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002 },
{ url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231 },
{ url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668 },
{ url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075 },
{ url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347 },
{ url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380 },
{ url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879 },
{ url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529 },
{ url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560 },
{ url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172 },
{ url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072 },
{ url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488 },
{ url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702 },
{ url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182 },
{ url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410 },
{ url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587 },
{ url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171 },
{ url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668 },
{ url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311 },
{ url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931 },
{ url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181 },
]
[[package]] [[package]]
name = "colorama" name = "colorama"
@@ -31,6 +75,91 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
] ]
[[package]]
name = "librt"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706 },
{ url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605 },
{ url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555 },
{ url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434 },
{ url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918 },
{ url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334 },
{ url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287 },
{ url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202 },
{ url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517 },
{ url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878 },
{ url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070 },
{ url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918 },
{ url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092 },
{ url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035 },
{ url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022 },
{ url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273 },
{ url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083 },
{ url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139 },
{ url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442 },
{ url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230 },
{ url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231 },
{ url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585 },
{ url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509 },
{ url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628 },
{ url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122 },
{ url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147 },
{ url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614 },
{ url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538 },
{ url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623 },
{ url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082 },
{ url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105 },
{ url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268 },
{ url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348 },
{ url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294 },
{ url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608 },
{ url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879 },
{ url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831 },
{ url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470 },
{ url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119 },
{ url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565 },
{ url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395 },
{ url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383 },
{ url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010 },
{ url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433 },
{ url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595 },
{ url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255 },
{ url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847 },
{ url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920 },
{ url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898 },
{ url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812 },
{ url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448 },
{ url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345 },
{ url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131 },
{ url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024 },
{ url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221 },
{ url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174 },
{ url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216 },
{ url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921 },
{ url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850 },
{ url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237 },
{ url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261 },
{ url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965 },
{ url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151 },
{ url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850 },
{ url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138 },
{ url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976 },
{ url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927 },
{ url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698 },
{ url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162 },
{ url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494 },
{ url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858 },
{ url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318 },
{ url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115 },
{ url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918 },
{ url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562 },
{ url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327 },
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572 },
]
[[package]] [[package]]
name = "llm-connect" name = "llm-connect"
version = "0.1.0" version = "0.1.0"
@@ -41,22 +170,100 @@ dependencies = [
[package.optional-dependencies] [package.optional-dependencies]
dev = [ dev = [
{ name = "mypy" },
{ name = "pytest" }, { name = "pytest" },
{ name = "ruff" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "mypy" },
{ name = "pytest" }, { name = "pytest" },
{ name = "ruff" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" },
{ name = "toml" }, { name = "toml" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.2" }] dev = [
{ name = "mypy", specifier = ">=1.10" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "ruff", specifier = ">=0.4" },
]
[[package]]
name = "mypy"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ast-serialize" },
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792 },
{ url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739 },
{ url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199 },
{ url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128 },
{ url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378 },
{ url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994 },
{ url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743 },
{ url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685 },
{ url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165 },
{ url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376 },
{ url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618 },
{ url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063 },
{ url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564 },
{ url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983 },
{ url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381 },
{ url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501 },
{ url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750 },
{ url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630 },
{ url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831 },
{ url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228 },
{ url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684 },
{ url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174 },
{ url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542 },
{ url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929 },
{ url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200 },
{ url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690 },
{ url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435 },
{ url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052 },
{ url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422 },
{ url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374 },
{ url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743 },
{ url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937 },
{ url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371 },
{ url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429 },
{ url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799 },
{ url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458 },
{ url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697 },
{ url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638 },
{ url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852 },
{ url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695 },
{ url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622 },
{ url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798 },
{ url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302 },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
@@ -67,6 +274,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 }, { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 },
] ]
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328 },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@@ -103,6 +319,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 }, { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 },
] ]
[[package]]
name = "ruff"
version = "0.15.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279 },
{ url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798 },
{ url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761 },
{ url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451 },
{ url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285 },
{ url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063 },
{ url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079 },
{ url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833 },
{ url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486 },
{ url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189 },
{ url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380 },
{ url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605 },
{ url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554 },
{ url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133 },
{ url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455 },
{ url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409 },
{ url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336 },
]
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.10.2" version = "0.10.2"

View File

@@ -0,0 +1,230 @@
---
id: ADHOC-2026-06-02
type: workplan
title: "Ad hoc — llm-connect lessons from CUST-WP-0045 canary"
domain: custodian
repo: llm-connect
status: finished
owner: custodian
topic_slug: custodian
created: "2026-06-02"
updated: "2026-06-03"
state_hub_workstream_id: "1c936c91-79c7-427d-ab37-9052e8a61cda"
---
# ADHOC-2026-06-02 — llm-connect lessons from CUST-WP-0045 canary
Captured at the close of CUST-WP-0045 T06. The daily-triage canary uncovered
a sequence of bugs that took the better part of a day to diagnose, mostly
because llm-connect has no way to see what the underlying provider returned
or what payload the adapter sent. Five commits landed today:
- `9de0f49` — Claude Code adapter passes `--output-format json` and unwraps
- `435da49` — envelope unwrap prefers JSON-bearing fields over prose
- `cd4551c` — OpenRouter adapter translates `json_schema``response_format`
and drops Claude-specific keys
- `583ab57` — OpenRouter `response_format.json_schema.strict` defaults to `False`
- `1b01f0e` — OpenRouter honours explicit `--model` even when it equals the
hardcoded default
This adhoc captures the structural improvements that would have collapsed the
diagnosis loop from half a day to minutes. Each task is sized as an opportunistic
improvement; the larger ones explicitly call out when to promote into a
workplan.
## Tasks
### T01 - Debug envelope mode for /execute responses
```task
id: ADHOC-2026-06-02-T01
status: done
priority: medium
state_hub_task_id: "69626e9e-29f1-40f6-8cd2-d38a7e802293"
```
When the `LLM_CONNECT_DEBUG=1` env var is set (or `?debug=1` is added to the
`/execute` request), include the raw provider response and the adapter's
constructed payload alongside the normal `content` field. Today's diagnosis
required two iterations on the Claude Code envelope shape and one on the
OpenRouter adapter routing — each required modifying source, restarting
llm-connect, re-running, observing, modifying again. A debug envelope mode
would have given the diagnostic data in one curl.
Proposed response shape under debug mode:
```json
{
"content": "<unchanged>",
"model": "<unchanged>",
"usage": {...},
"metadata": {...},
"debug": {
"provider_request": {"url": "...", "payload": {...}, "headers_redacted": {...}},
"provider_response": {"status": 200, "body": {...}},
"adapter_transformations": [
{"step": "merge_model_params", "before": {...}, "after": {...}},
{"step": "unwrap_cli_envelope", "before": "...", "after": "..."}
]
}
}
```
Done when `LLM_CONNECT_DEBUG=1 curl /execute …` returns the raw round-trip in
the response without changing the default response shape, and a test pins the
debug field is omitted in normal mode.
### T02 - Replace stdlib HTTPServer with ThreadingHTTPServer
```task
id: ADHOC-2026-06-02-T02
status: done
priority: low
state_hub_task_id: "e2b1be30-71f7-4497-9b10-b0f24d37beba"
```
`llm_connect/server.py` uses `http.server.HTTPServer`, which is single-threaded.
Concurrent requests queue behind whatever subprocess or HTTPS call is in flight.
That made today's diagnosis worse: a smoke probe sent while the canary's real
LLM call was running just hung until the canary finished. With Claude CLI calls
taking 60-90s each, queuing is painful.
`http.server.ThreadingHTTPServer` is a drop-in replacement (subclass of
`HTTPServer` + `ThreadingMixIn`). One-line change. Document that adapters must
be thread-safe (the subprocess and HTTPS adapters already are).
Done when concurrent `/execute` calls run in parallel and a regression test
asserts two requests with measurable backend latency complete in roughly the
max of their individual latencies, not the sum.
### T03 - Per-call audit log for replay
```task
id: ADHOC-2026-06-02-T03
status: done
priority: medium
state_hub_task_id: "da4821f0-a876-44ce-9dc3-f3fc67732d0f"
```
When `LLM_CONNECT_AUDIT_DIR=/path` is set, record every `/execute` call as a
JSON file named `{timestamp}-{response_id}.json` containing `{prompt, config,
provider_request, provider_response, parsed_content, latency_seconds}`. A
sibling `python -m llm_connect.replay <file>` command re-runs the parser /
unwrapper / validation against the recorded provider response without making a
new provider call.
Saves quota during diagnosis (today we burned several Sonnet calls just to
re-observe the envelope shape) and gives downstream consumers like
activity-core's instruction executor a "what did the model actually return"
artifact when validation fails.
Promote to a workplan if anyone picks it up — schema for the audit record
needs a small design pass (retention, redaction of API keys in
`provider_request.headers`, file size cap), and the CLI side has its own
ergonomics.
### T04 - Apply param-translation contract to OpenAI and Gemini adapters
```task
id: ADHOC-2026-06-02-T04
status: done
priority: medium
state_hub_task_id: "f8a033e6-22ac-4700-b8d2-43a5d76a3751"
```
Today's `cd4551c` added a `_merge_model_params` helper in the OpenRouter
adapter that whitelists OpenAI Chat Completions fields, translates
`json_schema` to `response_format`, and drops Claude/llm-connect-specific
keys. The same naive `payload.update(config.model_params)` is latent in
`llm_connect/openai.py` and `llm_connect/gemini.py` — neither has been
exercised against an activity-core-style payload yet, but they will 400
the moment they are.
Extract `_merge_model_params` to a shared module (`llm_connect/_payload.py`?)
and use it from all three adapters. Gemini's accepted top-level fields differ
(`generationConfig` wrapper, `safetySettings`, etc.), so the helper either
needs adapter-specific whitelists or a per-adapter translation table. The
shape choice is small but real.
Done when a parametrised test sends the activity-core daily-triage
`model_params` shape (`reasoning_effort`, `max_depth`, `json_schema`) through
each adapter and asserts the resulting payload is provider-accepted (no
forbidden top-level fields, schema in the right wrapper).
### T05 - Provider-agnostic structured-output smoke test in CI
```task
id: ADHOC-2026-06-02-T05
status: done
priority: medium
state_hub_task_id: "5d53dbb4-b374-45fe-b81c-ff0b222ca74f"
```
CI today does not exercise the round-trip from `model_params.json_schema`
through any real provider. The `1b01f0e` model-routing bug — where
`--model anthropic/claude-sonnet-4` silently became `gpt-4` because of an
inverted comparison — only surfaced through the canary, because none of the
adapter tests check the actual `payload["model"]` value end-to-end.
Add a test (gated on each provider's API key being present in CI env) that:
1. Starts the server with `--provider {p} --model {m}` for each registered
adapter.
2. POSTs a fixed prompt with `model_params.json_schema` requiring
`{summary, recommendations}` (mirroring activity-core's canary shape).
3. Asserts the response parses cleanly against the schema.
4. Asserts `response.model` matches what was configured at startup, not the
`RunConfig.model_name` default.
Without API keys present, the test mocks the provider HTTP layer and asserts
the *payload* matches expectations. That catches routing/translation bugs
without provider cost.
Done when a smoke probe shaped like today's CUST-WP-0045 canary would have
failed CI on `1b01f0e` (the routing bug) and on `cd4551c` (the translation
bug) before either was merged.
### T06 - Document required model_params translation in adapter README
```task
id: ADHOC-2026-06-02-T06
status: done
priority: low
state_hub_task_id: "33fcb951-d7ab-4d3c-8d67-9eebd986c711"
```
Adapter authors and callers both need to know:
- which `model_params` keys are pass-through to the underlying provider
- which are translated (`json_schema``response_format` for OpenAI-shaped
providers, → tool-use for native Anthropic, etc.)
- which are dropped (Claude-only `reasoning_effort`, llm-connect's
`max_depth`)
- the strict-mode tradeoff and how to opt back in
Today's bug chain happened partly because the activity-core team assumed
`model_params` was a pass-through and the llm-connect team assumed callers
would only send OpenAI-valid fields. Codify the contract in
`docs/adapter-model-params.md` (new file) or extend the existing
`ARCHITECTURE-LAYERS.md`.
Done when a new adapter author can read the doc and know what their
`_merge_model_params` implementation must support.
## Implementation Notes
Completed on 2026-06-03:
- Added opt-in `/execute` debug envelopes via `LLM_CONNECT_DEBUG=1` or
`?debug=1`, with redacted provider request/response capture and adapter
transformation records.
- Switched serve mode to `ThreadingHTTPServer` and added a concurrency
regression test.
- Added `LLM_CONNECT_AUDIT_DIR` per-call audit records plus
`python -m llm_connect.replay` for parser/unwrapper replay.
- Extracted shared OpenAI-compatible and Gemini payload translation helpers
and wired OpenRouter, OpenAI, and Gemini through them.
- Added CI-safe structured-output smoke tests that mock provider HTTP calls
and assert model routing plus payload shape.
- Documented the adapter `model_params` contract in
`docs/adapter-model-params.md`.

View File

@@ -0,0 +1,421 @@
---
id: LLM-WP-0006
type: workplan
title: "Activity-Core Always-On LLM Endpoint"
domain: agents
repo: llm-connect
status: finished
owner: codex
topic_slug: activity-core-llm-endpoint
planning_priority: high
planning_order: 6
created: "2026-06-07"
updated: "2026-06-18"
depends_on_workplans:
- LLM-WP-0003
related_workplans:
- ACTIVITY-WP-0006
state_hub_workstream_id: "8de71d58-1193-424f-8338-a9aa4e173c5b"
---
# LLM-WP-0006 - Activity-Core Always-On LLM Endpoint
**status:** finished
**owner:** codex
## Purpose
Provide an operator-approved, always-on `llm-connect` HTTP endpoint for
`activity-core` daily WSJF triage. The service must be reachable from the
`activity-core` Kubernetes namespace, expose the existing `GET /health` and
`POST /execute` contract, support the `custodian-triage-balanced` runtime
profile, and return JSON content that satisfies the daily triage schema without
leaking provider credentials or secret material into Git, logs, or State Hub.
This is not a new public API. The current `llm_connect.server` contract is a
lightweight internal service surface; this workplan turns it into a durable
internal dependency with profile resolution, deployable artifacts, smoke tests,
and activity-core handoff evidence.
## Demand Signal
State Hub messages from `activity-core` on 2026-06-07 requested a stable
`llm-connect` endpoint before `ACTIVITY-WP-0006/T03` can collect clean scheduled
WSJF evidence.
Required behavior from those messages:
- `GET /health` returns 200 from inside the activity-core runtime path.
- `POST /execute` accepts activity-core `RunConfig` payloads with
`model_name=custodian-triage-balanced`, `temperature=0.2`,
`max_tokens=1800`, `max_depth=2`, `model_params.reasoning_effort=medium`,
and `model_params.json_schema` for the daily triage report.
- The response contains a string `content` field whose value is valid JSON
matching the daily triage schema.
- Provider credentials stay outside Git and outside State Hub
messages/progress.
- The stable service URL can be handed to activity-core as `LLM_CONNECT_URL`.
- The service fits within `LLM_CONNECT_TIMEOUT_SECONDS=300` and surfaces useful
provider/transport errors without exposing secrets.
## Current Repo State
Already present:
- `llm_connect/server.py` exposes `GET /health` and `POST /execute` via
`ThreadingHTTPServer`.
- `/execute` forwards `RunConfig` fields including `max_depth` and
`model_params`.
- Structured-output helpers translate `model_params.json_schema` for OpenAI,
OpenRouter, Gemini, and Claude Code CLI.
- Debug and audit modes redact provider request headers and can replay captured
adapter transformations.
Missing for this request:
- No named runtime profile resolver for `custodian-triage-balanced`.
- No container or Kubernetes deployment artifact for an always-on service.
- No documented secret/config injection path for the cluster service.
- No activity-core daily triage fixture or in-cluster smoke job.
- No committed handoff document naming the final stable URL and verification
evidence.
## T01 - Lock Activity-Core Contract Fixture
```task
id: LLM-WP-0006-T01
title: "Lock activity-core daily WSJF request and schema fixture"
priority: high
status: done
state_hub_task_id: "f1d21c4b-2df3-4da8-8e6e-418fd7998a63"
```
Capture a non-secret fixture for the exact `POST /execute` request used by
`daily-statehub-wsjf-triage`, including the daily triage JSON schema, timeout
budget, expected response shape, and minimum prompt fields. Store only schema
and dummy prompt/evidence values in the repo.
Done when a fixture can be used by tests and smoke scripts without any provider
credentials or live State Hub data, and the workplan notes identify the
activity-core consumer contract it represents.
## T02 - Add Named Runtime Profile Resolution
```task
id: LLM-WP-0006-T02
title: "Resolve custodian-triage-balanced to provider, model, and RunConfig defaults"
priority: high
status: done
state_hub_task_id: "4538bae3-e8cf-4aa6-9056-270fd8d54caa"
```
Add a small named-profile layer for server mode so activity-core can send
`model_name=custodian-triage-balanced` while operators configure the underlying
provider/model out of band. The profile should merge request overrides with
profile defaults for temperature, max tokens, max depth, timeout, and portable
`model_params`, while preserving the existing direct provider/model behavior.
Done when unit tests prove `custodian-triage-balanced` resolves to the selected
adapter/model without hard-coding provider secrets, unknown profile names fail
with a clear non-secret error, and existing `/execute` behavior remains
backward compatible.
## T03 - Harden Server Responses for Operations
```task
id: LLM-WP-0006-T03
title: "Return useful non-secret provider and transport errors from server mode"
priority: high
status: done
state_hub_task_id: "d4adfe3b-6a57-4184-86fd-2eb11979f075"
```
Review server error handling for provider configuration failures, timeouts,
HTTP/API failures, invalid profile config, and malformed structured-output
responses. Keep the normal `LLMResponse.to_dict()` success shape, but make
errors actionable for operators and consumers without echoing API keys, bearer
tokens, request headers, or prompt bodies by default.
Done when tests cover sanitized error responses for configuration, timeout,
provider/API, and profile validation failures, and debug/audit mode remains
opt-in and redacted.
## T04 - Package the Always-On Service
```task
id: LLM-WP-0006-T04
title: "Add container packaging and service entrypoint for llm-connect server"
priority: high
status: done
state_hub_task_id: "38822b17-fa58-4583-939f-26e59b9c93c7"
```
Create the deployable service artifact: container build definition, non-root
runtime, healthcheck, explicit listen host/port, and environment-driven profile
configuration. Keep provider keys injected only at runtime through the approved
cluster secret path.
Done when the image builds locally, starts with mock and at least one real
provider configuration path, passes `GET /health`, and can receive a fixture
`POST /execute` without writing secrets to stdout, image layers, or committed
files.
## T05 - Add Kubernetes Deployment Surface
```task
id: LLM-WP-0006-T05
title: "Provide Kubernetes Deployment, Service, probes, and secret references"
priority: high
status: done
state_hub_task_id: "f9743610-b573-41b8-952f-b27319acb3e3"
```
Add the cluster deployment surface for an internal `llm-connect` service:
Deployment, Service, readiness/liveness probes, ConfigMap/profile settings,
Secret references for provider credentials, resource requests/limits, and
network access scoped to the activity-core namespace. Use the repository's
current deployment conventions if a shared Railiance chart location is selected
during implementation.
Done when an operator can apply the manifests without editing secret values
into Git, the service exposes stable cluster DNS, and `GET /health` succeeds
from an activity-core pod or equivalent smoke pod.
## T06 - Build Smoke Tests and Validation Scripts
```task
id: LLM-WP-0006-T06
title: "Validate health, fixture execute, JSON schema content, and timeout budget"
priority: high
status: done
state_hub_task_id: "f046d68b-97f3-4471-a1f6-f1ab351ec448"
```
Add smoke tooling that can run locally against mock/profile mode and in-cluster
against the deployed Service. It should check health, post the daily triage
fixture, parse `response.content` as JSON, validate it against the daily triage
schema, and report latency relative to the 300 second activity-core timeout.
Done when the smoke path produces a clear pass/fail summary without dumping
secret headers or provider credentials, and failed JSON/schema validation is
reported distinctly from provider transport failure.
## T07 - Coordinate Activity-Core Handoff
```task
id: LLM-WP-0006-T07
title: "Publish verified LLM_CONNECT_URL handoff and activity-core smoke evidence"
priority: high
status: done
state_hub_task_id: "92e043f0-5ca8-4c2d-b8f6-dd5fbf8ccb62"
```
After the service is deployed and smoke-tested, hand the stable URL to the
activity-core/railiance-cluster operator for `LLM_CONNECT_URL`. Coordinate one
manual or smoke daily WSJF run and record non-secret evidence that a State Hub
`daily_triage` event was emitted.
Done when the final URL value is documented in the appropriate operator-owned
config handoff, a fixture `POST /execute` succeeds from the activity-core
namespace, and activity-core has enough evidence to start counting clean 07:20
Europe/Berlin scheduled runs toward `ACTIVITY-WP-0006/T03`.
## Scope Guardrails
In scope:
- Server-mode profile resolution needed by activity-core.
- Internal service packaging and Kubernetes deployment artifacts.
- Redacted diagnostics and operator-safe error responses.
- Health and execute smoke tooling using non-secret fixtures.
- Coordination notes for the final `LLM_CONNECT_URL` handoff.
Out of scope:
- Publishing `llm-connect` as a public internet service.
- Storing provider credentials, live prompts, or State Hub event payloads in
Git.
- Replacing activity-core's scheduler or WSJF triage logic.
- Guaranteeing three scheduled production runs; this plan provides the
endpoint and first smoke evidence, while scheduled-run collection remains
activity-core ownership.
- Choosing or rotating production provider credentials; that is an operator
secret-management action.
## Acceptance
- `python -m llm_connect.server` or the packaged service starts an internal
endpoint with a configured `custodian-triage-balanced` profile.
- `GET /health` returns 200 locally and from inside the activity-core runtime
network path.
- A fixture `POST /execute` with the daily WSJF schema returns an
`LLMResponse` whose `content` field is a string containing schema-valid JSON.
- Provider failures, timeouts, and profile/config errors return useful
non-secret error bodies.
- The deployed Service has readiness/liveness probes, runtime-only secret
injection, and a documented stable URL for activity-core.
- A manual or smoke daily WSJF run emits non-secret evidence of a State Hub
`daily_triage` event.
## Risks and Open Questions
- The final provider/model behind `custodian-triage-balanced` needs operator
approval and runtime secret availability. The profile layer should keep that
choice configurable.
- If the chosen provider does not reliably honor the supplied JSON schema, the
smoke path may need a retry or repair strategy; that should be explicit and
bounded if added.
- The repository currently has no deployment directory. Implementation must
decide whether Kubernetes artifacts live here, in a Railiance deployment repo,
or are split between code-owned defaults here and environment-owned overlays
elsewhere.
- `llm_connect.server` is stdlib HTTP and thread-per-request. That is likely
sufficient for daily WSJF traffic, but sustained multi-consumer use may need
a later ASGI/worker model.
## Implementation Notes
2026-06-07:
- Added non-secret activity-core fixtures under `fixtures/activity_core/` using
the `daily-triage-report` schema from activity-core's Railiance runtime.
- Added `llm_connect.profiles` with `custodian-triage-balanced` profile
dispatch, env/file profile overrides, and metadata on profiled responses.
- Updated `llm_connect.server` so CLI serve mode enables runtime profiles by
default, reads host/port/provider/model defaults from env, validates configs
before execution, and returns structured sanitized error bodies.
- Added `LLM_CONNECT_MOCK_RESPONSE` support for local mock server smokes.
- Added standard-library smoke tooling in
`scripts/smoke_activity_core_endpoint.py`, plus tests that run the smoke path
against an in-process profiled mock HTTP server.
- Added `Containerfile`, `.dockerignore`, and a Kubernetes overlay at
`deploy/k8s/activity-core-llm-connect/`.
- Added handoff docs in `docs/activity-core-llm-endpoint.md`.
- Verification completed locally:
`python3 -m pytest tests/test_profiles.py tests/test_server.py
tests/test_activity_core_smoke.py tests/test_factory.py
tests/test_package_exports.py`;
`docker build --progress=plain -f Containerfile -t
llm-connect:wp0006-smoke .`; and `kubectl kustomize
deploy/k8s/activity-core-llm-connect`.
Live cluster evidence:
- Imported `docker.io/library/llm-connect:latest` into the actual Railiance k3s
node runtime on `coulombcore` (`92.205.130.254`) and updated the overlay to
use that normalized image reference with `imagePullPolicy: Never`.
- Applied the `activity-core` namespace deployment surface: ConfigMap, Secret
reference, Service, Deployment, readiness/liveness probes, and NetworkPolicy.
- Verified the live Deployment is `1/1` ready with image
`docker.io/library/llm-connect:latest`.
- Verified the stable in-cluster URL
`http://llm-connect.activity-core.svc.cluster.local:8080` returns
`{"status": "ok"}` for `GET /health` from the activity-core namespace path.
- Verified the activity-core fixture smoke reaches `POST /execute`; it fails
with a structured `configuration_error` until the provider credential Secret
is populated. No Secret values were inspected or recorded.
Remaining blocked live gate:
- `LLM-WP-0006-T07` still needs the runtime provider Secret populated outside
Git/State Hub, a successful fixture `POST /execute` returning schema-valid
JSON, the verified URL written to activity-core runtime config, and a
manual/smoke daily WSJF run that emits a non-secret State Hub `daily_triage`
event.
2026-06-07 follow-up:
- Submitted State Hub message `8e644cb0-1af4-482c-8da7-7061080d21bc` to
`railiance-cluster` requesting image publication, runtime provider Secret
creation outside Git/State Hub, overlay apply or porting, in-namespace
`/health`, and fixture smoke evidence for `LLM-WP-0006-T05`.
- Submitted State Hub message `ff798e7c-b8ef-4a3f-ab92-00bf09410534` to
`activity-core` requesting `LLM_CONNECT_URL` / timeout consumption after the
cluster smoke, a manual or smoke daily WSJF run, State Hub `daily_triage`
evidence, working-memory verification, and continuation of the three clean
scheduled 07:20 Europe/Berlin runs for `ACTIVITY-WP-0006-T03`.
- Submitted State Hub message `02033d4d-3cb0-41c8-b390-7b9e8471421e` to
`railiance-cluster` confirming the live Deployment, stable URL, and `/health`
evidence after importing the image into the actual `coulombcore` k3s node.
- Submitted State Hub message `771afe14-a2d0-46ca-b905-52018bf86c62` to
`activity-core` with the verified URL and the remaining provider Secret gate
for schema-valid `POST /execute` and `daily_triage` evidence.
2026-06-17 recheck:
- Verified the live `coulombcore` Kubernetes path is reachable and the
`activity-core` namespace `llm-connect` Deployment remains `1/1` available
with Service `llm-connect` on port `8080`.
- Confirmed the `llm-connect-provider-secrets` Secret object exists but still
reports `DATA 0`; no Secret values were inspected.
- Re-ran the in-namespace fixture smoke with the node-local image. The first
corrected pod needed `--image-pull-policy=Never` because the `:latest` tag
otherwise attempted a Docker Hub pull. With the local image, the smoke reached
`/execute` and failed safely with
`configuration_error: Adapter rejected RunConfig`.
- State Hub now also has a 2026-06-16 `daily_triage` event from
`activity-core` showing `LLM_CONNECT_URL is not configured`, and the local
activity-core runtime manifest still has `LLM_CONNECT_URL: ""`.
- `LLM-WP-0006-T07` therefore remains externally blocked until the provider
Secret is populated outside Git/State Hub, activity-core consumes
`http://llm-connect.activity-core.svc.cluster.local:8080` with
`LLM_CONNECT_TIMEOUT_SECONDS=300`, the fixture smoke returns schema-valid
JSON, and a non-secret `daily_triage` evidence event is recorded.
2026-06-18 recheck:
- activity-core has repo-local work to consume the stable URL:
`actcore-runtime-config` now sets
`LLM_CONNECT_URL=http://llm-connect.activity-core.svc.cluster.local:8080`
and `LLM_CONNECT_TIMEOUT_SECONDS=300`.
- The live `activity-core` namespace has not yet been reconciled to that
activity-core runtime surface; live deployments currently show only
`deployment.apps/llm-connect`, and live ConfigMaps show only
`kube-root-ca.crt` and `llm-connect-config`.
- The live `llm-connect-provider-secrets` Secret still reports `DATA 0`; no
Secret values were inspected.
- ops-warden's credential-routing guidance says LLM provider API keys are not
an ops-warden issuance task. The remaining credential gate belongs to the
approved operator/OpenBao-to-Kubernetes Secret path for
`activity-core/llm-connect-provider-secrets`.
- `LLM-WP-0006-T07` remains blocked until the provider Secret is populated,
the activity-core runtime is reconciled with the URL/timeout config, the
fixture smoke returns schema-valid JSON from inside the namespace, and
activity-core records non-secret `daily_triage` evidence.
2026-06-18 closure:
- Populated-provider state is now live: `activity-core/llm-connect-provider-secrets`
reports `DATA 1`; no Secret values were inspected or recorded.
- Updated the OpenRouter structured-output path to request strict JSON schema
output and to set `provider.require_parameters=true` for schema calls, so
OpenRouter routes only to providers that support the requested structured
output parameters.
- OpenRouter model metadata showed the previous
`anthropic/claude-sonnet-4` profile model does not advertise
`response_format`/`structured_outputs`; switched the activity-core profile
and Kubernetes ConfigMap defaults to `google/gemini-2.5-flash`, which does.
- Rebuilt `docker.io/library/llm-connect:latest` from `Containerfile`,
imported it into the `coulombcore` k3s image store, applied the updated
non-secret `llm-connect-config` ConfigMap, and rolled out
`deployment/llm-connect`.
- Verified live ConfigMap values:
`LLM_CONNECT_MODEL=google/gemini-2.5-flash` and
`LLM_CONNECT_CUSTODIAN_TRIAGE_MODEL=google/gemini-2.5-flash`.
- Final in-namespace smoke passed against
`http://llm-connect.activity-core.svc.cluster.local:8080` with:
`smoke: pass health=ok latency_seconds=2.147 recommendations=1`.
- Cleaned up the one-shot smoke pod after collecting logs. The llm-connect
endpoint handoff is complete; collecting scheduled `daily_triage` evidence
now belongs to activity-core / `ACTIVITY-WP-0006`.
## Closure Notes
After this workplan file is added or task statuses change, ask the custodian
operator to run from `~/state-hub`:
```bash
make fix-consistency REPO=llm-connect
```
That syncs file-backed workplan state into the State Hub cache.

View File

@@ -1,6 +1,20 @@
---
id: LLM-WP-0001
type: workplan
title: llm-connect — Foundation & GAAF Baseline
domain: agents
status: completed
owner: llm-connect
created: 2026-04-01
repo: llm-connect
planning_priority: high
planning_order: 1
state_hub_workstream_id: f7f08327-753f-4175-8591-ffa1c3188ebc
---
# LLM-WP-0001 — Foundation & GAAF Baseline # LLM-WP-0001 — Foundation & GAAF Baseline
**status:** active **status:** completed
**owner:** llm-connect **owner:** llm-connect
**repo:** llm-connect **repo:** llm-connect
**created:** 2026-04-01 **created:** 2026-04-01
@@ -13,6 +27,102 @@ and state-hub housekeeping.
## Tasks ## Tasks
```task
id: T01
title: 'Create SCOPE.md'
priority: high
status: done
state_hub_task_id: "c38c5a79-4ce5-4088-9a21-ac65e09b12ba"
```
```task
id: T02
title: 'Fill .claude/rules/ stubs: architecture.md, stack-and-commands.md, repo-boundary.md'
priority: high
status: done
state_hub_task_id: "6a15c794-d0f7-4d9c-a3ac-850f8c5bd5e9"
```
```task
id: T03
title: 'Create ARCHITECTURE-LAYERS.md with layer map, scorecard stub, next-review date'
priority: high
status: done
state_hub_task_id: "af1c63ac-e4be-495a-9fdb-68eddebfcb75"
```
```task
id: T04
title: 'Create /contracts/ tree (core/, functional/, config/)'
priority: high
status: done
state_hub_task_id: "da5a7986-5c47-4c4c-a8f6-a58956127535"
```
```task
id: T05
title: 'Core contract doc: LLMAdapter interface invariants, RunConfig/LLMResponse field contracts'
priority: high
status: done
state_hub_task_id: "01237203-0582-4bc4-a308-075e991e8e99"
```
```task
id: T06
title: 'Functional contract stubs for all 4 adapters + embedding adapters (maturity: Beta)'
priority: medium
status: done
state_hub_task_id: "2bee5174-d3d7-4267-9cee-6e0e9b5cc731"
```
```task
id: T07
title: 'Create tests/ with conftest.py, wire pytest in pyproject.toml'
priority: high
status: done
state_hub_task_id: "b6dccf3e-8742-486e-a6a7-82577866a3bc"
```
```task
id: T08
title: 'Unit tests: RunConfig, LLMResponse, MockLLMAdapter, full exception hierarchy'
priority: high
status: done
state_hub_task_id: "cc05b67d-f956-458a-908f-2ff58b1d33d3"
```
```task
id: T09
title: 'Unit tests: create_adapter (all providers + unknown provider error), create_embedding_adapter'
priority: high
status: done
state_hub_task_id: "8f9ec054-79ab-411d-8204-9d764bbbed98"
```
```task
id: T10
title: 'Add ruff, mypy to dev deps in pyproject.toml'
priority: medium
status: done
state_hub_task_id: "044ee879-6baa-42fd-a0a4-a43dac0eacbb"
```
```task
id: T11
title: 'CI workflow: pytest + ruff + mypy'
priority: medium
status: done
state_hub_task_id: "699eef00-e9df-4de0-b7e6-61cfaace9617"
```
```task
id: T12
title: 'State hub: register this host path, SBOM refresh'
priority: low
status: done
state_hub_task_id: "c0853a23-52ae-499e-9a49-e7b65749b508"
```
| ID | Title | Priority | Status | | ID | Title | Priority | Status |
|-----|-------|----------|--------| |-----|-------|----------|--------|
| T01 | Create `SCOPE.md` | high | done | | T01 | Create `SCOPE.md` | high | done |

View File

@@ -1,6 +1,20 @@
---
id: LLM-WP-0002
type: workplan
title: llm-connect — Core Extensions (FR-4 BudgetTracker + FR-3 async)
domain: agents
status: completed
owner: llm-connect
created: 2026-04-01
repo: llm-connect
planning_priority: high
planning_order: 2
state_hub_workstream_id: 448fa379-eb9e-4808-b3fa-0078f1e4eaba
---
# LLM-WP-0002 — Core Extensions (FR-4 + FR-3) # LLM-WP-0002 — Core Extensions (FR-4 + FR-3)
**status:** active **status:** completed
**owner:** llm-connect **owner:** llm-connect
**repo:** llm-connect **repo:** llm-connect
**created:** 2026-04-01 **created:** 2026-04-01
@@ -28,26 +42,114 @@ Core contract doc (from WP-0001 T05) must be updated after each change.
## Tasks ## Tasks
```task
id: T01
title: 'BudgetTracker dataclass: total, spent, remaining(), thread-safe increment'
priority: high
status: done
state_hub_task_id: "ae27c363-339a-4f78-9737-cf872698f6d8"
```
```task
id: T02
title: 'LLMBudgetExceededError(LLMError) in exceptions.py'
priority: high
status: done
state_hub_task_id: "ea6f6ef7-2cb2-48e2-b9c9-f2b84a1a242b"
```
```task
id: T03
title: 'Optional budget_tracker field on RunConfig'
priority: high
status: done
state_hub_task_id: "fe6dbb73-5d04-45e6-aa91-5eff79aae7ee"
```
```task
id: T04
title: 'Enforcement: adapters check/update tracker, raise LLMBudgetExceededError when exceeded'
priority: high
status: done
state_hub_task_id: "8fd21bc2-598e-4449-8c86-eacde760e23f"
```
```task
id: T05
title: 'Update Core contract doc for BudgetTracker and RunConfig changes'
priority: medium
status: done
state_hub_task_id: "e15745f5-9bb7-45d6-a36b-3a345fb0e9f1"
```
```task
id: T06
title: 'Tests: single call, delegation chain, exceeded error, multi-adapter shared tracker'
priority: high
status: done
state_hub_task_id: "5af37ade-3dd0-4ce9-8ead-be9887913bab"
```
```task
id: T07
title: 'Add async_execute_prompt to LLMAdapter ABC with default executor fallback'
priority: high
status: done
state_hub_task_id: "e221e630-658f-4adb-9f00-7b7df7ab8cb4"
```
```task
id: T08
title: 'Native async override in OpenAIAdapter, GeminiAdapter, OpenRouterAdapter'
priority: high
status: done
state_hub_task_id: "a75c2b2a-e4ef-4cbd-9c5f-7e98c8d3d7e8"
```
```task
id: T09
title: 'Native async for ClaudeCodeAdapter via asyncio.create_subprocess_exec'
priority: high
status: done
state_hub_task_id: "1c50889f-28ed-4c6e-a788-1fc7dcc5a2c3"
```
```task
id: T10
title: 'Update Core contract doc for async_execute_prompt'
priority: medium
status: done
state_hub_task_id: "fa4f9e80-ddee-4d05-a239-fe09e633b0cb"
```
```task
id: T11
title: 'Tests: asyncio.gather over N adapters, timeout propagation, budget interaction'
priority: high
status: done
state_hub_task_id: "bca78609-7f7c-4548-8857-a72e4c760dc6"
```
### FR-4 — BudgetTracker ### FR-4 — BudgetTracker
| ID | Title | Priority | Status | | ID | Title | Priority | Status |
|-----|-------|----------|--------| |-----|-------|----------|--------|
| T01 | `BudgetTracker` dataclass: `total`, `spent`, `remaining()`, thread-safe increment | high | todo | | T01 | `BudgetTracker` dataclass: `total`, `spent`, `remaining()`, thread-safe increment | high | done |
| T02 | `LLMBudgetExceededError(LLMError)` in `exceptions.py` | high | todo | | T02 | `LLMBudgetExceededError(LLMError)` in `exceptions.py` | high | done |
| T03 | Optional `budget_tracker: BudgetTracker \| None` field on `RunConfig` | high | todo | | T03 | Optional `budget_tracker: BudgetTracker \| None` field on `RunConfig` | high | done |
| T04 | Enforcement: each adapter checks/updates tracker around call; raises on exceeded | high | todo | | T04 | Enforcement: each adapter checks/updates tracker around call; raises on exceeded | high | done |
| T05 | Update Core contract doc | medium | todo | | T05 | Update Core contract doc | medium | done |
| T06 | Tests: single call, delegation chain (A→B→C shared tracker), exceeded error, multi-adapter | high | todo | | T06 | Tests: single call, delegation chain (A→B→C shared tracker), exceeded error, multi-adapter | high | done |
### FR-3 — async_execute_prompt ### FR-3 — async_execute_prompt
| ID | Title | Priority | Status | | ID | Title | Priority | Status |
|-----|-------|----------|--------| |-----|-------|----------|--------|
| T07 | Add `async_execute_prompt` to `LLMAdapter` ABC with default executor fallback | high | todo | | T07 | Add `async_execute_prompt` to `LLMAdapter` ABC with default executor fallback | high | done |
| T08 | Native async override in `OpenAIAdapter`, `GeminiAdapter`, `OpenRouterAdapter` | high | todo | | T08 | Native async override in `OpenAIAdapter`, `GeminiAdapter`, `OpenRouterAdapter` | high | done |
| T09 | Native async for `ClaudeCodeAdapter` via `asyncio.create_subprocess_exec` | high | todo | | T09 | Native async for `ClaudeCodeAdapter` via `asyncio.create_subprocess_exec` | high | done |
| T10 | Update Core contract doc | medium | todo | | T10 | Update Core contract doc | medium | done |
| T11 | Tests: `asyncio.gather` over N adapters, timeout propagation, budget interaction | high | todo | | T11 | Tests: `asyncio.gather` over N adapters, timeout propagation, budget interaction | high | done |
## Exit criteria ## Exit criteria

View File

@@ -1,6 +1,20 @@
---
id: LLM-WP-0003
type: workplan
title: llm-connect — Functional Extensions (FR-2 RoutingPolicy + FR-1 HTTP server)
domain: agents
status: completed
owner: llm-connect
created: 2026-04-01
repo: llm-connect
planning_priority: high
planning_order: 3
state_hub_workstream_id: 7b463cdc-40a2-4cc5-8b55-b59cc5ae3443
---
# LLM-WP-0003 — Functional Extensions (FR-2 + FR-1) # LLM-WP-0003 — Functional Extensions (FR-2 + FR-1)
**status:** active **status:** completed
**owner:** llm-connect **owner:** llm-connect
**repo:** llm-connect **repo:** llm-connect
**created:** 2026-04-01 **created:** 2026-04-01
@@ -22,26 +36,114 @@ Both additions are Functional-layer under GAAF-2026:
## Tasks ## Tasks
```task
id: T01
title: 'RoutingPolicy data model: rules list with task_type, prefer, max_cost_per_1k, fallback'
priority: high
status: done
state_hub_task_id: "85cf92fd-cddd-4e19-8782-970f6480a37f"
```
```task
id: T02
title: 'policy.resolve(task_type) returns configured LLMAdapter'
priority: high
status: done
state_hub_task_id: "352701ce-4b21-4f5d-a22e-462136e58fd2"
```
```task
id: T03
title: 'Export RoutingPolicy from llm_connect.__init__ and update __all__'
priority: medium
status: done
state_hub_task_id: "baeb9b39-7fee-4f2b-86cc-ce64ff9e9b95"
```
```task
id: T04
title: 'Functional contract doc for RoutingPolicy'
priority: medium
status: done
state_hub_task_id: "aa4488c6-950e-4cea-99b1-89defa4677ce"
```
```task
id: T05
title: 'Tests: rule match, cost-cap fallback, unknown task_type fallback, no-match default'
priority: high
status: done
state_hub_task_id: "a4ad9c9e-64a4-44f0-85f3-b9cfe9ef59f7"
```
```task
id: T06
title: 'Design /execute JSON schema (request: provider, model, prompt, config; response: LLMResponse)'
priority: high
status: done
state_hub_task_id: "cf79bce2-8d1a-4708-90b2-5e6569908b14"
```
```task
id: T07
title: 'Implement llm_connect/server.py: POST /execute, GET /health'
priority: high
status: done
state_hub_task_id: "c91964ab-7366-4b34-acd4-1ee12f96881e"
```
```task
id: T08
title: 'python -m llm_connect.server --port N --provider X --model Y CLI entry point'
priority: high
status: done
state_hub_task_id: "e3115bb4-cf3b-4ca0-9992-136e317068ac"
```
```task
id: T09
title: 'Add server optional dep (httpx or aiohttp) to pyproject.toml'
priority: medium
status: done
state_hub_task_id: "2caf5531-8e10-40e9-a595-8652882a10e0"
```
```task
id: T10
title: 'Functional contract doc: HTTP API schema (request/response shapes, error codes)'
priority: medium
status: done
state_hub_task_id: "dc3c81c2-698d-4fee-b1dd-1af156a4276f"
```
```task
id: T11
title: 'Tests: server POST round-trip (MockAdapter), GET /health, error responses'
priority: high
status: done
state_hub_task_id: "848a1622-abdd-4938-8bb4-3da27f5f9867"
```
### FR-2 — RoutingPolicy ### FR-2 — RoutingPolicy
| ID | Title | Priority | Status | | ID | Title | Priority | Status |
|-----|-------|----------|--------| |-----|-------|----------|--------|
| T01 | `RoutingPolicy` data model: `rules` list with `task_type`, `prefer`, `max_cost_per_1k`, `fallback` | high | todo | | T01 | `RoutingPolicy` data model: `rules` list with `task_type`, `prefer`, `max_cost_per_1k`, `fallback` | high | done |
| T02 | `policy.resolve(task_type)` → returns configured `LLMAdapter` | high | todo | | T02 | `policy.resolve(task_type)` → returns configured `LLMAdapter` | high | done |
| T03 | Export from `llm_connect.__init__` and update `__all__` | medium | todo | | T03 | Export from `llm_connect.__init__` and update `__all__` | medium | done |
| T04 | Functional contract doc for `RoutingPolicy` | medium | todo | | T04 | Functional contract doc for `RoutingPolicy` | medium | done |
| T05 | Tests: rule match, cost-cap fallback, unknown task_type fallback, no-match default | high | todo | | T05 | Tests: rule match, cost-cap fallback, unknown task_type fallback, no-match default | high | done |
### FR-1 — HTTP serve mode ### FR-1 — HTTP serve mode
| ID | Title | Priority | Status | | ID | Title | Priority | Status |
|-----|-------|----------|--------| |-----|-------|----------|--------|
| T06 | Design `/execute` JSON schema (request: provider, model, prompt, config; response: LLMResponse fields) | high | todo | | T06 | Design `/execute` JSON schema (request: provider, model, prompt, config; response: LLMResponse fields) | high | done |
| T07 | Implement `llm_connect/server.py` — minimal HTTP server, `POST /execute`, `GET /health` | high | todo | | T07 | Implement `llm_connect/server.py` — minimal HTTP server, `POST /execute`, `GET /health` | high | done |
| T08 | `python -m llm_connect.server --port N --provider X --model Y` CLI entry point | high | todo | | T08 | `python -m llm_connect.server --port N --provider X --model Y` CLI entry point | high | done |
| T09 | Add `httpx` or `aiohttp` server dep under `[project.optional-dependencies] server` | medium | todo | | T09 | Add `httpx` or `aiohttp` server dep under `[project.optional-dependencies] server` | medium | done |
| T10 | Functional contract doc (API schema — request/response shapes, error codes) | medium | todo | | T10 | Functional contract doc (API schema — request/response shapes, error codes) | medium | done |
| T11 | Tests: spin up server in subprocess or via `TestClient`, POST round-trip (MockAdapter), error responses | high | todo | | T11 | Tests: spin up server in subprocess or via `TestClient`, POST round-trip (MockAdapter), error responses | high | done |
## Exit criteria ## Exit criteria

View File

@@ -0,0 +1,368 @@
---
id: LLM-WP-0004
type: workplan
title: Adaptive Cost-Quality Routing
domain: agents
status: completed
owner: llm-connect
created: 2026-05-17
repo: llm-connect
planning_priority: high
planning_order: 4
state_hub_workstream_id: e1807fab-e29e-4517-b362-95737a96582d
---
# LLM-WP-0004 — Adaptive Cost-Quality Routing
**status:** completed
**owner:** llm-connect
**repo:** llm-connect
**created:** 2026-05-17
**depends-on:** LLM-WP-0003 (RoutingPolicy primitive)
## Purpose
Provide reusable primitives that let a consumer route each task to the
cheapest model whose observed output quality clears a per-task bar, with
the local Claude Code session (`ClaudeCodeAdapter`) available as the
baseline-quality oracle. The current `RoutingPolicy` (LLM-WP-0003) is
static: rules and cost caps are hand-authored. This workplan adds the
observation, grading, and adaptive-selection layer that learns *which*
model is good enough for which task type.
Demand signal: `infospace-bench` is about to scale from one-chapter
smoke runs to multi-chapter and full-book infospace generation across
multiple workflow stages (summarise, extract entities, extract
relations, evaluate). Each stage has very different quality / cost
trade-offs, and the consumer needs llm-connect to pick the right model
per stage instead of hard-coding one model for the whole run.
## Scope guardrails (read this before adding tasks)
llm-connect ships *primitives*, not consumer policy.
In scope here:
- Data models for quality observations and grading results
- A reusable observation ledger (persistent, append-only, file-backed)
- A `BaselineGrader` that pairs a baseline adapter with a candidate
adapter and emits a structured quality delta
- A small built-in grader catalogue (exact-match, embedding similarity,
LLM-as-judge wrapper)
- An `AdaptiveRoutingPolicy` that extends `RoutingPolicy` by consulting
the ledger to pick the cheapest adapter whose observed quality for a
task type still clears a configured threshold
- A shadow-mode wrapper adapter for collecting observations in
production without changing caller behaviour
Out of scope (belongs in consumer repos):
- Task-type taxonomy (callers name their tasks)
- Quality thresholds per task type (callers set their own bars)
- Choice of baseline (callers wire whichever adapter they trust)
- When to re-grade (callers decide; this repo just exposes ledger TTL
and refresh helpers)
- Cost accounting for billing or budgets beyond a per-call estimate
## GAAF notes
All additions are Functional-layer per GAAF-2026. Core stays untouched.
Each new module gets a functional contract doc under
`contracts/functional/`. Maturity on release: Beta — `infospace-bench`
is the first known consumer; the API may shift before any second
consumer (`inter-hub`, `markitect`) adopts it.
## Tasks
The fenced `task` blocks below are the State Hub registration index. Keep them
in sync with the detailed task tables that follow.
```task
id: T01
title: 'QualityObservation dataclass: task_type, adapter_id, model_id, cost_usd, quality_score (0..1), latency_ms, tokens_in, tokens_out, baseline_adapter_id, recorded_at, tags'
priority: high
status: done
state_hub_task_id: "1c285bec-c30b-45a8-a408-3f91d810a078"
```
```task
id: T02
title: 'QualityLedger append-only JSONL store with file-locked writes, configurable path, simple query helpers (by_task_type, recent, mean_quality)'
priority: high
status: done
state_hub_task_id: "5249f171-a047-499f-9ec4-cb50e1477765"
```
```task
id: T03
title: 'TTL helpers: prune_before(timestamp) and is_stale(observation, max_age)'
priority: medium
status: done
state_hub_task_id: "adb255cf-7e89-4fea-b822-6be437d99789"
```
```task
id: T04
title: 'Functional contract doc for the ledger schema and quality_score semantics'
priority: medium
status: done
state_hub_task_id: "51a33180-a99d-4aa4-96be-2fcee15bfbc3"
```
```task
id: T05
title: 'Tests: ledger round-trip, concurrent writes, query helpers, TTL, malformed-line resilience'
priority: high
status: done
state_hub_task_id: "458610c5-c903-4b42-9602-cd511999c9ba"
```
```task
id: T06
title: 'GradingResult dataclass: quality_score, notes, grader_id, baseline_response, candidate_response'
priority: high
status: done
state_hub_task_id: "c12a595b-90fc-4a80-8394-549edbda2031"
```
```task
id: T07
title: 'BaselineGrader protocol plus PairedGrader that runs baseline and candidate calls and delegates to a Judge'
priority: high
status: done
state_hub_task_id: "80b98e31-06fc-4462-b030-a12881095f93"
```
```task
id: T08
title: 'Judge protocol and built-ins: ExactMatchJudge, EmbeddingSimilarityJudge, LLMJudge'
priority: high
status: done
state_hub_task_id: "c2887fe3-bae6-4298-8c26-f9a519264dcf"
```
```task
id: T09
title: 'Functional contract doc covering judge bias caveats'
priority: medium
status: done
state_hub_task_id: "7a4fd87a-b0ba-41b0-8e1a-a60fdaded905"
```
```task
id: T10
title: 'Tests: judges with canned inputs, stable grader result, deterministic LLMJudge rubric seed'
priority: high
status: done
state_hub_task_id: "8415a11d-d508-4d17-8082-10f93e9d16c5"
```
```task
id: T11
title: 'AdaptiveRoutingPolicy extends RoutingPolicy and selects the cheapest adapter whose observed mean quality clears the floor'
priority: high
status: done
state_hub_task_id: "0e9f9f8e-5066-4257-913b-a19f5b3fc47d"
```
```task
id: T12
title: 'Tie-breaking: prefer lower observed cost, then explicit preferred adapter from static rules'
priority: medium
status: done
state_hub_task_id: "59d44712-1088-41ac-bad8-5d95db6f3a4f"
```
```task
id: T13
title: 'Cold-start behaviour falls through to static RoutingPolicy.resolve when observations are missing'
priority: high
status: done
state_hub_task_id: "1927d369-f5f6-48d3-8f53-7e4f1cae370e"
```
```task
id: T14
title: 'Functional contract doc for adaptive policy and sample-size/freshness trade-off'
priority: medium
status: done
state_hub_task_id: "4d4717c1-8849-4fed-8f8d-515901ecafe0"
```
```task
id: T15
title: 'Tests: floor enforcement, tie-break, cold-start, window-size effect, fallback chain'
priority: high
status: done
state_hub_task_id: "304bd782-db15-4b7a-8d05-49e064a926c3"
```
```task
id: T16
title: 'ShadowingAdapter wraps a candidate adapter, also invokes the baseline adapter, grades, and appends to QualityLedger'
priority: medium
status: done
state_hub_task_id: "62dd507f-536a-4623-8cbd-fa9f78e85ca6"
```
```task
id: T17
title: 'Sampling: caller-configurable shadow_rate so production load is not doubled'
priority: medium
status: done
state_hub_task_id: "ccb73e92-1fca-42f9-8437-9b2b50e6424c"
```
```task
id: T18
title: 'Failure isolation: shadow errors never affect the candidate response returned to the caller'
priority: high
status: done
state_hub_task_id: "b879d232-d6ce-4ff6-b534-616729ea5ad7"
```
```task
id: T19
title: 'Functional contract doc for ShadowingAdapter'
priority: low
status: done
state_hub_task_id: "99d2c1bc-f1d8-42b3-9e04-6eea49460943"
```
```task
id: T20
title: 'Tests: candidate response survives baseline failure, ledger sampling rate, sync vs async modes'
priority: high
status: done
state_hub_task_id: "f533fbf4-484f-4408-8260-7e84e23bdc46"
```
```task
id: T21
title: 'Example script: route fixture batch through three candidate adapters and populate the ledger'
priority: medium
status: done
state_hub_task_id: "7ef0c143-74b0-4740-81fa-819a826cf8f3"
```
```task
id: T22
title: 'Integration test: cold-start, static fallback, first observations, convergence to cheapest qualifying adapter'
priority: high
status: done
state_hub_task_id: "c4c6743f-157b-4445-8576-9caa6421d463"
```
```task
id: T23
title: 'Consumer integration guide showing how infospace-bench wires task types into adaptive policy'
priority: medium
status: done
state_hub_task_id: "3a073ff7-0170-4a95-9c2a-a5daa84964e6"
```
### T01 — Quality observation data model + ledger
| ID | Title | Priority | Status |
|-----|------------------------------------
---------------------------------------------------------------------------------------------------------------------------------------------------------|----------|--------|
| T01 | `QualityObservation` dataclass: `task_type`, `adapter_id`, `model_id`, `cost_usd`, `quality_score` (0..1), `latency_ms`, `tokens_in`, `tokens_out`, `baseline_adapter_id`, `recorded_at`, `tags` | high | done |
| T02 | `QualityLedger` append-only JSONL store with file-locked writes, configurable path, simple query helpers (`by_task_type`, `recent`, `mean_quality`) | high | done |
| T03 | TTL helpers: `prune_before(timestamp)` and `is_stale(observation, max_age)` so callers can refresh observations without re-reading the whole ledger | medium | done |
| T04 | Functional contract doc for the ledger schema and the field semantics of `quality_score` | medium | done |
| T05 | Tests: round-trip, concurrent writes, query helpers, TTL, malformed-line resilience | high | done |
### T02 — Baseline grader
| ID | Title | Priority | Status |
|-----|----------------------------------------------------------------------------------------------------------------------------------------------------|----------|--------|
| T06 | `GradingResult` dataclass: `quality_score`, `notes`, `grader_id`, `baseline_response`, `candidate_response` | high | done |
| T07 | `BaselineGrader` protocol: `.grade(baseline_adapter, candidate_adapter, prompt, run_config)``GradingResult`; built-in concrete `PairedGrader` runs both calls and delegates to a `Judge` | high | done |
| T08 | `Judge` protocol + three built-ins: `ExactMatchJudge`, `EmbeddingSimilarityJudge` (uses an embedding adapter), `LLMJudge` (uses a third adapter with a fixed rubric prompt) | high | done |
| T09 | Functional contract doc covering judge bias caveats (length bias, format bias, position bias for `LLMJudge`) | medium | done |
| T10 | Tests: each judge against canned inputs, grader emits stable result with both responses preserved, deterministic seed for `LLMJudge` rubric | high | done |
### T03 — Adaptive routing policy
| ID | Title | Priority | Status |
|-----|--------------------------------------------------------------------------------------------------------------------------------------------------|----------|--------|
| T11 | `AdaptiveRoutingPolicy` extends `RoutingPolicy`: given `task_type` + `quality_floor` + `ledger`, returns the cheapest adapter whose observed mean quality clears the floor over a configurable window | high | done |
| T12 | Tie-breaking: when two adapters meet the floor, prefer lower observed cost; if still tied, prefer the explicitly-preferred adapter from the underlying static rules | medium | done |
| T13 | Cold-start behaviour: when no observations exist for a `(task_type, adapter)` pair, fall through to the static `RoutingPolicy.resolve` result so the system stays usable on day zero | high | done |
| T14 | Functional contract doc; document the trade-off between sample size and freshness | medium | done |
| T15 | Tests: floor enforcement, tie-break, cold-start, window-size effect, fallback chain | high | done |
### T04 — Shadow-mode observation wrapper
| ID | Title | Priority | Status |
|-----|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|--------|
| T16 | `ShadowingAdapter` wraps a candidate adapter; on each call, also invokes the baseline adapter (sync or via a thread pool), grades, and appends to a `QualityLedger` | medium | done |
| T17 | Sampling: caller-configurable fraction (`shadow_rate=0.1` means grade one call in ten) so production load is not doubled | medium | done |
| T18 | Failure isolation: shadow errors never affect the candidate response returned to the caller; failures are logged but not raised | high | done |
| T19 | Functional contract doc | low | done |
| T20 | Tests: candidate response always returned even when baseline raises, ledger gets exactly `shadow_rate × calls` entries (within tolerance), sync vs async modes | high | done |
### T05 — End-to-end example + integration test
| ID | Title | Priority | Status |
|-----|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|--------|
| T21 | Example script: route a small fixture batch through three candidate adapters (one OpenRouter cheap, one OpenRouter mid, `ClaudeCodeAdapter` as baseline), grade each, populate ledger | medium | done |
| T22 | Integration test with mocked adapters covering: cold-start → static fallback → first observations → adaptive selection converges to the cheapest qualifying adapter | high | done |
| T23 | Brief consumer-integration guide in `docs/` showing how `infospace-bench` (or any caller) wires task-type-per-stage into the adaptive policy | medium | done |
## Risks and open questions
- **Judge bias.** `LLMJudge` has known biases — length, position, format,
self-preference when the judge model is the same family as a
candidate. The contract must document these and recommend pairing
with at least one non-LLM judge for calibration.
- **Baseline cost in shadow mode.** `ClaudeCodeAdapter` is not per-call
billed (it shells out to a local subscription session), but every
shadow call still consumes wall-clock and rate-budget. Sampling is
load-bearing, not optional.
- **Non-stationarity.** Provider model updates, prompt changes, and
template edits all silently invalidate prior observations. Plan for
a `prompt_fingerprint` tag on observations so the ledger can be
filtered to a coherent regime.
- **Scope creep.** "Pick a model" is one decision; "decide whether the
task is worth doing at all" is another. The latter is consumer
policy. Keep this workplan firmly on the former.
- **Privacy.** Observations contain prompt and response text by
default. Add a `redact: Callable[[str], str]` hook on the ledger
writer so sensitive callers can store hashes / digests instead.
- **API-vs-CLI baseline parity.** A consumer that grades against
`ClaudeCodeAdapter` (CLI) but later switches to a Claude API adapter
may see quality drift that's actually transport drift. Document this.
## Exit criteria
- `QualityLedger` round-trips observations and exposes the documented
query helpers
- `BaselineGrader` produces deterministic `GradingResult` objects for at
least one non-LLM judge and one LLM judge given canned inputs
- `AdaptiveRoutingPolicy.resolve(task_type, quality_floor=0.8)` returns
the cheapest adapter whose mean quality over the configured window
clears the floor, with a documented cold-start fallback
- `ShadowingAdapter` never alters the candidate response and respects
the sampling rate within statistical tolerance
- End-to-end example produces a ledger with at least three adapters per
task type and the integration test shows convergence to the cheapest
qualifying adapter
- Functional contracts published for the new data models, the grader
protocols, and the adaptive policy
## Consumer-side follow-up
`infospace-bench` will need a small companion workplan (`IB-WP-NNNN`) to:
- Replace direct `OpenRouterAssistedGenerationAdapter` use with a
task-type-tagged route through `AdaptiveRoutingPolicy`
- Define its task-type taxonomy (`summarize-source`, `extract-entities`,
`extract-relations`, `evaluate-entity`, `synthesize-report`)
- Pick a baseline adapter (most likely `ClaudeCodeAdapter`) and a
quality threshold per stage
- Wire the shadow-mode wrapper for the first multi-chapter run so the
ledger fills up while real generation proceeds
That workplan should be drafted after T01T03 of this workplan land, so
that the consumer-side wiring is anchored in a stable llm-connect API.

View File

@@ -0,0 +1,333 @@
---
id: LLM-WP-0005
type: workplan
title: "Cost Model and Problem-Class Token Estimators"
domain: agents
repo: llm-connect
status: finished
owner: llm-connect
planning_priority: high
planning_order: 5
created: "2026-05-19"
updated: "2026-05-19"
depends_on_workplans:
- LLM-WP-0003
- LLM-WP-0004
related_workplans:
- IB-WP-0019
- IB-WP-0020
state_hub_workstream_id: "869196c5-551b-4eef-b8d8-cca6f770a9b0"
---
# LLM-WP-0005 — Cost Model and Problem-Class Token Estimators
**status:** finished
**owner:** llm-connect
## Purpose
Move two consumer-side concerns into llm-connect as first-class
primitives:
1. **Model rate registry.** Provider-specific USD-per-1k prompt and
completion rates plus capture provenance — a fact about the base
model itself, not the application using it.
2. **Problem-class token estimators.** Generic shapes ("summarise a
chunk of N words", "extract entities from M paragraphs", "judge an
artifact against K criteria") with a small base-variable surface and
a few tunable parameters. Consumers select the class, supply problem
dimensions, and get a predicted (prompt_tokens, completion_tokens)
estimate before any call goes out. The cost model then converts the
estimate into USD using the rate registry.
Today the cost-rate table and a coarse word-count estimator live in
consumer code (`infospace-bench/src/infospace_bench/model_rates.yaml`
and `plan_generation_summary` in `generator.py`). The Lefevre live-run
smoke surfaced two consequences:
- A user supplying `--cost-per-1k 0.30` as a blended rate produced a
plan estimate 1000× larger than the actual gpt-4o-mini bill, because
the consumer's estimator has no notion of per-model rates and the
user has no easy way to pick a right number.
- The token estimator multiplies word counts by a constant and ignores
problem shape — actual prompts ran ~3× larger than the word-count
estimate predicted because templates, profile content, and entity
context dominate the prompt body, not the chunk text.
Both gaps recur in every llm-connect consumer (infospace-bench today;
inter-hub, markitect, and future repos tomorrow). Owning the primitives
here means each consumer wires structure-specific dimensions and
parameters but never re-implements rates or generic shapes.
## Demand signal
`infospace-bench` is the first concrete consumer. The Lefevre Chapter-I
smoke (2026-05-18) ran 32 calls / 28k prompt tokens / $0.0088 actual vs
a planned $8.40 — the 1000× variance is entirely on the consumer side
of the estimator. `IB-WP-0019` (budget registry) already records the
shape llm-connect would need to learn from (`output/budget/usage.yaml`
+ the llm-connect `QualityLedger`).
## Architecture sketch (read before writing tasks)
Three new modules in llm-connect:
```
llm_connect/
rates.py # ModelRate, ModelRateRegistry, default registry
costs.py # CostModel: tokens × rate → USD
problem_classes.py # ProblemClass protocol, built-in classes, registry
```
### `rates.py`
```python
@dataclass(frozen=True)
class ModelRate:
model_id: str
prompt_per_1k: float
completion_per_1k: float
currency: str = "USD"
source_url: str = ""
captured_at: str = ""
class ModelRateRegistry:
def get(self, model_id: str) -> ModelRate | None: ...
def all(self) -> dict[str, ModelRate]: ...
@classmethod
def default(cls) -> "ModelRateRegistry": ...
@classmethod
def from_yaml(cls, path: Path | str) -> "ModelRateRegistry": ...
def merged_with(self, override: "ModelRateRegistry") -> "ModelRateRegistry": ...
```
The default registry ships a small handful of well-known OpenRouter
models (the same set infospace-bench has today). Consumers can load
overrides from their own YAML.
### `costs.py`
```python
@dataclass(frozen=True)
class CostEstimate:
cost_usd: float | None
cost_source: str # "rate_table:<model>" | "unknown" | "override"
prompt_cost_usd: float | None = None
completion_cost_usd: float | None = None
def estimate_cost(
model_id: str,
prompt_tokens: int,
completion_tokens: int = 0,
*,
registry: ModelRateRegistry | None = None,
) -> CostEstimate: ...
```
Pure function over rate registry and token counts. Useful both for
preview (plan time) and post-hoc verification (compare adapter-reported
cost against rate-table estimate).
### `problem_classes.py`
```python
@dataclass(frozen=True)
class TokenEstimate:
prompt_tokens: int
completion_tokens: int
confidence: float = 0.5 # 0..1, learned over time
class ProblemClass(Protocol):
name: str
base_dimensions: tuple[str, ...] # e.g. ("chunk_words",)
tunable_params: tuple[str, ...] # e.g. ("template_tokens", "completion_ratio")
def estimate(
self,
dimensions: dict[str, Any],
params: dict[str, Any] | None = None,
) -> TokenEstimate: ...
```
Built-in classes (one per common workflow shape):
| Class | Dimensions | Tunable params |
|---|---|---|
| `chunk-summarization` | `chunk_words`, `template_words` | `completion_ratio` (default 0.25) |
| `entity-extraction` | `chunk_words`, `template_words`, `expected_entities` | `tokens_per_entity` (default 70) |
| `relation-extraction` | `chunk_words`, `template_words`, `expected_relations` | `tokens_per_relation` (default 80) |
| `judge-eval` | `artifact_words`, `template_words`, `n_criteria` | `tokens_per_criterion` (default 35) |
| `report-synthesis` | `n_chunks`, `n_entities`, `n_relations`, `template_words` | `base_completion_tokens` (default 400) |
Each class registers under a name; a `ProblemClassRegistry` keeps the
mapping. Consumers reference classes by name; advanced consumers can
register their own.
### Defaults and learning
Each built-in class ships seed parameters chosen from common practice
(roughly aligned with what infospace-bench observed on the Lefevre
smoke: ~1.0 prompt-words-per-chunk-word, ~0.2 completion ratio, ~70
tokens per emitted entity). Two adaptation hooks:
- `ProblemClass.fit(observations: list[Observation])` adjusts params to
best fit observed (dimensions → actual_tokens) pairs.
- A small CLI `llm-connect rates show` / `llm-connect classes show`
inspects the current registry and learned parameters.
`Observation` reuses the `QualityLedger` row shape so existing infra
keeps working (LLM-WP-0004 owns the ledger).
## Tasks
```task
id: T01
title: 'ModelRate + ModelRateRegistry data model, YAML loader, default-registry seed of nine OpenRouter models'
priority: high
status: done
state_hub_task_id: "535d3f12-911e-4b6a-87c3-b539c5986671"
```
```task
id: T02
title: 'CostModel.estimate_cost() pure function; tests for known model, unknown model, registry override, zero-token edge'
priority: high
status: done
state_hub_task_id: "691dd985-6a97-432d-8bf0-6cb99a9fbdcc"
```
```task
id: T03
title: 'ProblemClass protocol + TokenEstimate + ProblemClassRegistry'
priority: high
status: done
state_hub_task_id: "ecf263d2-f40a-460e-9195-4e01135ef727"
```
```task
id: T04
title: 'Built-in classes: chunk-summarization, entity-extraction, relation-extraction, judge-eval, report-synthesis'
priority: high
status: done
state_hub_task_id: "f1860b10-7467-4ce3-9775-ab293cef3ed0"
```
```task
id: T05
title: 'ProblemClass.fit() adapts tunable params from QualityLedger observations'
priority: medium
status: done
state_hub_task_id: "950b74e9-ede8-477a-b6b7-c7af423d4ebb"
```
```task
id: T06
title: 'CLI helpers: llm-connect rates show, llm-connect classes show, llm-connect classes fit <ledger>'
priority: medium
status: done
state_hub_task_id: "c47eca5f-4cb3-4f88-ac1b-38a9ae18e7e6"
```
```task
id: T07
title: 'Functional contract docs under contracts/functional/ for rates, costs, and problem classes'
priority: medium
status: done
state_hub_task_id: "c15fd1dc-48c3-40e9-abca-ba3ffe3684f9"
```
```task
id: T08
title: 'Consumer migration note for infospace-bench: replace plan_generation_summary cost+token math with llm-connect calls'
priority: medium
status: done
state_hub_task_id: "2993932a-334c-49f9-bb74-6ef4d3cbffcb"
```
## Scope guardrails
In scope:
- Data models, the registries, the built-in classes, and the
fit-from-observations helper.
- A default rate registry seeded with publicly known OpenRouter list
prices (refresh policy is on the consumer).
- CLI helpers for inspection.
Out of scope:
- Per-call billing or accounting infrastructure (consumer's job — see
`IB-WP-0019` for infospace-bench's per-infospace budget log).
- Provider-specific tokenisers (we already use a coarse
chars-per-token estimator; tokeniser parity is a separate piece of
work that should land before this one's accuracy bar tightens).
- A rates *update* mechanism. The registry is a static snapshot;
refreshing rates is a consumer chore (or a separate CronCreate-fed
workflow against provider price pages, deliberately out of scope
here).
## Acceptance
- `ModelRateRegistry.default()` returns a registry with at least the
nine OpenRouter models infospace-bench bundles today, each carrying
a `captured_at` timestamp.
- `estimate_cost("openai/gpt-4o-mini", 28000, 7500)` returns a
``CostEstimate(cost_usd≈0.009, cost_source="rate_table:openai/gpt-4o-mini")``
matching the Lefevre Chapter-I smoke within 20%.
- Each built-in `ProblemClass.estimate(dimensions, params)` produces a
`TokenEstimate` and its `fit()` recovers seeded params from a small
synthetic observation set within 10%.
- The consumer guide in the workplan's notes shows what
`infospace-bench plan_generation_summary` would look like once it
delegates to these primitives.
## Risks and open questions
- **Rate drift.** OpenRouter / Anthropic / OpenAI prices change. Each
rate has a `captured_at`; consumers must decide their freshness
policy. The default registry's date is the source of truth; a stale
registry will under- or over-estimate cost but the structure is
unchanged.
- **Class taxonomy lock-in.** The built-in class names will appear in
consumer code and ledger tags. Bump a `schema_version` on the
ProblemClassRegistry from day one; breaking changes need migration.
- **Tokeniser parity.** Current consumer estimators use a coarse
chars-per-token heuristic; the real tokenisers diverge by ±20%
across providers. This workplan accepts that for v1; tightening the
per-class accuracy bar belongs to a follow-on workplan once usage
observations expose the gap.
- **Fitting on small samples.** Adaptation needs enough observations
to be statistically meaningful. The fit function should refuse (or
fall back to seeds) when sample sizes are below a configurable
threshold.
## Downstream effects
- `infospace-bench` `plan_generation_summary` becomes a thin caller
over llm-connect's `estimate_cost` and the relevant `ProblemClass`.
The follow-on consumer workplan (`INFOSPACE-WP-NNNN`) describes
exactly which class maps to each workflow stage.
- `IB-WP-0019`'s budget registry stops needing its own
`src/infospace_bench/model_rates.yaml`; the workspace override path
stays but the bundled default lives upstream.
- `IB-WP-0020`'s routing CLI can ask the model rate registry whether a
`--routing-config` cost cap is realistic for the candidate set —
enables defensible `--cost-cap` defaults later.
## Consumer-side follow-up
Once T01-T04 land in llm-connect, `infospace-bench` opens a thin
companion workplan to:
- Replace `_CALLS_PER_CHUNK_BY_WORKFLOW` + `_profile_template_words` +
`WORDS_PER_TOKEN_DEFAULT` math in `plan_generation_summary` with
problem-class lookups.
- Map each workflow stage to a problem class
(`summarize-source` → `chunk-summarization`, etc.) and surface the
mapping in `docs/routing-task-types.md`.
- Drop `src/infospace_bench/model_rates.yaml` and read from
`ModelRateRegistry.default()`; keep the workspace override path
pointed at the same registry.
- Carry forward the existing `--cost-per-1k` override flag (with
documented semantics: blended single-rate override that wins over
rate-table lookup when set).