Skip to content

Provider adapters + telemetry ownership (issue #16, steps 2–3) - #19

Merged
elkaix merged 4 commits into
mainfrom
feat/llm-adapters-telemetry
Jul 12, 2026
Merged

elkaix merged 4 commits into
mainfrom
feat/llm-adapters-telemetry

Conversation

@elkaix

@elkaix elkaix commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Implements sequencing steps 2 and 3 of the RAG architecture deepening spec (#16). Step 1 landed in #17; steps 4–6 (Retriever/QueryEngine, backend split, WebSocket adapter, typed API seam) remain and are out of scope here.

Step 2 — Provider adapters with injected clients (ADR 0002)

Replaces the 812-line LLMHandler monolith — which repeated the same provider dispatch four times and built clients from module globals — with one ProviderAdapter per provider behind a Protocol seam:

  • OpenAICompatibleAdapter (OpenAI and GLM), AnthropicAdapter (owns the system-message split), OllamaAdapter (owns the /api/chat payload), DummyAdapter.
  • Each adapter takes an injected client factory, so every provider is unit-testable with a fake — Anthropic and Ollama gain their first real coverage; the GLM selection branch is covered too.
  • LLMHandler selects one adapter at construction (four dispatch tables → one selection). Public interface preserved; prompt variants translate to the messages form.
  • Generation returns GenerationResult(text, usage); usage is provider-reported where available, adapter-counted as fallback. Streaming yields text then a terminal Usage.
  • Behavior preserved: only ProviderUnavailableError (missing SDK / GLM key / Ollama connection refused) triggers the dummy fallback; real API errors propagate.

Step 3 — Telemetry ownership (ADR 0003)

  • Token counting + pricing move from src/eval/ to a core src/telemetry/ package. The eval package now imports from core, never the reverse (verified: no src.eval telemetry import remains in production).
  • The backend consumes provider-reported usage — the sync path via generate_with_usage, the streaming path via the terminal Usage — deleting the reconstructed-prompt token counting at all four assembly sites. The orphaned generate_with_context is removed.
  • Telemetry still covers the answer pass only (the reasoning pass's usage is discarded), and StageTelemetry's shape is unchanged — the API and frontend are untouched.

Deliberate behavior change

Consolidating the two dummy fallback variants means a single-prompt generate() on the no-provider path now returns the messages-based placeholder ("…Your last message was N characters (conversation has N turn(s))…") rather than the old "…Your prompt was N characters…". Only visible when no API keys are configured; the [LLM unavailable] marker is unchanged. Recorded here as a decision, not an accident.

Verification

  • CI_LLM_MOCK=1 python -m pytest tests/ -v → 304 passed (279 baseline + 25 new).
  • test_backend_telemetry.py passes unchanged (behavior-preservation proof), plus new coverage for the streaming conversation branch.
  • ruff clean; every touched module ≤250 lines; typed boundaries, no Any/# type: ignore added.
  • Establishes the docs/adr/ + CONTEXT.md glossary conventions; Architecture.md updated.

Closes part of #16 (steps 2–3).

elkaix added 4 commits July 12, 2026 13:06
Replace the 812-line LLMHandler monolith -- which repeated the same
provider dispatch four times and built clients from module globals --
with one ProviderAdapter per provider behind a Protocol seam.

- OpenAICompatibleAdapter (OpenAI + GLM), AnthropicAdapter, OllamaAdapter,
  DummyAdapter; each constructed with an injected client factory so every
  provider is unit-testable with a fake. Anthropic and Ollama gain their
  first real coverage.
- LLMHandler selects one adapter at construction; the four dispatch tables
  collapse to that selection. Public interface preserved; prompt variants
  translate to the messages form.
- generate() returns GenerationResult(text, usage); usage is
  provider-reported where available, adapter-counted as fallback. Streaming
  yields text then a terminal Usage -- LLMHandler drops it for now to keep
  the backend untouched; step 3 surfaces it to telemetry.
- Behavior preserved: only ProviderUnavailableError (missing SDK, missing
  GLM key, Ollama connection refused) triggers the dummy fallback; real API
  errors propagate.

Establishes docs/adr/ (ADR 0002) and CONTEXT.md glossary conventions.
Sequencing step 2 of #16. 299 tests pass (279 baseline + 20 new).
Fix the inverted dependency and the reconstructed-prompt cost estimate in
one step.

- Move count_tokens and pricing (cost_usd/MODEL_PRICES/ModelPrice) from
  src/eval/ to a core src/telemetry/ package (tokens.py, pricing.py).
  The eval package now imports from core, never the reverse; src/eval
  re-exports the pricing symbols so its public API is unchanged. Old eval
  modules deleted (git mv, not shimmed).
- Backend consumes provider-reported usage: the sync path calls
  generate_with_usage (real prompt/completion tokens); the streaming path
  reads the Usage carried on the stream's terminal event. LLMHandler's
  stream_response/stream_messages now yield str | Usage; the orphaned
  generate_with_context is removed. The reconstructed prompt strings and
  their count_tokens calls are gone.
- Behavior preserved: telemetry still covers the answer pass only (the
  reasoning pass's usage is discarded); the no-documents early return still
  emits zero telemetry; StageTelemetry's shape is unchanged, so the API and
  frontend are untouched. test_backend_telemetry.py passes unchanged.

ADR 0003; CONTEXT.md gains the telemetry term. Sequencing step 3 of #16.
300 tests pass.
- Split src/llm_handler/__init__.py (was 323 lines, over the 250 ceiling)
  into the LLMHandler facade + a providers module owning provider detection,
  client-factory construction, and model listing.
- Modernize type syntax (list / str | None) and drop the three
  `# type: ignore` on the optional-import guards (typed as ModuleType | None).
- Add missing docstring summary lines to the adapter/handler constructors;
  tighten the `-> dict` request helpers to dict[str, object]; hoist
  base.counted_usage's count_tokens import to module level.
- Add first coverage for the GLM provider-selection branch (detect_provider,
  build_adapter returns the OpenAI-compatible adapter, and the
  GLM_API_KEY-missing dummy fallback).

303 tests pass.
The streaming telemetry tests only drove stream_query without a
conversation_id, leaving the multi-turn branch — which captures the answer
stream's terminal Usage and persists the assistant message — unexercised.
Add a test that drives it with a real conversation_id and asserts telemetry,
the done event's ids, and message persistence.

304 tests pass.
@elkaix
elkaix merged commit aff8e23 into main Jul 12, 2026
1 of 2 checks passed
@elkaix
elkaix deleted the feat/llm-adapters-telemetry branch July 12, 2026 18:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant