Skip to content

[Extensibility 1/4] refactor: table-driven LLM provider registry - #259

Draft
amal66 wants to merge 7 commits into
Open-Legal-Products:mainfrom
amal66:olp-pr/provider-registry
Draft

[Extensibility 1/4] refactor: table-driven LLM provider registry#259
amal66 wants to merge 7 commits into
Open-Legal-Products:mainfrom
amal66:olp-pr/provider-registry

Conversation

@amal66

@amal66 amal66 commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Replaces the per-provider if/else chains in the LLM dispatch layer and the per-provider switch in the API-key lookup with a table-driven provider registry. Adding a provider (including OpenAI-compatible endpoints — the path endorsed in #20) becomes a single registerProvider() / registerApiKeyProvider() call, with no edits to call sites.

Behavior is preserved: same three built-in providers, same env-var precedence, same model routing, same API-key status results. This is the base of a 4-PR extensibility series — demo mode, Ollama, and Vertex AI each become one self-contained file on top of it.

Changes

  • backend/src/lib/llm/registry.ts (new): LLMProviderAdapter contract + registerProvider / findProviderForModel / friends.
  • backend/src/core/apiKeyProviders.ts (new): provider → env-var table replacing the hand-maintained switch and PROVIDERS array.
  • backend/src/lib/llm/index.ts, models.ts, userApiKeys.ts, types.ts: dispatch through the registry; Provider becomes an open string id.
  • Tests: registry.test.ts + models.test.ts (20 tests — registration, first-match-wins routing, model-set union, provider inference).

Why

Today every new provider requires touching each dispatch site, the model tables, and the API-key switch. With the registry, local LLMs and other OpenAI-compatible endpoints plug in from a single setup file.

Testing

On this branch, on upstream's own harness: npm test in backend/20 files, 279 passed, 5 skipped. tsc build clean.

Provenance

Mechanical port of code running in amal66/mike (main). Fork-only features intertwined with this code (air-gap branches, retry/circuit-breaker wrapper) were omitted, not rewritten. Full hunk-by-hunk provenance in amal66#27.

🤖 Generated with Claude Code

amal66 and others added 7 commits August 5, 2026 20:39
Replace the per-provider if/else chains in lib/llm/index.ts and the
env-var switch in lib/userApiKeys.ts with a provider registry
(lib/llm/registry.ts) and an API-key provider table
(core/apiKeyProviders.ts). Adding a provider is now a
registerProvider()/registerApiKeyProvider() call — no edits to
index.ts, models.ts, or userApiKeys.ts required.

Ported from the amal66/mike monorepo fork (origin/main, b3166dd);
mechanical translation into the backend/ layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC
WHY THIS MATTERS
The LLMProviderAdapter interface asked every provider for its models
grouped into three usage tiers:

    readonly models: {
        readonly main: readonly string[];
        readonly mid: readonly string[];
        readonly low: readonly string[];
    };

But nothing ever consumed that structure. The only reader,
allRegisteredModels(), immediately flattened it:

    [...p.models.main, ...p.models.mid, ...p.models.low]

So the tier grouping was pure ceremony: it forced every adapter —
including third-party ones and test fixtures — to stub empty arrays
(`{ main: models, mid: [], low: [] }`) to satisfy a shape whose
distinctions were discarded on the very next line.

WHAT IS SPECULATIVE API SURFACE
Structure added because it "might be useful later" rather than because
a consumer needs it today. It is a real cost, not a free option: every
implementer must fill it in, every reader must wonder which tier
matters, and when a future need actually arrives it rarely matches the
guessed shape anyway. The YAGNI principle ("you aren't gonna need it")
says to add structure at the moment a consumer demands it — an
interface change is cheap when you control all the implementations.

HOW THE FIX WORKS
The adapter now declares one flat list:

    readonly models: readonly string[];

- registry.ts: allRegisteredModels() iterates p.models directly.
- index.ts: each built-in registration spreads its tier constants into
  one array, e.g. [...CLAUDE_MAIN_MODELS, ...CLAUDE_MID_MODELS,
  ...CLAUDE_LOW_MODELS]. Duplicates across tiers (claude-sonnet-4-6
  appears in both main and mid) are harmless because the consumer
  builds a Set.
- registry.test.ts: fixtures pass `models` directly, no empty-tier
  stubs.

Tier information still exists where it is actually used: the exported
*_MAIN/_MID/_LOW_MODELS constants in models.ts, which the settings UI
and defaults consume. The registry only ever needed "which model IDs
exist", and now that is all it asks for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS
Model→provider routing knowledge lived in THREE places at once:

1. Each adapter's matchesModel() in the registry (e.g. Claude's
   `m.startsWith("claude")`).
2. A hard-coded prefix fallback inside providerForModel() that
   re-implemented exactly those same heuristics:

       if (model.startsWith("claude")) return "claude";
       if (model.startsWith("gemini")) return "gemini";
       if (model.startsWith("gpt-")) return "openai";

3. A static ALL_MODELS Set that resolveModel() consulted alongside the
   registry's allRegisteredModels().

Duplicated knowledge drifts. If someone changed an adapter's
matchesModel() rule (say, to also claim "anthropic.claude-*" Bedrock
IDs), the fallback in models.ts would silently disagree, and which
answer you got would depend on whether index.ts happened to be loaded
first. That is exactly the class of bug this refactor PR exists to
prevent — extensibility is only safe when routing has ONE authority.

WHAT IS "SINGLE SOURCE OF TRUTH"
A design rule: every fact in a system should have exactly one
authoritative home, and everything else should derive from it. Here the
fact is "which provider serves model X", and its home is the provider
registry: adapters declare matchesModel() and their model lists, and
all other code asks the registry instead of keeping private copies.

The duplication existed only so llmModels.test.ts could import
models.ts without loading index.ts (which registers the built-in
providers). That is a test-structure problem, and the finding's
guidance is to fix it in the tests — never by duplicating production
routing rules.

HOW THE FIX WORKS
- providerForModel() now delegates solely to findProviderForModel();
  no prefix fallback. Unknown models still throw the same
  "Unknown model id" error.
- resolveModel() now validates solely against allRegisteredModels();
  the static ALL_MODELS set is deleted.
- llmModels.test.ts imports from "../llm" (the package index) instead
  of "../llm/models". Loading index.ts registers the built-in
  providers, so the functions under test exercise the exact same
  registry path production uses. Every existing assertion — including
  "prefix-only inference without catalog validation", which now flows
  through the adapters' matchesModel() — passes unchanged.

All production consumers (userSettings.ts, chat/streaming.ts,
routes/user.ts, routes/tabular.ts) already import from "lib/llm", so
registration is guaranteed to have run before any routing call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS
On main, `Provider` was a closed union ("claude" | "gemini" | "openai"),
so TypeScript could prove a switch over providers handled every case.
This PR widens it to `string` to keep providers extensible — but that
silently disarms exhaustiveness checking. The first casualty was
providerLabel() in routes/tabular.ts:

    function providerLabel(provider: Provider): string {
        if (provider === "claude") return "Anthropic";
        if (provider === "openai") return "OpenAI";
        return "Gemini";   // <- claims EVERY other provider is Gemini
    }

With the closed union, that final return really did mean "gemini" —
the compiler guaranteed no other value could reach it. With
`Provider = string`, register an "ollama" provider and the user sees
"Gemini API key is required to use llama-3..." — a plausible-looking
but wrong message that sends them to configure the wrong vendor's key.

WHAT IS EXHAUSTIVENESS CHECKING
With a closed union, TypeScript narrows the type in each branch; in a
final else the type collapses to the single remaining member (or
`never`, which the compiler flags if you add a variant and forget a
branch). Widening to `string` removes that safety net, so any
"remaining cases" fallthrough becomes a lie waiting to happen. When a
type must stay open, unknown values need honest handling — data-driven
lookup with an explicit fallback — instead of a hard-coded assumption.

HOW THE FIX WORKS
The display name becomes data the provider declares about itself, in
the one place that already knows every provider — the registry:

- LLMProviderAdapter gains `readonly label: string` ("Anthropic",
  "Gemini", "OpenAI" for the built-ins). A third-party provider ships
  its own label in the same registerProvider() call.
- registry.ts adds providerDisplayLabel(id): returns the registered
  adapter's label, or the raw id for unknown providers — so a message
  can at worst say `ollama API key is required...`, which names the
  actual provider instead of blaming Gemini.
- routes/tabular.ts deletes its local providerLabel() and uses the
  registry version. In practice providerForModel() throws before an
  unregistered provider reaches this code, so the fallback is
  defense-in-depth, not a reachable lie.
- The `Provider` doc comment now warns future readers not to write
  catch-all branches over provider ids.

UserApiKeys' index signature was reviewed per the same finding and
deliberately kept: getUserApiKeys()/getUserApiKeyStatus() build their
maps dynamically from the registered-provider list, which requires an
open Record. Access flows through provider ids obtained from the
registries (not hand-typed literals), which is the mitigation the open
type needs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS
Two functions introduced by this PR had zero real consumers:

1. core/apiKeyProviders.ts isApiKeyProvider(value) — a boolean twin of
   normalizeApiKeyProvider(value), which already answers the same
   question (null means "not a provider") AND returns the validated id.
   Nothing in the codebase called it.

2. lib/llm/registry.ts registeredProviderIds() — consumed only by its
   own unit tests. A function whose only caller is its own test is not
   tested behavior, it is surface invented to have something to test.

Dead exports are not free. Every public function is a promise: future
maintainers must keep it working, wonder who depends on it, and search
for callers before changing anything nearby. Two near-identical
predicates (isApiKeyProvider vs normalizeApiKeyProvider) are also a
drift hazard — if validation rules ever change in one and not the
other, callers get different answers depending on which twin they
happened to import.

WHAT IS "DEAD SURFACE" (AND YAGNI)
API surface is everything a module exports. Surface is "dead" when no
production code consumes it — either literally uncalled, or called
only by tests written for it. The YAGNI principle says: do not export
speculative helpers; add them in the commit where a real caller
appears. Deleting dead surface is cheap now and expensive later, when
external code may have started depending on it.

HOW THE FIX WORKS
- isApiKeyProvider is deleted. Anyone needing a boolean writes
  `normalizeApiKeyProvider(v) !== null`, keeping one validation path.
- registeredProviderIds() is deleted along with its describe block in
  registry.test.ts. The remaining tests all exercise API that
  production code actually uses (registerProvider,
  getRegisteredProvider, findProviderForModel, providerDisplayLabel,
  allRegisteredModels). Note the similarly named
  getRegisteredProviders() in apiKeyProviders.ts stays — it has a real
  consumer (userApiKeys.ts builds key/status maps from it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS
The comment above the built-in provider registration claimed:

    // Providers are imported above so that Vitest's vi.mock() hoisting
    // works: test files mock e.g. "../claude" before this module loads,
    // so the mocked function is captured here and ends up in the registry.

A search of the backend test suite shows NO test mocks "../claude",
"../gemini", "../openai", or "lib/llm" at all — route tests mock one
level higher ("../../lib/chat", "../../lib/userSettings"). The comment
described a constraint that does not exist.

Wrong comments are worse than no comments: a maintainer who trusts this
one would preserve the import structure for a mocking scheme nobody
uses, or — worse — "fix" a test by adding vi.mock("../claude") because
the comment implies that is the established pattern.

WHAT IS vi.mock HOISTING (what the comment misdescribed)
Vitest transforms each test file so vi.mock(path, factory) calls run
before the file's imports execute; any module importing that path then
receives the mock. That mechanism is real, but it only matters for
modules some test actually mocks — which is not the case for the
provider modules here.

HOW THE FIX WORKS
The comment now states the true and useful invariant: registration runs
at module load, so any code that imports "lib/llm" sees a fully
populated registry before its first dispatch or routing call. That is
the ordering guarantee resolveModel()/providerForModel() rely on since
routing became registry-only. No code change — comment only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ation)

WHY THIS MATTERS
While this PR was in flight, main gained an Ollama provider whose model
list does not exist at build time: the backend discovers locally
installed models at runtime (GET /models/ollama) and any id of the form
"ollama/<tag>" is valid. Main expressed that in two hard-coded places:

    // models.ts (main)
    if (model.startsWith("ollama")) return "ollama";          // routing
    if (ALL_MODELS.has(id) || id.startsWith("ollama/")) ...   // validation

The rebase registered Ollama as a provider adapter, which cleanly
replaced the routing line — matchesModel() already covers "which
provider serves this id". But the validation line had no registry
equivalent: resolveModel() only accepted ids found in some adapter's
static `models` array, and Ollama's array is necessarily empty. Keeping
`id.startsWith("ollama/")` inside resolveModel() would have preserved
behavior while contradicting the whole point of this PR — models.ts
would once again hold provider-specific knowledge that drifts
independently of the adapter it describes.

WHAT IS A DYNAMIC MODEL ID
Most providers ship a fixed catalog: the adapter lists every model id it
serves, and validation is set membership. A dynamic provider (Ollama
locally, and later Bedrock/Azure-style gateways) serves ids that are
only knowable at runtime — whatever the user has pulled. For these,
"is this id valid?" is a predicate, not a set lookup. An interface that
only offers a static list forces dynamic providers to either lie
(claim nothing, breaking validation) or leak their predicate into core
files (what main did, acceptable only while providers were hard-coded).

HOW THE FIX WORKS
- LLMProviderAdapter gains an OPTIONAL hook:
      isDynamicModel?(model: string): boolean
  Static-catalog providers omit it; the Ollama registration implements
  it as `(m) => m.startsWith("ollama/")` — the same rule main had, now
  living next to the adapter it belongs to.
- registry.ts adds matchesDynamicModel(id): true when any registered
  provider's isDynamicModel() claims the id.
- resolveModel() accepts an id when it is in allRegisteredModels() OR
  matchesDynamicModel() claims it. No provider names appear in
  models.ts.
- Tests: registry.test.ts covers the new hook (claimed and unclaimed
  cases); llmModels.test.ts pins the observable behavior main
  introduced — providerForModel("ollama/llama3") routes to "ollama"
  and resolveModel() passes "ollama/<tag>" ids through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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