ADR-0065: Provider economics and extensibility — user-supplied pricing, the cost-path pricing-injection seam, pricing-reference capture, and custom OpenAI-compatible endpoints
- Status: Accepted
- Date: 2026-07-05
- Related: ADR-0064 (this ADR extends its static/live merge with a USER tier; append-only top-note added there) · ADR-0011 (this ADR amends the provider model — a
kindprotocol abstraction + building the adapter from the stored row; the id enum stays CLOSED; append-only top-note added there) · ADR-0028 (the pre-egress budget governor whose "cost cap will not apply" gap this closes) · ADR-0038 (host-injected resolution — the pricing overlay is injected exactly likekeyFor) · ADR-0006 + ADR-0019 (keys stay in the keychain — user pricing is a non-secret storage class) · ADR-0053 + ADR-0029 (the one shared SSRF primitive a custombase_urlreuses) · ADR-0050 · ADR-0056 (themodels pricing/provider list --verifycommands). Canonical homes: the cost path → cost-tracker.ts + budget-governor.ts; the static registry → pricing.ts; the DB columns → database-schema.md; the commands → commands.md.
Amended 2026-07-13 (ADR-0071 §1) — §2's precedence is REVERSED to
user → catalog → throw; the rest of this ADR stands.§2's original rule — "static
MODEL_PRICINGwins for known canonical ids; the overlay fills unknown ids only, so a user cannot silently misprice a shipped model" — protected the user from mispricing a model we had verified by hand. That made sense while the table was ours. ADR-0071 retires it for a catalog GENERATED from models.dev (a third-party aggregator, ~97 models vs 12), and static-first would then be a trap: a user prices an id the old table did not know, the catalog later learns it, and their explicit override is silently taken over by a public list price. This ADR's own custombase_urlendpoints (OpenRouter, Azure, LiteLLM, enterprise gateways) make the point sharpest — there the public price is simply wrong, and the user's negotiated or marked-up rate is the correct one; they hold the invoice.So
models pricingno longer REFUSES a model the catalog already knows — overriding one is the point of the command. This ADR's actual intent (notsilently) is preserved, not reversed: a user override that disagrees with the catalog is surfaced in the model picker, in/cost, and atmodels pricingtime — the user gets what they asked for, just never in silence. The Related line's "the static registry → pricing.ts" pointer now means the generated snapshot (rule 8).
Two latent defects, surfaced while scoping 2.5.G, frame this decision:
-
provider add --base-urlis dead config. It validates HTTPS and storesbase_urlinllm_providers, andprovider listechoes it — but no adapter ever reads it.createProviderResolver(providers.ts L186) is never handed theProviderStore; it always builds the keylessdefaultProviders()(providers.ts L14) with the default endpoints. A user who sets a custom endpoint today silently gets the default — and only the OpenAI adapter even accepts abaseURLat all (Anthropic/Gemini factories have none). -
An unpriced model silently disables the cost cap.
priceModel(cost-tracker.ts L19) throwsUnknownModelErrorfor any id absent from the staticMODEL_PRICING;BudgetGovernor.evaluatePreEgress(budget-governor.ts L141–149) catches it and returns{kind:'allow'}. So a model with no static price runs uncapped — themax_cost_microcentsgovernor (ADR-0028) silently no-ops. This is the exact "cost cap will not apply" gap.
The maintainer requires the model-selection and model-pricing story to be clean and complete with no
follow-up debt — including capturing, at provider-add time, a pricing reference and user-supplied
per-model pricing so cost governance works for models absent from the static registry. This is a distinct
decision from ADR-0064's "fetch a live list for the known providers": it centers
on apps/cli (the provider-add UX), @relavium/db (user-pricing storage), and — the sharp edge —
@relavium/core/@relavium/llm cost-path signatures; it has its own security surface (custom-base_url
SSRF + user-input validation); and it opens the data layer while keeping the id enum closed. It earns
its own ADR rather than swelling ADR-0064.
We make user-supplied per-model pricing a first-class non-secret storage class, inject the merged
static/user pricing into the cost path so a user-priced model is actually capped, rewire resolveProvider to
build the adapter from the stored provider row (fixing the dead-base_url bug and enabling custom
OpenAI-compatible endpoints over the SSRF floor), and capture a pricing-reference URL + user pricing at
provider-add — keeping the ProviderId enum closed.
The model_catalog cost columns (input/output/cached microcents) already exist
(schema.ts L106) but are unsettable. We widen ModelCatalogUpsert
(model-catalog-store.ts L51) to write them under
source = 'user' (the ADR-0064 §4 discriminant), so a background refresh
never clobbers a hand-entered row and the merge can rank precedence. Money is integer microcents
(usd() = round(usd × 1e8), pricing.ts L60) — no float persists; the
capture surface takes USD/MTok and converts at the boundary, echoing the resolved rate back. A new
relavium models pricing subcommand (under the ADR-0064 §10 models family; its
flags are the canonical commands.md's, not restated here) writes it;
the onboarding wizard also captures it when the chosen model lacks a static price. Numeric input is
bounds-validated at the CLI boundary (reject negative / NaN / magnitudes that would break microcent math).
User pricing is non-secret config/data — it lives in the DB in plaintext, never the keychain.
This is the load-bearing change. We add an optional pricing overlay — a Relavium-typed
ReadonlyMap<string, ModelPricing> (a resolvePrice resolver) — and thread it through both cost paths:
CostTracker (its constructor + cost/priceModel) and the pre-egress estimators
(estimateMaxNextCost/estimateMediaCost) and the BudgetGovernor. Precedence: static MODEL_PRICING
wins for known canonical ids; the overlay fills unknown ids only — a user cannot silently misprice a shipped
model (matching ADR-0064's static-wins), and the same slot can also carry a
live-catalog entry for a newly-released official model. (Considered a separate user-pricing lookup distinct
from the static path — rejected: it would double the lookup and let realized vs pre-egress cost diverge; one
overlay through priceModel keeps them in lockstep. Considered mutating MODEL_PRICING or injecting the overlay
unconditionally — rejected: an optional overlay leaves the default path and every existing cost test
unchanged, and keeps static authoritative for known ids.) priceModel consults static → overlay → then throws,
preserving the deliberate never-silent-zero invariant (1.B): a truly unknown id (neither static nor user)
still throws, and cost governance still degrades to allow with a loud, visible "cost cap will not apply"
notice rather than a silent no-op.
The host builds the overlay from only the model_catalog source='user' rows (a live/static cache
row's NOT NULL DEFAULT 0 cost column is never read as a price — pricing authority is static per
ADR-0064 §6) and injects it — @relavium/llm and
@relavium/core never import @relavium/db; the overlay arrives as plain Relavium data, byte-identical to
how keyFor injects the key (ADR-0038). It threads
SessionDeps → AgentSession → AgentTurnParams → new CostTracker(overlay) and directly into the
BudgetGovernor, covering both the realized-cost and pre-egress paths — wiring only one would make the cap
and the ledger disagree (a governor that blocks on a price the ledger never charges, or the reverse). This is a
deliberate core+llm seam-signature change; under-scoping it (storing the row but not injecting it) would
leave the gap open — the exact follow-up debt the maintainer forbids.
We upgrade createProviderResolver to accept the ProviderStore and, for a provider whose stored row carries
a custom base_url, build a per-provider adapter from {kind, base_url} rather than the static
defaultProviders() map — initially the openai-compatible kind
(createOpenAiAdapter({providerId, baseURL}) + its construction-time assertHttpsBaseUrl gate). A stored
base_url is no longer dead config. The Anthropic/Gemini factories gain a validated baseURL option
only if a custom endpoint for those kinds is in this round's scope; otherwise custom endpoints are honestly
openai-compatible-only, documented — a base_url under kind = anthropic|gemini is refused with a clear
message rather than silently ignored (the current bug).
A user registers a custom endpoint by pointing an existing provider id (openai/deepseek) at a custom
HTTPS base_url with kind = openai-compatible. listModels/generate over that custom endpoint is an
egress/SSRF surface and must reuse the shared HTTPS + private-range gate: assertHttpsBaseUrl +
isPrivateOrLocalHost at construction, and — for full DNS-rebinding protection — the host routes the
custom-endpoint hop through connectValidated (safe-egress.ts, the
one shared connect-by-validated-IP primitive — ADR-0053,
ADR-0029(d)), never a second URL parser. A custom base_url resolving to a
private/loopback/metadata address is refused. The known-provider fixed-host path
(ADR-0064 §9) is unchanged — this hardening is scoped to the custom endpoint.
provider add gains an optional --pricing-url (a non-secret, display-only URL where prices are looked
up), validated HTTPS at capture via requireHttpsUrl and stored in a new llm_providers.pricing_reference_url
column — not default_headers (that JSON is destined to be sent as wire headers once §3 wires the stored
row to the adapter — today it is dead config like base_url; stuffing a URL there would leak onto the wire once
live, a category error). It is never auto-fetched (no egress). The four known providers' pricing pages
pre-populate from a new pricingUrl field added to KNOWN_PROVIDERS (the pages pricing.ts already cites),
so the picker and provider list can show where to look up prices without asking. A kind column is added to
llm_providers too (nullable, populated for uniformity, load-bearing only for custom providers). These
llm_providers columns (pricing_reference_url, kind) ride their own additive migration (0008),
separate from ADR-0064's model_catalog 0007 — they land in the
provider-extensibility step, after the catalog cache. Both columns' one canonical home is
database-schema.md.
ProviderId stays the closed z.enum(LLM_PROVIDERS) (ADR-0064 §6). A user-added
provider is (existing id + kind + custom base_url + keychain key + user pricing); its models are unknown
ids priced solely from user rows (§2). A truly-custom provider id (a new, arbitrary id) would open the
closed enum — touching the persisted run-event provider field, authored agent YAML, and the exhaustive
Record<ProviderId, LlmProvider> — a deliberate future supersede of
ADR-0011's closed-set posture. (Considered opening the enum this round —
rejected: that cross-package + persisted-contract churn is disproportionate to 2.5.G, and the kind data-layer
delivers custom endpoints without it.) It is honestly named as future work, not this round. provider list gains an opt-in --verify (reusing validateProviderKey's bounded + redacted
probe) that reports per-provider verification state (the maintainer's decision) without hanging or leaking a key. One
honest limitation is documented: a custom OpenAI-compatible endpoint reuses the openai/deepseek id and
therefore cannot coexist with the real provider under that id — a genuinely-separate custom id awaits the
enum-opening ADR.
- The cost-cap gap is closed — a model absent from the static registry, once user-priced, is enforced by
max_cost_microcentson both the pre-egress and realized paths; a truly unknown model degrades to uncapped loudly and visibly, never silently. - The dead-
base_urlbug is fixed — a stored custom endpoint is now actually used; custom OpenAI-compatible endpoints work end-to-end, SSRF-validated through the one shared primitive. - The pricing story is complete, with no follow-up debt — capture (reference + per-model), storage
(non-secret,
source-tagged so a refresh never clobbers it), and enforcement (the injection) all land together; selection and pricing are cleanly separated yet both first-class. - A clean non-secret storage class — user pricing + the pricing-reference URL live in the DB, distinct from the keychain path; the two are never conflated.
- The id enum stays closed — no churn to the persisted run-event contract or authored YAML; extensibility is
delivered via the
kinddata layer, with a truly-open registry honestly deferred.
- The cost-path injection touches
core+llmsignatures and every cost test — the sharpest risk. Mitigation: it is the only change that closes the gap, the overlay is optional (absent ⇒ today's behaviour), precedence is static-wins so it cannot misprice a known model, and it is covered by its security round. Wiring only one of the two cost paths is called out as the specific hazard to avoid. - A user can misprice an unknown model — bounded to unknown ids (static always wins for known ids), numeric-validated at the boundary, with the USD→microcents conversion echoed back; a typo mis-governs only the user's own custom model.
- Custom-endpoint support is asymmetric —
openai-compatibleworks this round;anthropic/geminicustom endpoints need those factories to gain a validatedbaseURLfirst, so a non-openai custombase_urlis refused with a clear message rather than over-promised. - A DB migration — the
source,pricing_reference_url, andkindcolumns (additive ALTER-ADD, validated at the store boundary since SQLiteALTER ADDcarries noCHECK); forward-compatible under the single-user local posture (ADR-0050). - The reused-id limitation — a custom OpenAI-compatible endpoint shadows the real provider under the same id; a separate custom id awaits the future enum-opening supersede-ADR (named, not silently missing).
- Canonical reference docs pending update — this ADR points to
database-schema.md(thepricing_reference_url/kindcolumns) andcommands.md(models pricing,provider list --verify, and the now-honoured--base-url) as canonical homes not yet updated; those edits land in the implementing steps that add each artifact, matching ADR-0063's honestconfig-spec.mddeferral. - A mandatory security review — the custom-
base_urlSSRF and the user-pricing/reference input validation + the cost-path injection each carry a dedicated security round before shipping.