Skip to content

refactor(providers): every provider is a modular composition (source · adapter · knobs)#57

Merged
jmlago merged 3 commits into
mainfrom
providers-modular-registry
Jun 30, 2026
Merged

refactor(providers): every provider is a modular composition (source · adapter · knobs)#57
jmlago merged 3 commits into
mainfrom
providers-modular-registry

Conversation

@jmlago

@jmlago jmlago commented Jun 30, 2026

Copy link
Copy Markdown
Member

What

The provider concern was scattered across three places — to add one provider you touched build_registry (the source), serve.py (the wire adapter), and settings.SCHEMA (the knobs). This makes every provider a modular composition declared in ONE place.

providers.py — one Provider record per provider, composing up to four aspects (the structural declaration stays in config.live.lua, since the core reads the catalog):

aspect what
source catalog/pricing/discovery builder (+ an enabled predicate over the catalog)
adapter the wire backend for an api_kind (None ⇒ the default openai_compatible)
knobs operator-tunable settings, declared next to the provider

build_registry, the api_kind dispatcher handlers, and settings.SCHEMA all derive from PROVIDERS. Adding a provider = one config.live.lua entry + one Provider row with the aspects it has.

Composition, not inheritance: a provider supplies only its aspects (source=None/adapter=None), so there's no fat base class and no re-coupling of the catalog/wire concerns #25 deliberately split.

codex — the one exception (for now)

codex's scarcity-price source OBSERVES its own wire backend, so its source/adapter are wired imperatively in serve.py; the registry lists it (for its knobs) but skips building it. Generalising that single coupling would be mechanism for one case (act over potency). Follow-up: turn codex into a load-balancer provider over N subscriptions — the backend records aggregate quota state and the source DERIVES the scarcity price from it (the route_* "store raw, derive" pattern), which dissolves the exception.

This is the foundation for the pricing work

The 4 pricing PRs (coverage / effective-multiplier / measured-correction) build on this: a direct provider (anthropic/openai/gemini) becoming priced = giving it a source with pricing() here.

Verification

Pure refactor, behaviour-preserving: same sources, same handlers, same SCHEMA (15 knobs: 14 provider + compaction). Full suite 420 passed / 3 skipped / 0 failed (incl. test_settings, test_sources, the Config-tab dashboard test, all unchanged) + a new test_providers.py.

Summary by CodeRabbit

  • New Features

    • Provider-specific settings are now organized automatically, making provider options easier to find and manage.
    • Provider availability and API handling are now centrally configured, improving consistency across supported backends.
  • Bug Fixes

    • Improved handling for streaming and non-streaming provider behavior, including a special case for one provider that needs custom routing.
    • Reduced the chance of missing or inconsistently applied provider settings in the app.

…· adapter · knobs)

The provider concern was scattered: a provider's catalog SOURCE lived in `sources/`
(gated by ad-hoc ifs in build_registry), its wire ADAPTER in `provider_adapters/`
(hand-wired in serve.py), and its operator KNOBS in a flat settings.SCHEMA — three
places to touch to add one provider.

Add `providers.py`: one `Provider` record per provider composing up to four aspects
(the structural declaration stays in config.live.lua, core-facing):
  - source   — the catalog/pricing/discovery builder (+ an `enabled` predicate)
  - adapter  — the wire backend for an api_kind (None ⇒ default openai_compatible)
  - knobs    — operator-tunable settings, declared next to the provider
build_registry, the api_kind dispatcher handlers, and settings.SCHEMA all DERIVE
from PROVIDERS. Adding a provider = one config.live.lua entry + one Provider row.

Composition, not inheritance: a provider supplies only the aspects it has
(source=None / adapter=None), so no fat base class and no re-coupling of the
catalog/wire concerns #25 deliberately split.

codex stays the one documented exception (its scarcity source OBSERVES its own
backend, wired in serve.py) — generalising that single coupling would be mechanism
for one case. A follow-up turns codex into a load-balancer provider over N
subscriptions, dissolving the exception.

Pure refactor, behaviour-preserving: same sources built, same dispatcher handlers,
same SCHEMA (15 knobs). Suite 420/0.
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@jmlago, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4f46e3a3-2bfe-4fb9-a179-5ea65afeb5d5

📥 Commits

Reviewing files that changed from the base of the PR and between b0dfdf3 and 9b9187f.

📒 Files selected for processing (7)
  • env_coerce.py
  • providers.py
  • serve.py
  • settings.py
  • sources/__init__.py
  • tests/test_providers.py
  • tests/test_sources.py
📝 Walkthrough

Walkthrough

Introduces providers.py as a centralized registry of provider definitions (source, adapter, knobs, enabled gating, special flag). settings.py, sources/__init__.py, and serve.py are refactored to derive their provider-specific logic from this registry instead of hardcoded per-provider branching. Tests in tests/test_providers.py validate registry invariants.

Changes

Provider Registry Centralization

Layer / File(s) Summary
Provider dataclass and PROVIDERS registry
providers.py
Defines the Provider frozen dataclass and the PROVIDERS list with per-provider source, enabled, api_kind, adapter, stream_adapter, knobs, and special fields; marks codex as special=True with no source/adapter.
Registry query functions
providers.py
Adds build_source_registry(), native_adapter_handlers(), and provider_knob_schema() as the public API that consumers call to derive active sources, adapter maps, and settings schemas from PROVIDERS.
Wiring consumers to registry
serve.py, sources/__init__.py, settings.py
serve.py derives dispatcher handler maps from providers.native_adapter_handlers(); sources/__init__.py calls providers.build_source_registry() and special-cases openai_codex; settings.py extends SCHEMA via providers.provider_knob_schema().
Provider registry tests
tests/test_providers.py
Validates that every provider declares at least one aspect, knob schema keys are namespaced, settings.SCHEMA merges provider knobs, enabled gating behaves correctly, and codex is the sole special exception with no api_kind.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 A registry born, all providers aligned,
No more hardcoded paths left behind.
build_source_registry hops into view,
Knob schemas and adapters all fresh and new.
Codex stays special — a bunny's exception —
The warren is tidy with central collection! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main refactor toward modular provider composition across source, adapter, and knobs.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch providers-modular-registry

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
serve.py (1)

161-166: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Derive streaming handlers from the provider registry. The current wiring hardcodes stream_unsupported_api_kind for every native api_kind, so Provider.stream_adapter is never used. Build the streaming handler map from PROVIDERS (or remove the field/docs) to keep the registry and dispatcher in sync.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@serve.py` around lines 161 - 166, The streaming handler wiring in the
dispatcher is hardcoded for native api_kind values, so Provider.stream_adapter
is bypassed and the registry can drift out of sync. Update the handler map in
the serve.py streaming setup to derive entries from PROVIDERS (using each
provider’s stream_adapter where available) and keep the special openai_codex
path intact, or remove the unused field/docs if streaming is not supported.
Refer to the streaming dispatch code that builds handlers, the
Provider.stream_adapter hook, and the PROVIDERS registry when making the change.
providers.py (1)

50-52: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Thread stream_adapter into streaming dispatch Provider.stream_adapter is currently dead configuration: serve.py still assembles streaming_call from a hardcoded map (stream_unsupported_api_kind for native kinds plus a Codex special case). Either consume stream_adapter here or drop the field to keep the registry and dispatcher in sync.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers.py` around lines 50 - 52, `Provider.stream_adapter` is currently
unused, so the registry and streaming dispatcher have drifted apart. Update the
streaming path in `serve.py` to read and invoke `stream_adapter` from the
selected `Provider` instead of relying on the hardcoded `streaming_call` map,
while preserving the existing `stream_unsupported_api_kind` fallback and any
Codex-specific behavior. Use the `Provider` dataclass and the streaming dispatch
assembly in `serve.py` as the main touchpoints; if the field is not meant to be
supported, remove `stream_adapter` from `providers.py` instead.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@providers.py`:
- Around line 234-240: The provider knob export is still sharing nested mutable
values from PROVIDERS because provider_knob_schema() only shallow-copies each
spec. Update provider_knob_schema() to deep-copy each knob spec before stamping
in the provider id, so mutable defaults like lists are isolated from both
PROVIDERS and settings.SCHEMA. Use the provider_knob_schema() loop over
PROVIDERS and p.knobs as the place to apply the deep copy, preserving the
existing schema shape while preventing downstream in-place mutations from
leaking back.

---

Nitpick comments:
In `@providers.py`:
- Around line 50-52: `Provider.stream_adapter` is currently unused, so the
registry and streaming dispatcher have drifted apart. Update the streaming path
in `serve.py` to read and invoke `stream_adapter` from the selected `Provider`
instead of relying on the hardcoded `streaming_call` map, while preserving the
existing `stream_unsupported_api_kind` fallback and any Codex-specific behavior.
Use the `Provider` dataclass and the streaming dispatch assembly in `serve.py`
as the main touchpoints; if the field is not meant to be supported, remove
`stream_adapter` from `providers.py` instead.

In `@serve.py`:
- Around line 161-166: The streaming handler wiring in the dispatcher is
hardcoded for native api_kind values, so Provider.stream_adapter is bypassed and
the registry can drift out of sync. Update the handler map in the serve.py
streaming setup to derive entries from PROVIDERS (using each provider’s
stream_adapter where available) and keep the special openai_codex path intact,
or remove the unused field/docs if streaming is not supported. Refer to the
streaming dispatch code that builds handlers, the Provider.stream_adapter hook,
and the PROVIDERS registry when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4fc9ed45-20d1-4143-9f09-9d8f4954142b

📥 Commits

Reviewing files that changed from the base of the PR and between b0dfdf3 and 1b3cff3.

📒 Files selected for processing (5)
  • providers.py
  • serve.py
  • settings.py
  • sources/__init__.py
  • tests/test_providers.py

Comment thread providers.py
Comment on lines +234 to +240
def provider_knob_schema() -> "dict[str, dict]":
"""The provider knobs, namespaced `<id>.<knob>` and stamped with the provider
group — the per-provider half of settings.SCHEMA, derived from PROVIDERS."""
schema: dict[str, dict] = {}
for p in PROVIDERS:
for name, spec in p.knobs.items():
schema[f"{p.id}.{name}"] = {**spec, "provider": p.id}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Deep-copy knob specs before exporting them.

provider_knob_schema() only clones the top-level dict, so mutable defaults like the lists in Line 144 and Line 149 stay shared with PROVIDERS and with the merged settings.SCHEMA. Any downstream in-place mutation of spec["default"] will leak back into the registry and future callers.

Proposed fix
+from copy import deepcopy
+
 def provider_knob_schema() -> "dict[str, dict]":
     """The provider knobs, namespaced `<id>.<knob>` and stamped with the provider
     group — the per-provider half of settings.SCHEMA, derived from PROVIDERS."""
     schema: dict[str, dict] = {}
     for p in PROVIDERS:
         for name, spec in p.knobs.items():
-            schema[f"{p.id}.{name}"] = {**spec, "provider": p.id}
+            exported = deepcopy(spec)
+            exported["provider"] = p.id
+            schema[f"{p.id}.{name}"] = exported
     return schema
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def provider_knob_schema() -> "dict[str, dict]":
"""The provider knobs, namespaced `<id>.<knob>` and stamped with the provider
groupthe per-provider half of settings.SCHEMA, derived from PROVIDERS."""
schema: dict[str, dict] = {}
for p in PROVIDERS:
for name, spec in p.knobs.items():
schema[f"{p.id}.{name}"] = {**spec, "provider": p.id}
from copy import deepcopy
def provider_knob_schema() -> "dict[str, dict]":
"""The provider knobs, namespaced `<id>.<knob>` and stamped with the provider
groupthe per-provider half of settings.SCHEMA, derived from PROVIDERS."""
schema: dict[str, dict] = {}
for p in PROVIDERS:
for name, spec in p.knobs.items():
exported = deepcopy(spec)
exported["provider"] = p.id
schema[f"{p.id}.{name}"] = exported
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers.py` around lines 234 - 240, The provider knob export is still
sharing nested mutable values from PROVIDERS because provider_knob_schema() only
shallow-copies each spec. Update provider_knob_schema() to deep-copy each knob
spec before stamping in the provider id, so mutable defaults like lists are
isolated from both PROVIDERS and settings.SCHEMA. Use the provider_knob_schema()
loop over PROVIDERS and p.knobs as the place to apply the deep copy, preserving
the existing schema shape while preventing downstream in-place mutations from
leaking back.

jmlago added 2 commits June 30, 2026 18:30
…op dead stream_adapter

Addresses the review's two blocking points.

Axis 2 (dependency direction) — break the sources ↔ providers cycle. `providers`
lazy-imports `sources.*`, yet `sources/__init__.build_registry` imported back into
`providers` — a form-level cycle, benign only because the imports were lazy, and
inconsistent with how `settings ↔ providers` was already dodged. Make `providers`
the single composition root that depends DOWN only:
  - `build_source_registry` now builds the COMPLETE source list, codex included,
    via a `_codex_source` factory (it only needs the codex provider id from the
    catalog). `special` no longer means "source skipped" — it marks the one thing
    that is still imperative in serve.py: codex's adapter + the observe/bind
    coupling (its source watching its own backend's quota traffic).
  - `sources/__init__` loses `build_registry` (and its `import providers`) and is
    now a pure leaf. serve.py and the tests call `providers.build_source_registry`.
The edge is one-directional now: serve/settings → providers → sources/* ·
provider_adapters/*.

Axis 3 (act over potency) — delete `Provider.stream_adapter`. It was declared and
documented but read by nothing (streaming handlers hardcode
`stream_unsupported_api_kind` per native api_kind). Reintroduce only when a
provider actually ships a streaming twin and a derivation reads it.

Still behaviour-preserving: same sources built, same handlers, same SCHEMA.
Suite 421 passed / 0 failed.
Non-blocking review nit: `_i`/`_f` (env-var → int/float with a default) were
duplicated in settings.py and providers.py — providers.py couldn't import them
from settings without recreating the settings ↔ providers cycle, so they were
copied.

Pull them into `env_coerce.py`, a leaf depending on nothing but `os`. Both modules
import `env_int`/`env_float` from it: one implementation, and the dependency
arrows stay one-directional (settings → env_coerce ← providers, no cycle).

Behaviour-identical. Suite 421 passed / 0 failed.
@jmlago

jmlago commented Jun 30, 2026

Copy link
Copy Markdown
Member Author

Thanks — both blocking points were spot on; addressed in three follow-up commits (still behaviour-preserving, suite 421/0).

Axis 2 — sources ↔ providers cycle (85fd538). Made providers the one-way composition root:

  • build_source_registry now builds the complete source list, codex included, via a _codex_source factory (it only needs the codex provider id from the catalog). special no longer means "source skipped" — it now marks only what stays imperative in serve.py: codex's adapter + the observe/bind coupling.
  • sources/__init__ lost build_registry and its import providers; it's a pure leaf again. serve.py and the tests call providers.build_source_registry.
  • Edge is one-directional now: serve/settings → providers → sources/* · provider_adapters/*. You correctly read the old state as an oversight (vs. the settings ↔ providers cycle I had dodged) — same treatment applied here.

Axis 3 — stream_adapter (85fd538). Deleted the field + its docstring. It'll come back only when a provider ships a real streaming twin and a derivation reads it.

Axis 1 — _i/_f duplication (9b9187f). Factored into env_coerce.py, a leaf both settings and providers import (env_int/env_float) — one implementation, no cycle reintroduced.

The _codex_source factory also shrinks codex's special surface: constructing the source is now generic; only the observe/bind wiring is special. That sets up the planned follow-up (codex as a load-balancer over N subscriptions) cleanly.

@jmlago jmlago merged commit 7a212ed into main Jun 30, 2026
1 check passed
jmlago added a commit that referenced this pull request Jun 30, 2026
… pages (#59)

* feat(pricing): price the direct providers from their official pricing pages

PR B of the pricing roadmap. openai/anthropic/google had NO price source, so
every (provider, family) sat at the field default price = +inf
(core/llm_policy/fields.lua): the direct candidate never passed a cost ceiling
and ranked last on cost, even though the first-party API is usually cheaper than
the same model via openrouter. This gives each direct provider a price source.

No provider exposes a pricing API — all three publish prices on documentation
pages — so the source scrapes the OFFICIAL page each provider serves and parses
it. The formats differ, so each has a tailored, tested parser:
  - Anthropic — the Markdown twin (pricing.md): a real pipe table. (in / cache
    read / out)
  - OpenAI — the .md is MDX; pricing lives in a <TextTokenPricingTables> JSX
    component as [name, input, cached, output] array rows.
  - Google — the devsite HTML, where each model is an <h2 id="gemini-…"> (the id
    is the catalog family verbatim) with <td>Input price</td><td>$…</td> rows; the
    cache-read and per-hour storage prices share one <br>-split cell.
A model label maps to a catalog family by a dot/dash/space-insensitive slug, so
no hand-maintained alias map drifts ("Claude Opus 4.8" ↔ claude-opus-4-8).

Durable + fail-safe, strictly off the request path:
  - new host_store table `provider_prices(provider_id, model_family, price_in,
    price_out, price_cached_in, updated_at)` — the source of truth, in Postgres.
  - the source upserts the table and returns in/out to the core; on a failed
    scrape or a restyled page past the parser it COASTS on the table (which also
    warm-starts a fresh process), so coverage degrades, a price never snaps to
    +inf. poll ~hourly.
  - cache-READ price is captured into the table (verified against all three live
    pages) for the effective-cost work in PRs C/D; the core ranks on in/out only.

Builds on the modular registry (#57): each direct provider just declares a
`source`. Suite 428/0; parsers verified against the live pages (anthropic 15 /
openai 45 / gemini 19 families, in+out+cache).

* fix(pricing): guard the durable price write — drop implausible parses, never commit (Axis 7)

A per-Mtok price is invariant-bearing: it governs both routing and billing, and
because pricing() COASTS on the table, a bad parse that lands there becomes a
sticky wrong price the whole fleet routes/bills on — persisting even after the
page is fixed. The previous write only checked `not None`.

Validate before the upsert (in _resolve) and symmetrically on read (in _prices,
so a poisoned coasted row can't re-stamp either):
  - in/out must be a positive number <= $1000/Mtok. For these first-party PAID
    providers a $0 is a misparse, not a free offer (unlike the marketplace's
    legitimate $0); the ceiling matches config.live.lua's market_price_cap and
    clears real prices (o1-pro is $600/Mtok out) while a misread number (a context
    window ~272k, a 1e6 storage figure) sits far above it. A bad row DROPS →
    coverage degrades, coasting on the last good value.
  - cache-read must be plausible AND strictly cheaper than the base input (a cache
    read is always a fraction of input; cached >= input means the parser grabbed
    output/storage/the wrong cell). Implausible cache is nulled, not dropped — the
    in/out row is still good.

Adversarial tests: a $0 row and an over-ceiling row drop and are not committed to
the table; a poisoned stored row is not re-served on coast; a cache >= input is
nulled. Verified against the live pages (0 cached>=input violations across all
three). Suite 432/0.
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