Context
Once custom backends can be registered (see the backend-registration issue, #1325), an app can have any number of backends live at once — the registry is Map<logicalName, backend> per kind, not one-remote-one-local. What's missing is (a) a clean addressing scheme for picking among many, and (b) a way to make the routing decision pluggable so a separate plugin can do cost-, latency-, capability-, or tenant-aware selection and fallback — without baking policy into core.
This also resolves #510 open decision #2 (fallback chains: "declarative-in-config vs imperative"). The answer is neither baked into core: fallback, A/B, canary, and tenant routing all become one extension point.
Addressing — how apps refer to many backends
Today opts.model: string is a flat logical-name lookup (default 'default'), and embedding / generative are already separate namespaces. Keep that app-facing surface; layer a two-level scheme under it:
- Physical backends get provider-namespaced ids —
openai:gpt-4o, ollama:llama3.2:3b, local:bge-small. The id is the registry key; namespacing avoids collisions when multiple plugins register.
- Logical roles are operator-defined aliases —
default, fast, accurate, cheap — mapping to one or an ordered set of backend ids.
opts.model accepts either a role or a concrete id. Apps speak intent ({ model: 'fast' }); ops/router own the physical mapping.
The shift that makes mixing first-class: an alias resolves to a group (ordered list), not necessarily a single backend.
Routing — the pluggable seam
Resolution today is a dumb Map.get(opts.model) at one call site each in Models.embed/generate/generateStream. That single seam is where routing plugs in:
interface ModelRouter {
route(req: {
kind: 'embedding' | 'generative';
logicalName?: string; // opts.model — a role or a concrete id
requires?: Capability[]; // 'embed' | 'generate' | 'stream' | 'tools' | 'adapters'
hints?: { tenant?: string; app?: string; promptTokens?: number; [k: string]: unknown };
}): ModelBackend[]; // ordered: [primary, ...fallbacks]
}
function registerRouter(router: ModelRouter): void; // replaces the default map-lookup router
Models loops the returned candidates, recording one hdb_model_calls row per attempt (the success / error_code columns already exist), so a fallback reads as "attempt 1 failed on A, attempt 2 succeeded on B". The router receives the call context Models already assembles (tenant, handlerPath, requested capabilities, signal), so it can route on cost, latency, tenant, prompt size, or required capabilities.
Two implementation paths
- Interim — router-as-backend. A routing plugin registers a normal
ModelBackend (via the registration issue) that internally picks and delegates. Needs no new core surface; composable. Caveat: avoid double-counting analytics — either delegate through models.generate({ model: realId }) with a delegating: true capability bit so core skips the outer row, or delegate straight to the registry. (Same "outer call abstains, inner calls record" pattern already used by the toolMode: 'auto' loop in Models.ts.)
- Target — first-class router seam. The
ModelRouter interface above, with the default router preserving exact current behavior. This is where ordered fallback, capability negotiation, and per-attempt billing live cleanly in core.
Config (additive superset)
Single-backend entries are unchanged; a fallback: list opts into ordered fallback the default router walks:
models:
generative:
default:
backend: openai
model: gpt-4o
apiKey: ${OPENAI_API_KEY}
fallback: [anthropic:claude, local:llama] # NEW
New optional opts field (default router ignores it):
type GenerateOpts = { model?: string; requires?: Capability[]; /* …all existing… */ };
App call sites never change: models.generate(input, { model: 'fast' }).
Backward compatibility
Additive only — new symbols (registerRouter, ModelRouter) and optional fields (opts.requires, config fallback:, capabilities().delegating?). Namespaced ids are new valid values for opts.model; flat names keep resolving. Alias→group is a superset of single-backend config.
Regression guard (must hold): today Models.embed/generate throw ModelBackendNotFoundError synchronously, and generateStream runs its capability check synchronously before returning the iterable. The default router must preserve that exact behavior — same error class, same sync timing — or callers relying on the throw point break. The default router stays synchronous (a thin wrapper over the current resolve*() calls).
Acceptance
Relationship
🤖 Generated with Claude Code
Context
Once custom backends can be registered (see the backend-registration issue, #1325), an app can have any number of backends live at once — the registry is
Map<logicalName, backend>per kind, not one-remote-one-local. What's missing is (a) a clean addressing scheme for picking among many, and (b) a way to make the routing decision pluggable so a separate plugin can do cost-, latency-, capability-, or tenant-aware selection and fallback — without baking policy into core.This also resolves #510 open decision #2 (fallback chains: "declarative-in-config vs imperative"). The answer is neither baked into core: fallback, A/B, canary, and tenant routing all become one extension point.
Addressing — how apps refer to many backends
Today
opts.model: stringis a flat logical-name lookup (default'default'), andembedding/generativeare already separate namespaces. Keep that app-facing surface; layer a two-level scheme under it:openai:gpt-4o,ollama:llama3.2:3b,local:bge-small. The id is the registry key; namespacing avoids collisions when multiple plugins register.default,fast,accurate,cheap— mapping to one or an ordered set of backend ids.opts.modelaccepts either a role or a concrete id. Apps speak intent ({ model: 'fast' }); ops/router own the physical mapping.The shift that makes mixing first-class: an alias resolves to a group (ordered list), not necessarily a single backend.
Routing — the pluggable seam
Resolution today is a dumb
Map.get(opts.model)at one call site each inModels.embed/generate/generateStream. That single seam is where routing plugs in:Modelsloops the returned candidates, recording onehdb_model_callsrow per attempt (thesuccess/error_codecolumns already exist), so a fallback reads as "attempt 1 failed on A, attempt 2 succeeded on B". The router receives the call contextModelsalready assembles (tenant, handlerPath, requested capabilities, signal), so it can route on cost, latency, tenant, prompt size, or required capabilities.Two implementation paths
ModelBackend(via the registration issue) that internally picks and delegates. Needs no new core surface; composable. Caveat: avoid double-counting analytics — either delegate throughmodels.generate({ model: realId })with adelegating: truecapability bit so core skips the outer row, or delegate straight to the registry. (Same "outer call abstains, inner calls record" pattern already used by thetoolMode: 'auto'loop inModels.ts.)ModelRouterinterface above, with the default router preserving exact current behavior. This is where ordered fallback, capability negotiation, and per-attempt billing live cleanly in core.Config (additive superset)
Single-backend entries are unchanged; a
fallback:list opts into ordered fallback the default router walks:New optional opts field (default router ignores it):
App call sites never change:
models.generate(input, { model: 'fast' }).Backward compatibility
Additive only — new symbols (
registerRouter,ModelRouter) and optional fields (opts.requires, configfallback:,capabilities().delegating?). Namespaced ids are new valid values foropts.model; flat names keep resolving. Alias→group is a superset of single-backend config.Regression guard (must hold): today
Models.embed/generatethrowModelBackendNotFoundErrorsynchronously, andgenerateStreamruns its capability check synchronously before returning the iterable. The default router must preserve that exact behavior — same error class, same sync timing — or callers relying on the throw point break. The default router stays synchronous (a thin wrapper over the currentresolve*()calls).Acceptance
opts.modelresolves both role aliases and provider-namespaced backend ids; flat names still work.registerRouter(router)overrides selection; the default router reproduces current 5.1.0 behavior exactly (incl. synchronousModelBackendNotFoundErrorandgenerateStreamsync capability check — explicit regression test).hdb_model_callsrow per attempt.Relationship
🤖 Generated with Claude Code