Skip to content

Phase 2.6.Q — models.dev as the model-metadata source (ADR-0071)#76

Open
cemililik wants to merge 52 commits into
mainfrom
development
Open

Phase 2.6.Q — models.dev as the model-metadata source (ADR-0071)#76
cemililik wants to merge 52 commits into
mainfrom
development

Conversation

@cemililik

@cemililik cemililik commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Phase 2.6.Q — models.dev becomes the model-metadata source; the hand-typed registry is retired

Implements ADR-0071. The 12-row hand-typed MODEL_PRICING table is gone; price, context window, output ceiling, and the per-model reasoning control now come from a generated models.dev snapshot that ships in the binary and answers offline. The reasoning control's shape is per-model data, not a per-adapter assumption — which fixes a live bug (/effort on Gemini 2.5 was sending thinkingLevel, a parameter Google's docs say those models do not take).

Everything downstream of a price is a safety control (the ADR-0028 cost cap) or a wire parameter (the reasoning tier), so this touched a lot of money paths. Each step was landed under the same discipline: implement → commit → Opus review → fold → commit → Sonnet review → fold → commit. The adversarial rounds earned their keep — they caught, among others, a downgrade that recorded $14.50 of real spend as $0.00, an unpriced-model notice wired at 2 of 9 surfaces, and a WorkflowEngine that took two injected deps and dropped them on the floor.

The nine steps

# What Key commits
1–2 The generated 80-model catalog + the acceptedTiers bridge; reasoning-wire.ts as the one home for tier→wire 394acb7 79e0d95 2d7b051
3 Four adapters: the reasoning control is chosen per model, not per adapter (the live Gemini bug) 344bc83 1395b9a fbb83ca
4 The effort gate + both pickers ask the model which tiers it takes; one predicate, not four bda0b10 edaffac 466445e
5 Output-cap clamp to the model ceiling + the max_completion_tokens dialect rule (official vs custom endpoint) 9030339 94636a1 ce6cfbf
6 The big swap: MODEL_PRICING deleted, priced from the catalog, precedence flipped to user → catalog 5216c43 0b0b9b2 e9c4367
7 models refresh --providers/--catalog + [catalog] auto_refresh (default OFF), additive-only, shipped snapshot is the floor dddb001 3f3d084 77c88ac
8 An unpriced model is said out loud (per-turn notice) + strict_cost_cap to refuse it 785468d 70f086e 3945c0d
9 The two ADR §9 guards: a weekly moved-price red check + a nightly live effort-conformance probe ec0b2ee 21943b4 95a5880

Notable decisions

  • user → catalog precedence (a deliberate reversal). The catalog is a snapshot of a third-party aggregator; the user holds the invoice. A models pricing row now overrides a catalog price instead of being refused — and the divergence is loud (the command names the catalog price it replaces; --clear reverts it).
  • The refresh is additive-only. A runtime models refresh never touches a shipped model's price — that stays a human, release-time decision (§9). It adds the long tail and can never make a known model cheaper, because it does not write one.
  • strict_cost_cap turns the silent degrade-to-allow on an unpriced model into a hard pre-egress refusal, for a user who set a cap specifically to bound an untrusted model.

Guarantees held

  • ProviderId stays the closed enum (four adapters); adding a provider is one entry + one CATALOG_PROVIDER_KEYS line, and docs/runbooks/add-a-provider.md says exactly that.
  • No vendor SDK type crosses the @relavium/llm seam; packages/core stays platform-pure (the refresh fetch lives host-side in apps/cli/src/engine/).
  • No new runtime dependency — native fetch, existing Zod. models.dev is fetched from a compile-time-constant host, HTTPS-only, no credentials, Zod-validated at the boundary.
  • Migration 0011 (the cached_input_stated flag) is additive and verified on a copy of the real 3.5 MB history.db — 84 catalog rows and 52 sessions intact.

Toolchain

pnpm turbo run lint typecheck test build24/24 green. Test counts: @relavium/llm 642 (+11 skipped live/effort), @relavium/core 1001, apps/cli 2187, plus shared/db/mcp.

Follow-ups filed (docs/roadmap/deferred-tasks.md)

  • Auto-open a PR for additive catalog drift (needs a create-pull-request action pinned to a verified SHA — not invented unseen, per rule 3).
  • Per-tier cache-write rate (models.dev's tier schema publishes cache_read and no cache_write; scaling one would be a fabrication on a money path).
  • The Anthropic non-streaming generate() ceiling (the SDK refuses a large max_tokens; the clamp cannot reach under it).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Model metadata/pricing now comes from a generated catalog snapshot, with models refresh split into separate provider-availability vs catalog-metadata axes (--providers / --catalog).
    • Reasoning-effort tiers are now model-specific across the chat UI and model picker.
    • Added models pricing --clear to remove user pricing overrides.
  • Bug Fixes
    • Unpriced-model and withheld-effort notices now consistently match engine behavior (including one-time deduping) and respect official vs custom endpoint pricing/caps.
  • Documentation
    • Updated CLI/config and ADR guidance for the generated catalog refresh flow and strict_cost_cap behavior.

cemililik and others added 28 commits July 13, 2026 11:42
…db coupling it exposed

Marks 2.6.C done in current.md, the phase plan, and CLAUDE.md (which still called Phase 2.6 "next up" while two of
its workstreams were already on `main`).

Files one finding that had nowhere to live. `regression.e2e.test.ts` drives the real CLI shell without a `--db`, so it
resolves the DEFAULT path and opens — and MIGRATES — the developer's real `~/.relavium/history.db`. Verified rather
than assumed: running that file with HOME pointed at an empty directory creates `$HOME/.relavium/history.db` with all
11 migrations applied. The sibling test at :315 already does it correctly (mkdtemp + explicit dbPath), so the isolation
exists; this path just does not use it.

It is worth fixing because it already bit, in this very workstream: a migration re-cut during development changes its
journal timestamp, drizzle replays such a migration, and `CREATE TABLE session_costs` ran against the table it had
itself created. CI could not see it — a fresh runner has no history.db, so nothing had been applied and nothing could
conflict. The failure surfaced only against the maintainer's 3.3 MB database of real sessions. A suite that writes to
real user data both damages it and blinds CI to the damage.

Also records the cross-turn tool-call memory spin-off in current.md's open obligations, with the three already-live
bugs that gate it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…he hand-typed registry is retired

The 12-row `MODEL_PRICING` table cannot say what is true, and that is not an inconvenience — it is three live
failures with one root:

  • It prices 12 of ~97 reachable models, so ADR-0028's cost cap — a SAFETY control — is silently skipped for the
    rest. `gpt-5.4-pro`, the model the maintainer actually hit, is not in it.
  • Reasoning capability is a `boolean`, and the truth is not. `gpt-5.4-pro` accepts {medium, high, xhigh} and
    REJECTS `low`; `gpt-5-pro` accepts only {high}. A boolean cannot say that, so the picker offers all five tiers
    and the provider 400s.
  • Worst: the control's SHAPE is per-model, and we get it wrong TODAY. ADR-0066 assumed one native shape per
    ADAPTER ("Gemini → thinking-level") and filed the token-budget shape as "legacy". Google's docs for the
    generateContent API we call: "Gemini 2.5 series models don't support thinkingLevel; use thinkingBudget
    instead" — and gemini-2.5-pro cannot disable thinking at all. Those two models are our ONLY Gemini rows. The
    shape ADR-0066 called legacy is the shape of 100% of the Gemini models we ship.

models.dev was verified, not trusted: all 12 of our hand-checked prices match it EXACTLY (including 0.435/0.87 and
0.75/4.5), and its gemini-2.5-pro budget range {min:128, max:32768} matches Google's own documentation — on the very
model where our code is broken.

The design, in one line each: a GENERATED snapshot (not hand-typed, not runtime-only) ships in the binary, so the
cap works offline on day 0 — a cap that needs a third-party host reachable is not a safety control. The refresh is
opt-in DEFAULT-OFF (ADR-0068's convention), additive-only, and can never lower the floor the binary shipped with.
Accepted tiers are COMPUTED from the wire values, never copied — a literal read drops `off` from every Claude model.
And one `CATALOG_PROVIDER_KEYS` table drives everything, so adding a provider tomorrow is one line, not a rewrite.

Folds a review pass that was right about the substance:
  • Recorded the fifth outbound path in security-review.md's closed inventory — and drew the distinction it exists
    for: the four SSRF paths are dangerous because SOMETHING ELSE picks the URL. This one's host is a compile-time
    constant. Made non-constant, it moves into the four and needs the primitive.
  • Named what the retirement BREAKS up front (`contextWindowForModel`, ADR-0062's compaction path) rather than
    discovering it mid-implementation.
  • Narrowed ADR-0065's precedence to `user > catalog`, justified by ADR-0065's OWN custom base_url feature: on a
    proxy the public list price is simply wrong. Its real intent ("not SILENTLY") is kept by making the divergence
    visible, not by overruling the user.
  • Dated amendment notes on ADR-0064 (two clauses superseded), ADR-0065 (precedence), ADR-0066 (a FALSE premise,
    quoted), plus the missing ADR-0070 back-reference and the decisions/README index row.

Updated for the next provider: the add-llm-adapter skill's "hand-write a pricing entry" step becomes "add one line
to CATALOG_PROVIDER_KEYS and review the generated diff", and add-a-provider.md gains the two-axis model
(availability from your key, metadata from the snapshot) and the two-axis `models refresh`.

Refs: ADR-0071, ADR-0064, ADR-0065, ADR-0066, ADR-0070, ADR-0028

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… the max_completion_tokens rule

Auditing the plan against the Accepted ADR before writing code found a real gap, and it was mine: rewriting the
ADR to fold the review, I DROPPED its "what the catalog does NOT own" section. So the plan committed to fixing
`max_tokens` vs `max_completion_tokens` — the second half of the maintainer's "max tokens errors" report — while
the ADR said nothing about it at all.

Restored as §10, and it forced a decision I had earlier got wrong. I had proposed DISCOVERING the dialect at
runtime: send the modern field, and on a 400 that names the parameter, retry once with the legacy field and cache
the result per endpoint. That is worse than a rule, and rejecting it is now recorded:

  • it burns a real user turn to learn what a constant already tells us;
  • it depends on string-matching a provider's 400 message, which is not a contract; and
  • it introduces mutable per-endpoint state that must be persisted, invalidated, and somehow made deterministic
    for tests.

The rule instead: the official OpenAI endpoint gets `max_completion_tokens` (the current field — `max_tokens` is
the deprecated one that reasoning models reject); DeepSeek and every custom `base_url` keep `max_tokens`, which is
the field every OpenAI-compatible server implements. A per-provider config key covers an exotic endpoint that
wants the modern one. Knowable, testable, and it costs the user nothing.

§10 also pins the four other things the catalog does not own, so none of them surfaces as a mid-build surprise:
media output rates (`cost: null` on every image model — nothing lost, no model carries one today), deprecation
DATES (it has a flag, not a date — adopting it would LOSE information we already have), `cache_read` when absent
(`undefined`, never `0` — `0` means "no discount" and would price cached input at zero), and reachability with
your key (that is `listModels`' job; a catalog cannot know your account).

The plan doc is rewritten as an implementation plan and cross-checked against the ADR line by line. It had drifted
badly: it still said "decisions pending", still called for the cancelled Step-0 probe, still carried an archived
section contradicting its own §3, still said "zero egress" (the refresh is opt-in default-OFF, not absent), and
still said to cut D3 from scope — which I had already retracted, because D3 IS the maintainer's finding. Its
consumer list was also short: it named 15 files; the real count is 18, and the one it missed —
`session-view-model.ts` — is the 2.6.C context-fullness indicator.

Refs: ADR-0071, ADR-0065, ADR-0062

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ith the reasoning control per MODEL

Step 1 of ADR-0071: the catalog data and its generator, entirely ADDITIVE (nothing consumes it yet, so nothing
can break). What it produces is the thing a 12-row hand-typed table could never say.

The three rows this whole workstream exists for, straight out of the snapshot:

  gemini-2.5-pro     reasoning={"budgetTokens":{"min":128,"max":32768}}   ← NO effort axis. And min:128 says,
                                                                             in one field, that `off` is IMPOSSIBLE
                                                                             ("N/A: Cannot disable thinking").
  claude-haiku-4-5   reasoning={"budgetTokens":{"min":1024}}              ← NO effort axis either — yet our
                                                                             adapter sends `output_config.effort`.
  gpt-5.4-pro        reasoning={"effortValues":["medium","high","xhigh"]} ← the maintainer's bug report, verbatim:
                                                                             it REJECTS `low`, which we offer.
  deepseek-reasoner  reasoning={}                                          ← reasons, but has NO controllable tier.
                                                                             Distinct from "does not reason".

All 12 of our hand-verified prices survive the transform BYTE-EXACT (checked, including 0.435/0.87 and 0.75/4.5).

Two real bugs were found by building it, and both are the kind that ship silently:

**The first draft validated the whole 166-provider payload with one strict schema — and died on the first run.**
`requesty`, a provider we have no adapter for and can never call, publishes a `budget_tokens` option with no
`min`. One malformed row in data we never read killed the entire sync. Validating a third-party payload wholesale
makes you hostage to 162 providers' data quality. Now: parse only what we consume, one model at a time, so a bad
row is a dropped model — reported, never silent — instead of a dead sync.

**The CI guard was born dead.** `renderSnapshot` emitted `JSON.stringify`'s double quotes; prettier rewrote them
to single ones; `--check` then reported the snapshot STALE *even when it was current*. A weekly guard that is red
no matter what is not a guard — everyone learns to ignore it, and the price-change protection it exists to give
quietly evaporates. The generator now formats with prettier, so the comparison is apples-to-apples. Break-verified:
corrupt the snapshot and `--check` fails; leave it alone and it says "current".

Design points worth naming, because each is a money decision:

  • ABSENT ≠ 0 for cache-read (19 of 97 models, incl. gpt-5.4-pro). `0` means "no discount"; writing it for an
    absent rate bills cached input as FREE — a silent undercharge in the mechanism this work is hardening.
  • An unpriceable model (upstream `cost: null` on image models) is DROPPED, not written at $0. A $0 row is worse
    than absence: it *passes* the cost cap, where an unpriced model is flagged.
  • `google-vertex` is ignored — it republishes the same Gemini ids at DIFFERENT prices, and a naive flatten would
    price the user's Gemini traffic at Vertex rates.
  • A dropped model is reported by its OWN id, never the record key: the sync's shipped-model guard compares that
    id against the committed snapshot, so a key/id divergence would wave a real regression straight through.
  • A cross-provider id collision THROWS. A generator that quietly halves its own output is exactly the failure
    mode this workstream exists to end.

Provider-extensible by construction: `CATALOG_PROVIDER_KEYS` is a `Record<ProviderId, string>`, so a new provider
is a COMPILE ERROR until it is mapped, and the generator iterates `LLM_PROVIDERS` rather than a literal list.
Adding one tomorrow is a single line.

Refs: ADR-0071, ADR-0066, ADR-0028

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tion

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…an embedding was on its way to the picker

An adversarial review found 23 defects, and the two worst were mine in the same place: the guards that exist to
stop a third-party file from silently weakening the ADR-0028 cost cap could not, in fact, stop it.

**The guard was blind to `o1` and `o3`.** It regex-matched the generated snapshot's TEXT and required a
single-quoted key. Prettier's default `quoteProps: 'as-needed'` unquotes any key that is a valid JS identifier, so
those two were emitted bare — the regex saw 88 of 90 models, and they had NO BASELINE AT ALL. A halved `o1` price
passed in silence; the fatal "a shipped model was dropped" check could not fire for them either.

This is the SECOND time the same root bit me. I had already "fixed" a prettier mismatch by formatting the
generator's output — and then left a guard that reads those bytes back with assumptions prettier does not honour.
The generated file's exact shape is prettier's decision, not the tool's. Any guard that reads it as text is one
formatting default away from going quietly blind. So the guard now diffs the DATA: it imports the built snapshot
module and compares structurally. The key's byte shape is now irrelevant, which is the only way this stays fixed.

**The guard compared only the flat input/output pair.** Cache-read, cache-write and every context tier were
unguarded — and the tier is the worst possible field to miss, because the pre-egress estimate takes the HIGHEST
applicable tier, so on a long-context turn the TIER rate is the number that sizes the cap. Twelve models are
tiered. Both are break-verified against the real tool:

  halve o1's flat rate (unquoted key)          → guard FIRES   (was silent)
  halve ONLY gemini-2.5-pro's >200k tier       → guard FIRES   (was invisible)

**A shipped model that VANISHES was removed in silence.** The old check could only see models the normalizer
explicitly dropped; one that simply disappeared from the payload — upstream deleted it, a provider-key edit erased
its provider — appeared in no list. Now fatal, with `--accept-removals` for a deliberate one (a provider really
does retire models; a guard with no escape hatch is a tool that eventually cannot run).

**An embedding was on its way into the model picker.** The catalog imported `text-embedding-3-large` and 9 other
non-chat models. That is not cosmetic: `keepOpenAiModelId` short-circuits on `pricedIds.has(id)` — a PRICED id
bypasses the live list's deny-list entirely — so once the catalog becomes the priced set, every non-chat model in
it would be RESCUED past that filter and offered to the user as something to chat with. The filter now has ONE
canonical home (`model-kind.ts`), shared by the live list and the catalog. Two filters that can disagree is a
cascade; one filter cannot. 90 models → 80 chat models.

Also folded: the outer payload schema no longer validates all 166 providers wholesale (one malformed stub among
providers we never read could still kill the sync); the SHA is now of OUR catalog, not the 3.17 MB upstream body
(any byte moving in the 162 providers we discard made `--check` report STALE when nothing we ship had changed —
a weekly guard that is always red is not a guard); the fetch has a timeout and surfaces `error.cause` instead of
Node's bare "fetch failed"; and `tools/**` is now linted in CI, which it never was — it had 5 live errors,
including a `no-restricted-syntax` hit on a computed dynamic import that the ADR-0011 seam fence is right to
forbid. Fixed with a literal specifier, not a disable.

Refs: ADR-0071, ADR-0064, ADR-0028, ADR-0011

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…he catalog, never copied

Step 2 of ADR-0071 (§6). The catalog's `effortValues` are PROVIDER-WIRE strings; Relavium's tiers are a different
vocabulary. Reading one as the other is not a rounding error — it is actively wrong in both directions:

  a literal read DROPS `off` from every Claude model  (there, `off` is `thinking:{disabled}` — not an effort value)
  a literal read DROPS `off` and `max` from gpt-5.5   (its wire spellings are 'none' and 'xhigh')
  a literal read OFFERS `low` on gpt-5.4-pro           (which rejects it — the maintainer's 400)

So `acceptedTiers(provider, controls)` composes the wire map with the model's published values. The wire maps
moved out of three adapters into one home (`reasoning-wire.ts`), because two copies of "what we send a provider"
are two chances to disagree about it.

The asymmetry that makes this necessary: `off` is an effort value on ONE of our four providers.

  anthropic / deepseek   `thinking: {type:'disabled'}`     — an independent switch; always available
  openai                 `reasoning_effort: 'none'`         — only if 'none' is among the model's values
  gemini                 `thinkingConfig.thinkingBudget: 0` — only if the model's budget floor IS zero

That last line is the live bug in one sentence. Google's docs say gemini-2.5-pro "cannot disable thinking"; the
catalog says `budgetTokens.min = 128`. Same fact, one field — and our adapter maps off→MINIMAL and sends it
anyway, which neither disables thinking nor is a value the model takes.

Two bugs found while writing it, both mine:
  • The empty descriptor (`deepseek-reasoner` — reasons, but publishes NO control) was being offered `off`, on
    DeepSeek's *general* ability to disable. That is a guess about a model whose capability upstream declined to
    describe — and a guess is precisely what put a rejected value on the wire in the first place. It now offers
    nothing, and the field is withheld.
  • Gemini's `ThinkingLevel` enum is the UPPERCASE form of the catalog's tokens. Two maps would have drifted;
    there is one map and an explicit `toGeminiThinkingLevel` at the wire.

Tested against the REAL shipped catalog, not fixtures — a bridge that is right about a fixture and wrong about
gemini-2.5-pro is worth nothing. Two invariants hold across all 80 models: no model is offered a tier whose wire
value it does not publish, and no model that cannot be disabled is ever offered `off`.

Refs: ADR-0071, ADR-0066

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…all four adapters

Step 3 of ADR-0071 (§6). This is the bug the maintainer hit, and the one the ADR-0066 correction note names: the
native reasoning shape was a property of the ADAPTER, and it is a property of the MODEL.

**Gemini.** The adapter sent `thinkingConfig.thinkingLevel` to every reasoning model. Google's docs for the
`generateContent` API we call: "Gemini 2.5 series models don't support thinkingLevel; use thinkingBudget instead."
`gemini-2.5-flash` and `gemini-2.5-pro` are the ONLY two Gemini rows we ship. So `/effort` on Gemini has been
sending a parameter those models do not take, for as long as the feature has existed.

And `off` was worse than wrong. It mapped to `MINIMAL` — which is the LOWEST thinking level, not an off switch. A
model set to MINIMAL still thinks, and still bills for it. A user who turned reasoning OFF was being charged for
reasoning. Disabling on Gemini is `thinkingBudget: 0`, a different field, and only a model whose budget floor IS
zero can express it: `gemini-2.5-pro` has `min: 128` ("N/A: Cannot disable thinking"), so `off` is not offered for
it at all, and if one arrives anyway the field is WITHHELD rather than substituted.

**Anthropic.** `claude-haiku-4-5` publishes a token budget and NO effort axis — the maintainer confirmed this
against Anthropic independently. The adapter sent `output_config.effort` to it anyway. ADR-0066 filed the budget
shape as "legacy", and it is, for the industry; it is not legacy for one of the four Claude models we ship. It now
gets `thinking.budget_tokens`, with the ceiling taken from the REQUEST's own `max_tokens` — Anthropic requires
`budget_tokens < max_tokens`, which is exactly why haiku publishing no `max` is not a gap the catalog can close
alone.

The old Gemini test asserted the bug — and its own comment said "a Pro model rejects budget 0", so the author
already suspected the shape was not universal. It is rewritten to assert what is true, per model:

  gemini-2.5-flash (budget, min 0)  → thinkingBudget: N, and `off` → thinkingBudget: 0
  gemini-3.5-flash (effort)         → thinkingLevel: 'HIGH'
  gemini-2.5-pro   (budget, min 128)→ `off` is WITHHELD, never downgraded to MINIMAL
  claude-haiku-4-5 (budget)         → thinking.budget_tokens, and output_config is NEVER sent
  an unknown model (custom endpoint)→ no reasoning field at all

That last row is the discipline the whole change turns on: when the catalog cannot describe a model, the field is
withheld. A guess is what put a rejected value on the wire in the first place.

The four wire maps also moved out of three adapters into one home (`reasoning-wire.ts`) — `acceptedTiers` has to
compose them with the catalog's per-model values, and two copies of "what we send a provider" are two chances to
disagree about it.

Refs: ADR-0071, ADR-0066

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…her the tier was IN it

The Opus review of step 3 found the fix carried the original bug's shape one level
down. Four defects, all of which put a value the model rejects on the wire, or bill
the user for reasoning they turned off:

- MEMBERSHIP, not presence. Every adapter tested that the model published *an* effort
  axis, then sent its own tier name regardless. `claude-opus-4-5` publishes
  ['low','medium','high'] — no `max` — so `/effort max` on it was a 400 that only the
  failover chain hid. `acceptedWireValue` now returns the wire value ONLY if the model
  actually lists it, and the four adapters ask it instead of testing for `!== undefined`.
- Gemini's off switch diverged from the picker's. `gemini-2.5-flash-lite` publishes a
  toggle AND `budgetTokens.min: 512`; `acceptedTiers` offered `off`, the adapter looked
  only at `min === 0` and silently withheld the field. The user switched reasoning off,
  was billed for it anyway, and nothing said so. Both sides now ask one predicate,
  `canDisableReasoning`.
- `max` spent the ENTIRE output cap on thoughts, leaving zero tokens to answer with —
  a request the API accepts and that comes back empty. `thinkingCeiling` reserves 20%
  (THINKING_BUDGET_SHARE) for the reply, and a budget that no longer fits under the cap
  is withheld rather than sent to be rejected.
- `fallback-chain.ts` still gated on the boolean `modelSupportsReasoning`, so a failover
  entry re-sent the tier its own model had just rejected. It re-gates on `acceptedTiers`.

Refs: ADR-0071

Co-Authored-By: Claude <noreply@anthropic.com>
…h tiers it takes

The five tiers were a fiction the UI told the user. `/effort` listed all five for every
model and `gateReasoningEffort` passed each one straight through, so a tier the model
never published reached the adapter and came back a 400 — or, worse, was quietly
downgraded to something that still thinks.

- `gateReasoningEffort` takes a `resolveEffortTiers(model)` and returns a discriminated
  result — `send` / `unset` / `uncontrollable` / `rejected` — instead of a bare tier, so
  a caller cannot forget the case where the model has no reasoning control at all.
- `/effort` and the model picker's effort sub-step build their list from
  `effortTiersFor(model)`; the sub-step does not open at all for a model with no control.
  `gpt-5-pro` publishes exactly one effort level and now offers exactly one row, rather
  than five that mostly 400. `deepseek-reasoner` publishes none and offers nothing.
- The CLI session host and the workflow engine builder inject the resolver from the one
  catalog (`catalogModel` + `acceptedTiers`), so the picker and the wire read the same
  source of truth.

Refs: ADR-0071

Co-Authored-By: Claude <noreply@anthropic.com>
…shes two

The Sonnet round on step 3 found two more paths where the picker and the wire answer
the same question differently — the bug class this work exists to close, one level in.

- `acceptedTiers` treated `effortValues` and `budgetTokens` as an either/or. `claude-opus-4-5`
  publishes BOTH (ladder ['low','medium','high'], budget {min:1024}), and its adapter already
  serves `max` from the budget — so the exclusive branch hid, from the picker and from the
  failover re-gate, a tier the model serves perfectly well. The rescue turn ran with no
  reasoning at all. The axes now UNION.
  Not a blind union, though: only Anthropic and Gemini have a budget field to put it in.
  OpenAI's `reasoning_effort` and DeepSeek's `thinking` are effort-shaped, so a budget in the
  catalog buys those models nothing, and offering every tier for one would be the same
  divergence wearing a different hat (`honorsBudget`).

- The DeepSeek arm sent `thinking` the moment the model had ANY descriptor. `deepseek-reasoner`
  ships `reasoning: {}` — it reasons, and upstream describes no knob — so `{}` is not
  `undefined`, the gate opened, and every tier including `off` went on the wire for a model
  that never said it takes one. Both arms of the shared OpenAI-compatible adapter now ask
  `acceptedTiers`, the same function the picker asks.
  `canDisableReasoning` was the enabling half of that: it answered `true` for anthropic and
  deepseek on the PROVIDER's general ability to disable, regardless of the model. An empty
  descriptor has nothing to turn.

Plus `effortTiersFor(modelId)` — one exported predicate, so the four surfaces that currently
hand-write `catalogModel(m) + acceptedTiers(...)` cannot drift apart.

Refs: ADR-0071

Co-Authored-By: Claude <noreply@anthropic.com>
…fort tier" — now it has one

The Opus round on step 4 found two blockers, and both are the same shape: step 4 migrated
the pickers and the engine's gate to the catalog and left the other half of the CLI on the
old boolean. The two then disagreed, and every disagreement is a bill the user cannot see.

- `chat.ts` still asked `modelSupportsReasoning` — an id heuristic over the hand-typed
  pricing table — for the footer indicator and for `/effort`. It contradicts the catalog on
  SIXTEEN shipped models. On `claude-sonnet-4-5` (absent from `MODEL_PRICING` entirely) the
  picker offered five tiers, the engine sent the chosen one, and this line decided the model
  was not capable and blanked the footer: the user paid for extended thinking with no
  indicator it was on, and `/effort` could never tick the tier it had just bound.

- The typed `/effort <tier>` never went through the picker. It validated against the fixed
  five and applied unconditionally, so on twenty-four models it accepted a tier the gate then
  silently dropped — `/effort low` on `gpt-5.4-pro` answered "applies to your next message",
  showed `low` in the footer, and sent nothing. It now refuses, and names what the model
  does take.

Both now ask `effortTiersFor`, which moves to `@relavium/llm` as ONE exported predicate. The
three hand-written `catalogModel(m) + acceptedTiers(...)` copies (two hosts + the picker) are
gone; the CLI keeps only an ordering projection of it, because a Set has no order a UI can use.

And the gate no longer withholds in silence. It replaced a loud provider 400 with a quiet
no-op, which on an authored workflow is the worse of the two — the run succeeds, the knob does
nothing, and the bill lands at the provider's default tier. `onEffortWithheld` hands the host
the gate's own verdict (carrying the tiers the model WOULD take); the chat surface puts it in
the transcript, `relavium run` writes it to stderr — never stdout, which `--json` owns.

Also: a stale `reasoning_effort` in config survives a model-only default write, so switching
the default model onto `gemini-2.5-pro` — which cannot disable thinking at all — left `off`
bound, inert, and displayed. The session seed now says so instead.

Refs: ADR-0071

Co-Authored-By: Claude <noreply@anthropic.com>
…laces that build a session

The Sonnet round on step 4 found the previous commit had built the channel and then
mostly not connected it. `onBudgetWarning` — the same shape, the same purpose — is
threaded through every one of these; `onEffortWithheld` reached only the two straight-line
chat builds and `relavium run`.

Silent everywhere else: `/clear`, the `/models` reseat, both bare-Home chat builds,
`relavium agent run`, and `relavium gate`. On `agent run` and `gate` the miss is total —
there is no picker, no footer, no client-side check of any kind — so an authored
`reasoning_effort: low` on `gpt-5.4-pro` ran, was dropped, and billed at the provider's
default with nothing said anywhere. That is the exact failure the previous commit's message
describes as the reason the channel exists.

Three more from the same round:

- The notices moved out of `render/tui/effort-picker.ts` into `chat/effort-notice.ts`. The
  engine host and the session host were reaching into the TUI layer to build a sentence.
- The model id is SANITIZED. It comes from an authored YAML — `nonEmptyString`, no charset
  restriction — and two of the sinks write the note raw to a terminal, so an escape sequence
  in a crafted workflow's model id reached the terminal uninterpreted. `models.ts` already
  guards the same input for the same reason.
- `onceEffortNotice`: the gate runs on EVERY turn and every agent-node execution, and a
  withheld tier is a standing condition, not an event. A stale `off` bound on `gemini-2.5-pro`
  (which cannot disable thinking at all) grew a fresh transcript line every turn; an agent
  inside a `loop` printed the same stderr warning per iteration.

And the fourth answer is gone for good: `ModelCatalogEntry.supportsReasoning` and the
`modelSupportsReasoning` id heuristic behind it are DELETED, not just unused. Nothing read
them any more, but they sat on the public seam of `@relavium/llm` — a boolean that
disagreed with the catalog on sixteen shipped models, one import away from any surface that
asked the wrong question again. `effortTiersFor` is the answer.

Refs: ADR-0071

Co-Authored-By: Claude <noreply@anthropic.com>
…ld name was wrong on half the population

The other half of the maintainer's "max tokens errors" (ADR-0071 §7/§10a).

THE CLAMP. Nothing compared an authored `max_tokens` against the model's real output limit,
because nothing KNEW the limit: `MODEL_PRICING` carried a context window and no output
ceiling at all. So `max_tokens: 200000` on a 64 000-token model was a 400 on every single
turn, and the workflow it sat in never ran. The catalog carries both numbers, so
`cappedMaxTokens` holds the cap at or below the model's ceiling — DOWN, never up. A cap
BELOW the ceiling is the author's deliberate budget (a cost control, a latency bound), and
raising it to the maximum would spend their money for them.

Anthropic's thinking budget is derived FROM that cap, and the provider requires
`budget_tokens < max_tokens` — so clamping the cap and not the derivation would have traded
one 400 for another. The value is computed once and both uses read it.

THE DIALECT. OpenAI's official Chat Completions deprecated `max_tokens` in favour of
`max_completion_tokens`, and its reasoning models reject the old field outright. But this
same adapter serves every custom OpenAI-compatible `base_url` — LM Studio, Ollama, vLLM,
LiteLLM, an enterprise gateway — most of which implement only the legacy field, so switching
globally would have traded one broken population for another. The rule is by ENDPOINT, not by
provider: OpenAI's own API gets the modern field; DeepSeek's own API and every custom base URL
keep `max_tokens`.

`official` is not a synonym for `openai`: DeepSeek's `api.deepseek.com` is OUR default, not a
caller's override, so it is official — and it still takes the legacy field.

The same endpoint flag governs the clamp. A custom `base_url` may serve something entirely
different under a familiar model id, and silently LOWERING a number the user typed on a model
we cannot describe is a behaviour change we have no right to make. That it lands the opposite
way from the reasoning field — which we withhold on an unknown model — is deliberate:
withholding a field we cannot justify is safe, lowering a number the user chose is not.

Refs: ADR-0071

Co-Authored-By: Claude <noreply@anthropic.com>
…h — so BOTH went out

The Opus round on step 5 found a regression the dialect rule introduced, and a money bug it
exposed.

- **Both cap fields on the wire.** The escape-hatch merge is `{...providerOptions, ...body}`,
  and `body` winning was automatic only while the mapped key and the escape-hatch key were
  the SAME STRING. Renaming the mapped key to `max_completion_tokens` meant the old one no
  longer shadowed anything, so a caller's `providerOptions.max_tokens` rode alongside it —
  and OpenAI rejects a request carrying both. A 400 on exactly the population §10a rescues.
  The adapter now guarantees EXACTLY ONE cap field: whichever we map wins outright, the other
  key is dropped, and an un-mapped escape-hatch cap stands untouched (which is the override
  ADR §10a promised — no config key exists, and none is needed).

- **"Official" was decided by "did a caller pass a string".** The CLI stores a `--base-url`
  verbatim, so registering OpenAI with its own endpoint spelled out —
  `https://api.openai.com/v1/`, one trailing slash — was classified CUSTOM: the deprecated
  field, and no clamp, on the official API. That is this very bug, restored by a typo. The
  endpoint is decided by resolved HOST now.

- **The budget governor pre-authorized spend on the UNCLAMPED cap.** Every hop from
  `agent-runner` through the fallback chain to `checkPreEgress` carried the raw authored cap
  while only the adapter clamped. `gemini-2.5-pro` with `max_tokens: 200000` (ceiling 65 536)
  was estimated at three times the spend the model can physically produce — `on_exceed: fail`
  killed the run over money that could never be spent, `pause_for_approval` gated a human on
  the same phantom. Before the clamp landed the request just 400'd, so the gap was invisible.
  `estimateMaxNextCost` now prices the cap the wire will actually carry.

Plus: `wasCapClamped` is deleted rather than shipped with no caller; the Gemini clamp gets the
tests it never had (including the clamped-cap → `thinkingBudget` coupling); the seam doc records
that `maxTokens` is clamped and that the OpenAI wire field is endpoint-dependent; and the
Anthropic SDK's refusal of a large non-streaming `max_tokens` — which the clamp cannot fix,
because every Anthropic ceiling sits above the SDK's threshold — is filed in deferred-tasks
rather than left as a comment in a test.

Refs: ADR-0071

Co-Authored-By: Claude <noreply@anthropic.com>
…-authorizing real spend

The Sonnet round on step 5 found the clamp fix's own mirror image, and a table drift that is
the argument for this whole ADR.

- **The estimate clamped a CUSTOM endpoint.** `estimateMaxNextCost` always assumed the
  official rule, but the adapter deliberately does NOT clamp a custom `base_url` — a gateway
  may serve anything under a familiar id. So for a LiteLLM/vLLM endpoint the estimate landed
  BELOW what the wire can spend: `gpt-5.5` with `max_tokens: 500000` was pre-authorized at the
  128 000 ceiling, and the governor waved through a call it exists to stop. Over-estimating
  kills a valid run; under-estimating spends the user's money, and this was the second kind.
  The endpoint is now injected — `ProviderResolver.endpointKind` → `BudgetGovernor` → the
  estimate — so the number the governor reasons about is the number the adapter will send.
  The engine still knows nothing of base URLs; the host answers, exactly as it does for price.

- **The two tables disagreed, and nothing compared them.** `claude-sonnet-4-6` carried
  `maxOutputTokens: 64_000` in the hand-typed registry and `128_000` in the generated catalog;
  the clamp reads the catalog, the CLI's model list reads the registry, so the user was shown
  one ceiling while the wire enforced another. A guard added to catch it found a SECOND drift
  the review had missed — `gpt-5.5`'s context window, 1_000_000 by hand against 1_050_000
  generated, which the context-fullness footer has been dividing by. Both corrected to the
  catalog, and the guard stands until Step 6 deletes the registry.

- **A trailing-dot FQDN is the same host.** `https://api.openai.com./v1` is a legitimate
  spelling that DNS resolves identically, the CLI stores a `--base-url` verbatim, and the
  adjacent SSRF check already normalizes it — so leaving it unnormalized classified the REAL
  official endpoint as custom: deprecated field, no clamp. The bug, reinstated by a dot.
  Normalized in the adapter and in the CLI's own custom-host predicate, which must agree.

Refs: ADR-0071

Co-Authored-By: Claude <noreply@anthropic.com>
…e source, and the USER outranks it

THE BIG SWAP (ADR-0071 §1). `MODEL_PRICING` is gone: twelve models, hand-typed from four
pricing pages, re-verified whenever someone remembered. `pricing.ts` drops from 290 lines to
96 and keeps only what a price IS — the contract the CostTracker bills against, the pre-egress
governor estimates against, and a `models pricing` row instantiates.

The numbers come from the generated catalog now (eighty models, synced from models.dev with
money-guards that refuse a silent price change), and from the user, who OUTRANKS it.

**The precedence flipped, and that is the point.** Registry-first protected the user from
mispricing a model *we* had verified by hand — a protection that made sense while the table
was ours. It is not ours any more, and by the time it was retired it had drifted from the
catalog on two numbers without anyone noticing. The user has a negotiated rate, an enterprise
discount, or simply a price our snapshot has not caught up with, and they are the one holding
the invoice. `priceModel` resolves user → catalog → throw.

So `models pricing <a-model-the-catalog-knows>` no longer REFUSES. It used to, honestly: the
table always won, so the override would have been a silent no-op. Now it takes effect, which
is the entire reason to type the command.

Also retired, because they answered the wrong question:
- `isCanonicalModelId` / `KNOWN_MODEL_IDS` / `CanonicalModelId` — "is this one of OUR models".
  The honest question is "can we price this one", and a user-priced model is perfectly billable
  while never having been canonical.
- `ModelPricing.reasoning` (a boolean) — a price is not the place to answer a capability
  question, and "does this model reason" is not what the wire asks anyway.
- The static `deprecatedAt`. models.dev does not publish a retirement date and no source we
  have does; it comes from the live provider list (and the user) alone. The provider is the
  only one who knows when the provider is retiring something — a date typed into a file is a
  date that goes stale in a file.

BREAKING CHANGE: `MODEL_PRICING`, `KNOWN_MODEL_IDS`, `isCanonicalModelId` and `CanonicalModelId`
are removed from `@relavium/llm`'s public surface. `PricingSource` reports `'catalog'` where it
reported `'registry'`. A user pricing row now overrides a catalog price instead of being refused.

Refs: ADR-0071, ADR-0064, ADR-0065

Co-Authored-By: Claude <noreply@anthropic.com>
…ken — all of them money

The Opus round on step 6 found the ADR's own clauses violated by the code that implements it.

**Cached reads billed at ZERO.** `catalogPricing` wrote `?? 0`, and `cost()` computes
`cacheReadTokens × rate` — so 0 does not mean "charge the normal rate", it means charge
nothing. Eleven catalog models publish no cache rate, OpenAI auto-caches, and on `o1-pro`
($150/MTok in) the cached fraction of every prompt billed at $0.00. ADR-0071 §10 exists to
forbid exactly this line — "absent ⇒ fall back to the full input rate" — and the comment I
wrote above it claimed that was what 0 did. The test that "checked" it compared the projection
to `x ?? 0`, the very expression it was testing, so it certified the bug. It now asserts the
behaviour: a 100k cached read must cost the input rate, not zero.

**The loud divergence never got built.** §5 makes the removal of "a user can never misprice a
shipped model" conditional on the divergence being VISIBLE. It wasn't: `models pricing` printed
its confirmation and said nothing about the catalog price it had just overruled. It now names
both, in prose and in `--json` (`overriddenCatalogPrice`). The user gets what they asked for;
they simply cannot do it in silence.

**The merge and the cost path disagreed about which user rows apply.** `mergeModelCatalog`
drops a row whose provider contradicts the catalog's; `priceModel` read the overlay
unconditionally and billed it. So `models pricing gpt-5.5 --provider anthropic --input
0.00000001` zeroed a shipped OpenAI model's cost — realized fold and pre-egress estimate
alike — while the picker went on displaying $5/MTok. Not merely silent: actively wrong. The
guard now lives in the shared overlay builder, and the command refuses the write.

Three more from the same round:

- **A partial override was not partial.** The DB's columns are NOT NULL DEFAULT 0, so a user
  who types only `--input`/`--output` arrived here with zeroes for the window, the ceiling and
  the cache rate — harmless when a user row could only describe an unknown model, destructive
  under user-first precedence. The picker showed a 0-token context window for GPT-5.5, and its
  cache reads billed at nothing. Each unstated dimension now inherits the catalog's.

- **Deprecation went permanently `false` for every model in the product.** I deleted the two
  DeepSeek dates arguing they should come from the live list — but no adapter populates
  `ModelListing.deprecatedAt` (the OpenAI list is id-only), so `deepseek-chat` was set to stop
  working in eleven days with nothing to say so. ADR-0071 §10 always specified a Relavium-owned
  overlay for precisely this; I removed the data instead of building it.

- **The context tiers were dead data.** Parsed, guarded, exported — and read by no pricing code,
  so a >200k `gemini-2.5-pro` turn billed at the cheap tier. A tolerable gap while those models
  threw `UnknownModelError`; a silent 2× under-bill the moment the catalog started pricing them.
  The realized fold prices the tier the prompt landed in; the estimate takes the highest, because
  a cap that over-estimates refuses an affordable turn while one that under-estimates lets money
  out. Only one of those is recoverable.

Refs: ADR-0071, ADR-0064, ADR-0065

Co-Authored-By: Claude <noreply@anthropic.com>
…ser to run a command I never wrote

The Sonnet round on step 6 found six, and the most embarrassing is the simplest: the "loud
divergence" message tells the user to run `models pricing <model> --clear` to revert. There
was no `--clear`. There was no way back AT ALL — an override could be corrected but never
removed, so a user who mispriced a catalog model by mistake was stuck with their own number
for good. It exists now: a soft-deactivate (`model_catalog.id` is an FK target from five
tables, so never a DELETE), and the model falls back to the catalog's price.

**A cache HIT could cost five times a cache MISS.** A partial override inherited the
catalog's ABSOLUTE cache rate — so a user who negotiated $0.10/MTok input on `gpt-5.5`
inherited its $0.50 cache rate, and paid five times more for a cached token than a fresh one.
A cache read is a DISCOUNT off input; the discount is what a partial override inherits, and it
can never exceed the input rate it is a discount off.

**`--cached 0` could not be said.** The money column is NOT NULL DEFAULT 0, so it cannot tell
"the user typed zero" — a genuinely free cache on a self-hosted endpoint — from "the user never
mentioned cache reads". Reading the zero as free bills a whole class of tokens at nothing;
reading it as unset discards an explicit instruction. One column cannot hold both meanings, so
the FACT of the statement gets its own (migration 0011, additive; verified on a copy of the
maintainer's real 3.5 MB history.db — 84 catalog rows and 52 sessions intact).

**A user-priced tiered model lost its tiers**, which quietly reopened the same silent 2×
under-bill the previous commit closed — just on the overlay path. A tiered model is tiered
whoever is paying: the tiers are inherited as MULTIPLIERS, so the user's own rate still doubles
above 200k. The catalog's $2.50 is a fact about a price they no longer pay; that it DOUBLES is a
fact about the model.

Plus the tests that should have caught all of it: `model-catalog-view.test.ts` priced only
models the catalog has never heard of, so nothing exercised an override of a model it KNOWS —
which is the entire point of the flip, and where every one of these defects lived.

One gap is documented rather than guessed at: cache WRITES are billed at the flat rate, never a
tier's, because models.dev's tier schema publishes `cache_read` and no `cache_write`. Scaling
one from the input tier's multiple would be a fabrication on a money path. Filed.

Refs: ADR-0071

Co-Authored-By: Claude <noreply@anthropic.com>
…ays default-OFF

`relavium models refresh` re-fetches BOTH axes now (ADR-0071 §4a), because "refresh what I
know about models" is one user intent, not two: the provider lists say which models a key can
REACH, and the catalog says what they COST and which reasoning tiers they take. `--providers`
and `--catalog` address one each; neither flag means both.

The catalog half needs **no provider key**, which is the form that makes the default livable: a
user with nothing configured yet, who wants to see what things cost, types one command and gets
them. A `models refresh` with no key but a delivered catalog is no longer an error — telling
someone their command failed while half of it worked is a lie.

**Default OFF, and the default is the design.** `[catalog] auto_refresh` is `false`. Relavium
SHIPS the catalog: a fresh install with no network prices every model it carries, offline, and
contacts nobody. A local-first tool that reaches out to a third party by default violates its
own spirit even when the payload is innocuous (rule 6), so the standing egress is opt-in — while
an explicit `models refresh` fetches regardless, because a command the user typed IS consent.
ADR-0068's convention: ship a risk-bearing surface default-OFF, validate, then decide.

**Additive only, and the shipped snapshot is the FLOOR.** A refresh may add models and enrich
thin ones; it may never leave one less described than it shipped. An unreachable models.dev, a
5xx, a truncated body, a row that arrives without a price — every one of them is a NO-OP, and
the snapshot answers. The cost cap is a safety control, and it does not get to lapse because a
third party had a bad deploy.

The posture (§8): one compile-time-constant destination, HTTPS, `redirect: 'manual'` with an
off-host response refused, no key, no cookie, no user data, Zod at the boundary — and the same
`normalizeCatalog` the offline generator uses, so a refreshed row and a shipped row cannot end
up shaped differently.

The cache holds the RAW payload, not the normalized rows. Caching the normalized ones would
freeze today's normalizer into the file, so a release that FIXED a normalization bug would go on
serving the old wrong rows to everyone who had ever refreshed. Re-normalizing on load means an
upgrade repairs the cache for free.

Refs: ADR-0071, ADR-0064, ADR-0068

Co-Authored-By: Claude <noreply@anthropic.com>
…ded $14.50 as $0.00

The Opus round on step 7 found the one thing that mattered: `installCatalogRefresh`'s
"additive only, the snapshot is the FLOOR" guard enforced no floor. It read
`if (shipped === undefined || model.output > 0)`, and the line above had already dropped
every `output <= 0` — so the second clause was ALWAYS true, the whole guard was `if (true)`,
and a refreshed row replaced its shipped row wholesale. A moved price, a dropped context
tier, a shrunk reasoning ladder all sailed through.

Executed end to end: a payload that passes Zod (input 0 is legal, output 1 microcent is
`> 0`) downgraded `gpt-5.5` from the shipped $30/Mtok output to ~free, and a 1M-in/100k-out
turn that truly costs $14.50 recorded as $0.00 — so a `max_cost_microcents` cap of any value
stopped tripping. It does not take a hostile models.dev; an upstream typo (a dropped decimal)
or a preview model published at `input: 0` reproduces it. The safety control this whole ADR
exists to build, defeated by the code that builds it.

The fix is the reading §4.2 and §9 require together: the runtime refresh is **purely additive
for shipped models** — it never writes a model the snapshot already pins, because a
human-verified price is a release-time decision, not a runtime one (§9). It adds the long
tail and nothing else, so it CANNOT make a known model cheaper — it does not write one.

The floor test lives in `@relavium/llm`'s `lookup.test.ts`, source-direct, because the CLI
test imports across the package boundary from `dist` and a break there would not fail until a
rebuild — the stale-artifact trap that makes a break-verification lie. Verified: with the
skip deleted, the source-direct test fails; the CLI test keeps the integration leg.

Four more from the same round:
- The cache stored the FULL 166-provider ~2 MB `api.json`, re-read and re-parsed by `seedCatalog`
  on every invocation including `--help`. It now caches only the four mapped providers.
- The cache write is temp-then-rename now, so a concurrent reader never sees a torn file.
- `seedCatalog`'s dead `io` parameter is gone.
- ADR-0071 §4.2/§9 amended to record the additive-only reconciliation.

Refs: ADR-0071

Co-Authored-By: Claude <noreply@anthropic.com>
… cap measured too late

The Sonnet round on step 7 confirmed the floor holds (it reverted the fix and watched the
source-direct test fail), and found three reporting/robustness gaps on the same money+egress
path.

- **`added` overcounted.** The host counted new ids against the snapshot by hand, while
  `installCatalogRefresh` separately dropped any new-but-unpriced id — so a payload with one
  priced and one unpriced new model reported `added: 2` while only one landed. The count now
  comes from `installCatalogRefresh` itself (it returns how many it admitted), so the number
  cannot drift from the floor that produced it.

- **The byte cap measured after buffering the whole body.** `response.text()` read everything
  into memory and only then checked the length — so on a fast link a misbehaving host could
  deliver many times the 16 MB cap inside the timeout before the check fired. The comment
  claimed it "refuses an endless body"; it did not. `readCapped` streams the reader, counts as
  it goes, and aborts the transfer the moment the running total crosses the ceiling.

- **Config was resolved twice per invocation.** `seedCatalog` ran a full `loadResolvedConfig`
  — a project-dir walk plus up to three TOML parses — at boot on EVERY command, including bare
  Home and `--help`, and `withModelsDeps` then resolved it again. The seed needs only the home
  dir, which is `homedir()` and needs no config parse: a new `resolveHomeDir` gives it that
  cheaply, and the boot path stops paying for work it throws away.

Plus the tests those gaps hid: a mixed priced/unpriced payload asserting `added: 1`, an
oversized `ReadableStream` asserting the reader is aborted early rather than drained, and the
`[catalog] auto_refresh` behaviour on both settings (on ⇒ a bare `models` refreshes before it
lists; off, the default ⇒ no network at all).

Refs: ADR-0071

Co-Authored-By: Claude <noreply@anthropic.com>
…_cost_cap refuses it

A cost cap on a model we cannot price is a false sense of safety (ADR-0071 §K7). The governor
catches `UnknownModelError` and degrades to `allow` — the right trade for a self-hosted model
with ~no metered cost — but it did it in SILENCE. The user who set `max_cost_microcents` never
learned that the one turn it mattered for slipped past the cap entirely.

- The governor now returns a `{ kind: 'unpriced', model }` result and fires an `onUnpriced`
  sink, once per model (a `loop` over an unpriced model must not repeat it every turn). The
  chat surface routes it to the transcript, `relavium run` to stderr — never stdout, which
  `--json` owns. The turn still runs; it is just no longer silent about the hole.

- `strict_cost_cap` (default false) inverts the trade for anyone who wants it: a workflow
  `budget.strict_cost_cap` or `[chat] strict_cost_cap`, and an unpriced model is REFUSED
  pre-egress instead of waved through. For a user who set a cap SPECIFICALLY to bound an
  untrusted model, "if you cannot price it, do not run it" is the safer default — and now it
  is available. The refusal names the model and how to fix it (`models pricing <model>`, or
  turn the flag off).

`BudgetExceededError` gains an optional message so the strict refusal reads as what it is —
"cannot be enforced" — rather than a spend-exceeded projection.

Documented in both canonical homes (config-spec.md `[chat]`, workflow-yaml-spec.md `budget`),
and the governor's break-verify confirms the block: with `strict_cost_cap` ignored, the
BLOCKS test fails.

Refs: ADR-0071, ADR-0028

Co-Authored-By: Claude <noreply@anthropic.com>
… other surfaces

The Opus round on step 8 found the same enumeration gap step 4 taught: `onUnpriced` was wired
at some session/engine builds and not all, and it defaults to silent — so the "say it out
loud" half of the commit type-checked its way out of the flagship surface.

- **The in-Home chat** (`drive-home.tsx`, fresh + `/models` reseat) wired `onBudgetWarning`
  and `onEffortWithheld` but not `onUnpriced`. It is the primary interactive surface in Phase
  2.6, and there an unpriced turn slipped past the cap in exactly the silence this commit
  exists to end. Both builds route to the transcript now.
- **`relavium agent run`** and the resumed **`relavium gate`** built a governor without the
  sink too — both to stderr now (never stdout, which `--json` owns).

Two more from the same round:
- The notice text lived inline in four places, in two formats — one printed USD, two printed
  raw micro-cents, and the USD one rendered a tiny cap as `$1e-8`. One `unpricedModelNote`
  home now, with a `capUsd` formatter that reads `$0.00`, so all five surfaces say it the same
  way.
- `strict_cost_cap` is inert without a positive `max_cost_microcents` (no cap, nothing to
  enforce) — documented in config-spec.md rather than left as a silent surprise.

Plus the tests: `buildGovernorWiring` now asserts an unpriced model degrades-and-notifies
(deduped per model) and that `strict_cost_cap` hard-fails regardless of `on_exceed`; and the
governor's own "OFF degrades with a notice" test actually drives `checkPreEgress` now instead
of discarding the captured sink.

Refs: ADR-0071

Co-Authored-By: Claude <noreply@anthropic.com>
…ropped them on the floor

The Sonnet round on step 8 found the bug two commits missed — including the fold whose whole
job was to close the enumeration gap. `WorkflowEngine`'s constructor stored only `#resolvePrice`
and forwarded only that to each `RunExecution`. `deps.onUnpriced` and `deps.resolveEndpoint`
reached the class and DIED there — never a field, never forwarded, in `start()` or
`resumeFromCheckpoint()`.

So on the two workflow surfaces — `relavium run` (fresh) and `relavium gate` (resumed) — both
of these were inert:

- **The unpriced notice (§K7)** was dead. A user relying on it to learn their cost cap silently
  did not apply to a self-hosted model saw nothing, on exactly the batch/automation surfaces
  where an unattended run is the highest-risk place for the cap to lapse.

- **The endpoint resolver (§7)**, added in step 5's own fold and never wired here, was ALSO
  dead — a pre-existing spend-safety bug the same code-reading surfaced. Without it the
  pre-egress estimate assumed `official` for every model, so a custom `base_url` under a
  familiar id was estimated at the clamped cap while the wire sends the un-clamped one: the
  governor under-authorizes and waves through a call it exists to stop.

Both are now stored and forwarded exactly like `#resolvePrice`, in both `start()` and
`resumeFromCheckpoint()`. It went uncaught because NO test exercised these deps at the
`WorkflowEngine` boundary — every passing test built `BudgetGovernor` or the session governor
directly, which are the surfaces that already worked. A regression test at the engine boundary
now fails without the forward (break-verified: delete it and the "reaches the sink THROUGH
WorkflowEngine" test goes red), plus a `strict_cost_cap`-blocks-through-the-engine case.

Three more from the round:
- `unpricedModelNote` takes the surface's own strict-setting spelling now — a chat user reads
  `[chat] strict_cost_cap`, a workflow user `budget.strict_cost_cap`, instead of a generic name
  that makes each guess which file to edit.
- `capUsd` gets direct tests (`$0.05`, `$10.00`, `$0.00` — the `$1e-8` bug pinned).
- The reseat's three notice sinks all route through one `noteToStore` indirection now, so none
  can TDZ on the store declared after the build — `onBudgetWarning` was the only one guarded,
  and the asymmetry was an accident waiting for a refactor.

Refs: ADR-0071, ADR-0028

Co-Authored-By: Claude <noreply@anthropic.com>
…nightly effort probe

A third party (models.dev) now feeds a safety control (the cost cap) and a wire parameter (the
reasoning tier). Two guards keep a stale or moved snapshot from silently breaking either — both
scheduled, neither gating a PR (`.github/workflows/models-catalog.yml`).

- **weekly-catalog-check** runs `pnpm sync:models:check`: it writes nothing and fails red when
  the committed snapshot has drifted — a new priced model, or (the one that matters) a moved or
  vanished price on a model we already ship. `sync.mjs`'s own money guards then refuse the
  moved-price / vanished-model commit without `--accept-price-changes` / `--accept-removals`, so
  taking it is a human decision in a reviewable diff, never a silent bot commit. The automatic
  PR for the purely-additive case is deferred — it needs a create-pull-request action pinned to
  a verified SHA, and inventing one unseen is the supply-chain risk rule 3 forbids; a red weekly
  check already surfaces the drift. Filed.

- **nightly-effort-conformance** is `effort.conformance.test.ts`: for a representative shipped
  reasoning model per provider it sends EVERY tier the catalog claims it accepts and asserts the
  real API does not reject it. This is the ONLY mechanism that catches a stale catalog claiming
  a tier a model has stopped accepting — the exact Gemini bug this whole ADR fixed, which no
  offline test can see (fixtures replay what we recorded; a unit test proves internal
  consistency, not that the provider agrees). Key-gated per provider, so it skips a missing
  secret and never gates a PR. An OFFLINE assertion pins the four probe ids to real shipped
  reasoning models, so a rename cannot quietly turn the live lane into a silent no-op.

ADR-0071 §9 amended to record both guards as wired.

Refs: ADR-0071

Co-Authored-By: Claude <noreply@anthropic.com>
…vider the bug came from

The Opus round on step 9 found the make-or-break flaw. The effort conformance test's Gemini
probe was `gemini-2.5-flash`, which is BUDGET-shaped — so the adapter sends `thinkingBudget`
(a number the API always accepts) and NEVER `thinkingLevel`, the one wire value that can 400
when a model stops accepting a tier. The lane passed no matter how the catalog drifted, on the
exact provider whose `thinkingLevel` acceptance was the original bug this whole ADR fixed.

The probe is `gemini-3-flash-preview` now — effort-shaped, so the adapter sends `thinkingLevel`
and the real provider is the independent arbiter, exactly as the anthropic/openai/deepseek lanes
already are. The offline gate confirms it has a graded ladder.

Two more from the round:

- **The weekly check went red on ANY drift, not just a moved price.** `sync:models:check` fails
  the moment models.dev adds any model in our four (fast-moving) providers — and a weekly check
  that is red every week trains the maintainer to ignore it, which is precisely the erosion
  sync.mjs's own comments warn against, defeating the moved-price protection it exists to give.
  The weekly job runs `pnpm sync:models` now, whose money guards red ONLY on a moved or vanished
  shipped-model price (the human decision) and pass on benign additive drift. New models still
  "merge automatically" via the deferred auto-PR.

- The nightly conformance step is scoped to `src/conformance`, so a flaky live case cannot widen
  the red surface to the whole package's unit suite.

ADR §9 amendment and the deferred-task corrected to match (moved-only red, effort-shaped probe).

Refs: ADR-0071

Co-Authored-By: Claude <noreply@anthropic.com>
…eck for one

The Sonnet round on step 9 found the offline gate — whose whole job is to keep a future edit
from silently reintroducing the budget-shaped tautology the Opus round just removed — asserting
only that the probe has a graded ladder. But a budget-shaped model ALSO reports a full ladder
(the budget axis fills every tier), so `gemini-2.5-flash` still passed the gate. The one CI
signal meant to prevent the tautology could not see it.

The gate now pins each probe as EFFORT-shaped (`effortValues` defined) — the property the live
guard actually needs, since it only bites when the adapter sends an effort wire value the
provider can reject. Break-verified: revert the probe to `gemini-2.5-flash` and the gate goes
red, naming it as budget-shaped.

Three more from the round:
- The Gemini probe is `gemini-3.5-flash` now — a GA effort-shaped model, not the `-preview` id
  the fold picked. A preview id is the one most likely to be renamed or pulled by the vendor
  independent of any drift, which would red the nightly lane for the wrong reason — the exact
  erosion the weekly-check scoping was careful to avoid.
- The live loop sends one call per distinct WIRE value, not per tier. DeepSeek's low/medium/high
  all map to `reasoning_effort: 'high'` and Gemini's `max` onto `HIGH`, so probing every tier
  fired real, billed calls proving the identical fact. The test title still names the full
  claimed ladder; only the sends are deduped.
- A garbled word ("Reding") in the ADR §9 amendment.

And the scope: the prior fold's `fix(llm,ci)` used `ci` as a scope, but commit-style.md's scope
list has no `ci` (it is a commit TYPE) — the workflow file is root tooling, which the standard
names `repo`. Corrected here.

Refs: ADR-0071

Co-Authored-By: Claude <noreply@anthropic.com>

@sourcery-ai sourcery-ai 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.

Sorry @cemililik, your pull request is larger than the review limit of 150000 diff characters

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR replaces hand-maintained model metadata with a generated models.dev catalog, adds guarded refresh and override flows, introduces per-model reasoning controls, and wires endpoint-aware pricing, strict unpriced-model governance, provider-aware defaults, CLI/TUI notices, persistence, tests, and documentation.

Changes

Catalog foundation

Layer / File(s) Summary
Generated catalog and guarded refresh
packages/llm/src/catalog/*, tools/sync-models-dev/*, apps/cli/src/engine/catalog-refresh.ts, .github/workflows/models-catalog.yml
Adds normalized catalog contracts, a generated snapshot, structural price/removal guards, additive runtime refresh, cache loading, and scheduled/manual CI checks.
Catalog-backed reconciliation and persistence
packages/llm/src/model-catalog.ts, packages/llm/src/cost-tracker.ts, apps/cli/src/commands/models-pricing.ts, packages/db/src/*
Uses catalog pricing with user overrides taking precedence, tracks explicit cached-input rates, supports clearing overrides, and updates merged model metadata.
Configuration and provider identity
packages/shared/src/config.ts, apps/cli/src/config/*, apps/cli/src/chat/default-agent.ts, apps/cli/src/home/*
Adds catalog refresh and strict-cost-cap settings, couples default model/provider resolution, and persists provider identity with selected defaults.

Runtime behavior

Layer / File(s) Summary
Reasoning controls and adapters
packages/llm/src/reasoning-wire.ts, packages/llm/src/adapters/*, packages/core/src/engine/reasoning-effort.ts
Derives accepted tiers per model, selects provider-specific wire controls, clamps official output caps, and withholds unsupported controls.
Governance and engine wiring
packages/core/src/engine/*, apps/cli/src/engine/build-engine.ts, apps/cli/src/chat/session-host.ts
Adds structured effort gating, endpoint-aware cost estimation, unpriced-model notices, and optional strict cost-cap refusal.
CLI and TUI surfaces
apps/cli/src/commands/*, apps/cli/src/render/tui/*, apps/cli/src/chat/effort-notice.ts
Shows model-accepted effort tiers, propagates notices across session lifecycle paths, and adds catalog refresh axes and pricing-clear semantics.
Validation and documentation
packages/llm/src/conformance/*, apps/cli/src/*/*.test.*, docs/decisions/*, docs/reference/*
Adds adapter, catalog, governance, refresh, configuration, and conformance coverage and documents the updated contracts.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • HodeTech/Relavium#7: Earlier pricing and cost-tracking foundation extended by catalog-backed pricing.
  • HodeTech/Relavium#26: Earlier budget-governor path extended for unpriced-model and strict-cap handling.
  • HodeTech/Relavium#66: Earlier reasoning-effort plumbing refactored into tier-based gating.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately captures the PR’s main change: moving model metadata sourcing to models.dev under ADR-0071.
Docstring Coverage ✅ Passed Docstring coverage is 80.28% which is sufficient. The required threshold is 80.00%.
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 development

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 (1)
apps/cli/src/commands/chat.ts (1)

47-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Merge duplicate effort-notice.js imports.

SonarCloud flags '../chat/effort-notice.js' as imported multiple times — the line-range details show a separate import of effortRejectedNote/effortUnavailableNote/onceEffortNotice from the same module elsewhere alongside this effortTiersFor import. Consolidate into a single import statement.

♻️ Proposed fix
-import { effortRejectedNote, effortUnavailableNote, onceEffortNotice } from '../chat/effort-notice.js';
-...
-import { effortTiersFor } from '../chat/effort-notice.js';
+import {
+  effortRejectedNote,
+  effortTiersFor,
+  effortUnavailableNote,
+  onceEffortNotice,
+} from '../chat/effort-notice.js';
🤖 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 `@apps/cli/src/commands/chat.ts` around lines 47 - 48, Consolidate the
duplicate imports from ../chat/effort-notice.js in chat.ts into one import
declaration containing effortRejectedNote, effortUnavailableNote,
onceEffortNotice, and effortTiersFor; preserve all existing usages and remove
the separate declaration.

Source: Linters/SAST tools

🤖 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 `@apps/cli/src/chat/effort-notice.ts`:
- Around line 1-3: Update the workspace dependency resolution for apps/cli so
`@relavium/core` and `@relavium/shared` resolve to their workspace source or
generated declarations before typechecking; use the existing project references
or path configuration rather than changing effort-notice.ts. Ensure
seamEffortTiersFor and EffortGateResult are available to eliminate the
downstream no-unsafe errors.

---

Nitpick comments:
In `@apps/cli/src/commands/chat.ts`:
- Around line 47-48: Consolidate the duplicate imports from
../chat/effort-notice.js in chat.ts into one import declaration containing
effortRejectedNote, effortUnavailableNote, onceEffortNotice, and effortTiersFor;
preserve all existing usages and remove the separate declaration.
🪄 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: 8922c721-7616-4854-b614-774c8275b482

📥 Commits

Reviewing files that changed from the base of the PR and between 95a5880 and 987d8df.

📒 Files selected for processing (31)
  • .claude/skills/add-llm-adapter/SKILL.md
  • apps/cli/src/chat/effort-notice.ts
  • apps/cli/src/chat/session-host.ts
  • apps/cli/src/commands/chat.ts
  • apps/cli/src/commands/models-pricing.ts
  • apps/cli/src/commands/models.ts
  • apps/cli/src/commands/specs-forwarding.test.ts
  • apps/cli/src/commands/specs.ts
  • apps/cli/src/config/load.ts
  • apps/cli/src/engine/build-engine.ts
  • apps/cli/src/engine/catalog-refresh.ts
  • apps/cli/src/engine/providers.ts
  • apps/cli/src/harness/regression.e2e.test.ts
  • apps/cli/src/home/drive-home.tsx
  • apps/cli/src/render/sanitize.ts
  • apps/cli/src/render/tui/chat-projection.ts
  • apps/cli/src/render/tui/effort-picker.test.ts
  • apps/cli/src/render/tui/effort-picker.ts
  • apps/cli/src/render/tui/model-picker.ts
  • docs/decisions/0065-provider-economics-and-extensibility.md
  • docs/roadmap/deferred-tasks.md
  • packages/db/drizzle/meta/0011_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/model-catalog-store.ts
  • packages/llm/src/adapters/anthropic.ts
  • packages/llm/src/adapters/openai.ts
  • packages/llm/src/catalog/models-dev-schema.ts
  • packages/llm/src/fallback-chain.ts
  • packages/llm/src/model-kind.ts
  • packages/llm/src/pricing.test.ts
  • tools/sync-models-dev/sync.mjs
💤 Files with no reviewable changes (1)
  • apps/cli/src/home/drive-home.tsx
🚧 Files skipped from review as they are similar to previous changes (13)
  • apps/cli/src/config/load.ts
  • packages/db/drizzle/meta/_journal.json
  • docs/decisions/0065-provider-economics-and-extensibility.md
  • tools/sync-models-dev/sync.mjs
  • apps/cli/src/commands/specs.ts
  • packages/llm/src/fallback-chain.ts
  • apps/cli/src/engine/build-engine.ts
  • apps/cli/src/render/tui/effort-picker.test.ts
  • apps/cli/src/commands/models.ts
  • apps/cli/src/engine/catalog-refresh.ts
  • apps/cli/src/render/tui/model-picker.ts
  • apps/cli/src/chat/session-host.ts
  • packages/db/src/model-catalog-store.ts

Comment thread apps/cli/src/chat/effort-notice.ts Outdated
Comment on lines +1 to +3
import type { EffortGateResult } from '@relavium/core';
import { catalogModel, effortTiersFor as seamEffortTiersFor } from '@relavium/llm';
import { REASONING_EFFORTS, type ReasoningEffort } from '@relavium/shared';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== `@relavium/llm` effortTiersFor export ==="
rg -n "export (function|const) effortTiersFor" packages/llm/src

echo "=== `@relavium/core` EffortGateResult export ==="
rg -n "EffortGateResult" packages/core/src -g '*.ts' -C2

echo "=== package.json 'main'/'exports' + built dist presence ==="
fd -a 'package.json' packages/llm packages/core --exec cat -n {} \;

Repository: HodeTech/Relavium

Length of output: 7408


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== apps/cli/src/chat/effort-notice.ts ==="
cat -n apps/cli/src/chat/effort-notice.ts

echo
echo "=== workspace tsconfig / eslint resolver hints ==="
fd -a 'tsconfig*.json' . | sed -n '1,40p'
echo
rg -n '"paths"|"references"|projectReferences|typescript-eslint|import/resolver|preserveSymlinks' \
  -g 'tsconfig*.json' -g '.eslintrc*' -g 'eslint.config.*' -g 'package.json' .

echo
echo "=== apps/cli package metadata ==="
fd -a 'package.json' apps/cli --exec cat -n {} \;

Repository: HodeTech/Relavium

Length of output: 13066


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== apps/cli/tsconfig.json ==="
cat -n apps/cli/tsconfig.json

echo
echo "=== packages/core/tsconfig.json ==="
cat -n packages/core/tsconfig.json

echo
echo "=== packages/llm/tsconfig.json ==="
cat -n packages/llm/tsconfig.json

echo
echo "=== root package/tsconfig references ==="
cat -n tsconfig.json 2>/dev/null || true
cat -n package.json | sed -n '1,120p'

Repository: HodeTech/Relavium

Length of output: 5206


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== dist presence for workspace packages ==="
for d in packages/core/dist packages/llm/dist packages/shared/dist apps/cli/dist; do
  if [ -d "$d" ]; then
    echo "-- $d"
    find "$d" -maxdepth 2 -type f | sed -n '1,40p'
  else
    echo "-- $d (missing)"
  fi
done

echo
echo "=== root tsconfig.base.json ==="
cat -n tsconfig.base.json

echo
echo "=== workspace package import resolution clues ==="
rg -n '"moduleResolution"|"paths"|"baseUrl"|"composite"|"references"|"declaration"' \
  tsconfig.base.json packages/core/tsconfig.json packages/llm/tsconfig.json apps/cli/tsconfig.json

Repository: HodeTech/Relavium

Length of output: 2231


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== tsc availability ==="
command -v tsc || true
tsc --version || true

echo
echo "=== resolution probe for apps/cli imports ==="
tsc -p apps/cli/tsconfig.json --noEmit --traceResolution 2>&1 \
  | rg -n "`@relavium/`(core|llm)|effort-notice|dist/index\\.d\\.ts|Module name|resolved to|was not resolved" \
  -C 2

Repository: HodeTech/Relavium

Length of output: 50374


Build the workspace deps before apps/cli typecheck

@relavium/core and @relavium/shared resolve to dist/index.d.ts here, but those files are missing in this workspace layout. That leaves seamEffortTiersFor(model) and EffortGateResult unresolved and triggers the no-unsafe-* errors on 75-78 and 87-96. Either point apps/cli at the workspace source via project references/paths or ensure the packages are built first.

🤖 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 `@apps/cli/src/chat/effort-notice.ts` around lines 1 - 3, Update the workspace
dependency resolution for apps/cli so `@relavium/core` and `@relavium/shared`
resolve to their workspace source or generated declarations before typechecking;
use the existing project references or path configuration rather than changing
effort-notice.ts. Ensure seamEffortTiersFor and EffortGateResult are available
to eliminate the downstream no-unsafe errors.

Source: Linters/SAST tools

…uplicate import

The linear-vs-quadratic wrap-shape ratio is GC-dominated on a shared CI runner: the
8x-larger case allocates 8x as much and pays GC the small case dodges, so even a
ratio of minimums is unbounded. It read 11.47 then 22.17 on two successive CI runs
for PROVABLY-LINEAR code — the second above the ~16.6 the injected quadratic
produces, i.e. a linear reading indistinguishable from the bug it exists to catch.
A test that is red regardless of the code is not a guard, so skip it under CI
(`it.skipIf(IN_CI)`): it runs on a warm local box where the ratio measures the
algorithm (real ~4.8, injected O(n^2) ~16.6) and belongs on a nightly perf lane, not
the per-PR gate. The CI-side regression signal for this same path stays the latency
budget test (`< 100ms`, 3–4x headroom, green on both CI runs).

Also fold a Sonar nit from the effort-notice repoint: chat.ts imported
../chat/effort-notice.js twice — merge into one declaration. (The CodeRabbit
"@relavium/core/@relavium/shared don't resolve → no-unsafe" finding is a false
positive: typecheck + lint are clean; the imports resolve via workspace project
references — the tool analyzed the file without the built deps.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@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.

🧹 Nitpick comments (1)
apps/cli/src/render/tui/chat-projection.test.ts (1)

1091-1095: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Keep the complexity guard runnable in an explicit performance lane.

it.skipIf(IN_CI) skips this test in every CI environment, including the dedicated nightly performance lane described in the comment. That removes the only automated check for the O(n²) regression; the latency-budget test is explicitly insufficient for that case. Keep it out of the per-PR gate, but add an opt-in override such as RUN_WRAP_SCALE_GUARD=1 for nightly CI, or document that this guard is manual-only.

Also applies to: 1151-1164

🤖 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 `@apps/cli/src/render/tui/chat-projection.test.ts` around lines 1091 - 1095,
Update the complexity-guard test’s skip condition to continue excluding normal
CI while allowing an explicit performance override such as
RUN_WRAP_SCALE_GUARD=1. Apply the same condition to the related guard block
around the additional referenced lines, preserving local execution and keeping
the latency-budget test behavior unchanged.
🤖 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.

Nitpick comments:
In `@apps/cli/src/render/tui/chat-projection.test.ts`:
- Around line 1091-1095: Update the complexity-guard test’s skip condition to
continue excluding normal CI while allowing an explicit performance override
such as RUN_WRAP_SCALE_GUARD=1. Apply the same condition to the related guard
block around the additional referenced lines, preserving local execution and
keeping the latency-budget test behavior unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6f4d9172-f3d0-4045-b184-10401f824bab

📥 Commits

Reviewing files that changed from the base of the PR and between 987d8df and aff4960.

📒 Files selected for processing (2)
  • apps/cli/src/commands/chat.ts
  • apps/cli/src/render/tui/chat-projection.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/cli/src/commands/chat.ts

…f-guard env override

CI `format:check` (a gate separate from lint/typecheck/test, which is why the earlier
folds passed while this went red) flagged 27 files of prettier drift — most predating
this work, some from the review folds. Run the repo's own `prettier --write` so the
committed tree matches the pinned prettier; changes are whitespace-only.

Also fold a reviewer nitpick on the reseat-carry SHAPE guard: gate its CI-skip on
`RUN_WRAP_SCALE_GUARD !== '1'` so a dedicated perf lane can still FORCE the (locally
stable, GC-flaky-on-shared-CI) ratio on a runner it controls, while the shared per-PR
gate keeps skipping it. The latency-budget test is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/cli/src/chat/effort-notice.test.ts (1)

40-50: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Strengthen the malicious-ID sanitization assertions.

The fixture does not contain a BEL character, so the BEL assertion is vacuous. Also, toContain('model') can pass from static notice text even if the supplied model ID is dropped entirely. Include BEL in the input and assert preservation of user-provided fragments such as evil and second-line.

🤖 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 `@apps/cli/src/chat/effort-notice.test.ts` around lines 40 - 50, The
sanitization test for effortRejectedNote must exercise BEL removal and verify
that model-ID content is preserved. Update the malicious model ID fixture to
include a BEL character, and replace the ambiguous model assertion with checks
that the supplied fragments “evil” and “second-line” remain in the sanitized
notice while existing ESC and newline exclusions stay intact.
🧹 Nitpick comments (1)
apps/cli/src/engine/model-catalog-view.test.ts (1)

246-249: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Keep unpriced models distinct from zero-priced models.

catalogPricing(id) returns undefined for unknown models, but ?? 0 converts that into a free price. Make this helper fail for unknown IDs or return undefined so the new unpriced-model tests cannot pass with a false zero expectation.

Suggested fix
-): number => catalogPricing(id)?.[field] ?? 0;
+): number => {
+  const pricing = catalogPricing(id);
+  if (pricing === undefined) {
+    throw new Error(`Missing catalog pricing for ${id}`);
+  }
+  return pricing[field];
+};

As per PR objectives, unpriced models must remain distinguishable from zero-priced models.

🤖 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 `@apps/cli/src/engine/model-catalog-view.test.ts` around lines 246 - 249,
Update the catalogRate helper to preserve the distinction between unknown and
zero-priced models: return undefined when catalogPricing(id) is undefined
instead of coercing it to 0, while retaining actual zero values for explicitly
free models. Adjust the helper’s return type and any dependent assertions or
expectations accordingly.
🤖 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.

Outside diff comments:
In `@apps/cli/src/chat/effort-notice.test.ts`:
- Around line 40-50: The sanitization test for effortRejectedNote must exercise
BEL removal and verify that model-ID content is preserved. Update the malicious
model ID fixture to include a BEL character, and replace the ambiguous model
assertion with checks that the supplied fragments “evil” and “second-line”
remain in the sanitized notice while existing ESC and newline exclusions stay
intact.

---

Nitpick comments:
In `@apps/cli/src/engine/model-catalog-view.test.ts`:
- Around line 246-249: Update the catalogRate helper to preserve the distinction
between unknown and zero-priced models: return undefined when catalogPricing(id)
is undefined instead of coercing it to 0, while retaining actual zero values for
explicitly free models. Adjust the helper’s return type and any dependent
assertions or expectations accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2509ac56-252e-4967-b975-f4b7e927e567

📥 Commits

Reviewing files that changed from the base of the PR and between aff4960 and 5702ee0.

📒 Files selected for processing (20)
  • apps/cli/src/chat/effort-notice.test.ts
  • apps/cli/src/commands/chat.ts
  • apps/cli/src/commands/models-dispatch.test.ts
  • apps/cli/src/commands/specs-forwarding.test.ts
  • apps/cli/src/commands/specs.ts
  • apps/cli/src/engine/catalog-refresh.ts
  • apps/cli/src/engine/model-catalog-view.test.ts
  • apps/cli/src/render/tui/chat-projection.test.ts
  • apps/cli/src/render/tui/effort-picker.test.ts
  • apps/cli/src/render/tui/model-picker.ts
  • packages/core/src/engine/budget-governor.test.ts
  • packages/llm/src/adapters/gemini.test.ts
  • packages/llm/src/adapters/openai.test.ts
  • packages/llm/src/adapters/openai.ts
  • packages/llm/src/catalog/lookup.test.ts
  • packages/llm/src/conformance/effort.conformance.test.ts
  • packages/llm/src/cost-tracker.test.ts
  • packages/llm/src/model-catalog.test.ts
  • packages/llm/src/output-cap.test.ts
  • packages/llm/src/reasoning-wire.test.ts
🚧 Files skipped from review as they are similar to previous changes (18)
  • packages/llm/src/output-cap.test.ts
  • apps/cli/src/commands/specs-forwarding.test.ts
  • packages/llm/src/catalog/lookup.test.ts
  • packages/core/src/engine/budget-governor.test.ts
  • apps/cli/src/render/tui/chat-projection.test.ts
  • apps/cli/src/commands/specs.ts
  • packages/llm/src/adapters/gemini.test.ts
  • packages/llm/src/cost-tracker.test.ts
  • packages/llm/src/reasoning-wire.test.ts
  • apps/cli/src/commands/models-dispatch.test.ts
  • apps/cli/src/render/tui/effort-picker.test.ts
  • packages/llm/src/conformance/effort.conformance.test.ts
  • apps/cli/src/engine/catalog-refresh.ts
  • packages/llm/src/adapters/openai.test.ts
  • apps/cli/src/render/tui/model-picker.ts
  • packages/llm/src/model-catalog.test.ts
  • packages/llm/src/adapters/openai.ts
  • apps/cli/src/commands/chat.ts

cemililik and others added 3 commits July 13, 2026 23:56
…ferrable id starts

Bug 3 of the live model-picker reports. After a provider list-refresh, an OpenAI id
`keepOpenAiModelId` admits by substring (`chat-latest` / `chatgpt-4o-latest`) appears in
the picker; selecting it and starting a chat crashed "cannot infer a provider for chat
model 'chat-latest'". Root cause: the bare-Home default-model WRITE path dropped the
provider — known authoritatively at pick time (the live `/models` list is per-provider) —
so the next chat re-inferred it from the opaque id, and the prefix map places no `chat*`.

Fix (ADR-0059 "the provider is authoritative, never re-inferred", extended to the config
path): add an optional `[preferences]/[chat].default_provider` (a closed ProviderId enum)
to the config schema; persist it alongside `default_model` in the `/models` picker
(home-controller `writeDefault`) and the onboarding wizard, in one atomic
`writeGlobalPreferences` write; resolve it (config/resolve) with the same precedence as
`default_model`; and pass it to `buildDefaultChatAgent` (agent-source → session-host), which
uses it VERBATIM and skips inference. As a fallback hardening, `inferProviderFromModel` is
now catalog-first (a catalog-known id resolves regardless of prefix). `keepOpenAiModelId` is
deliberately NOT tightened — `chatgpt-4o-latest` is a real chat model, and ADR-0064 §6 is
"dim, never hide".

Backward-compatible: an old config with no `default_provider` still loads and falls back to
inference. Docs: config-spec.md (both blocks + the write-driver note) and an append-only
ADR-0063 note. Tests: buildDefaultChatAgent uses a persisted provider for an unprefixed id;
the picker + wizard persist the provider; config round-trips + resolves default_provider.
Toolchain green (shared 459, cli 2196).

Refs: ADR-0059, ADR-0063
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g-3 review fold)

Folds the adversarial review of the Bug-3 provenance fix (6 confirmed findings) + two
test nitpicks.

Correctness (the one real defect the review caught)
- config/resolve: `default_model` + `default_provider` were resolved with INDEPENDENT
  per-layer `??` fallbacks. A project pinning only `[chat] default_model` (a claude id —
  the common case, since the field is brand new and claude ids were always inferable)
  paired with a global `[preferences] default_provider = "openai"` (written by the wizard)
  would build an OpenAI agent over a Claude id → egress 404, breaking a previously-working
  config. Resolve the pair from the SAME layer that supplies the model (absent ⇒ inference),
  the exact coupling `allowed_commands`/`allowed_command_globs` already use. Regression test
  pins the no-leak.

Test coverage (the fix's decisive hops were only pinned at the innermost fn)
- agent-source: resolveChatAgent passes a persisted provider verbatim (unplaceable id
  builds; absent provider still throws).
- session-host: buildChatSession carries chat.defaultProvider into the default agent.

Docs / comments
- config-spec: the `[chat]` resolution paragraph now documents default_provider as a
  second coupled group. write.ts: "two-key" → three-key setter; the defaultProvider
  no-op comment corrected (a provider-only write is a real partial write). default-agent
  test: soften the catalog-first comment (all shipped ids also match a prefix, so it
  cannot prove first-ness).

Two test nitpicks (first-fold files)
- effort-notice: the BEL-removal assertion was vacuous (no BEL in the fixture) — add one,
  and assert the legible content ('evil'/'second-line') survives sanitization.
- model-catalog-view: `catalogRate` coerced an unknown rate to 0 (conflating unknown with
  free) — return `number | undefined` and assert real rates at the call site.

Toolchain: format:check + lint + typecheck + test green (shared 459, cli 2199).

Refs: ADR-0059, ADR-0063
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug 2 of the live model-picker reports. Two Haiku rows showed — "Claude Haiku 4.5"
(selectable) and "Claude Haiku 4.5 (latest)" (dimmed "unavailable on your key") —
despite an Anthropic key. Root cause: availability is decided by EXACT model-id
membership in the provider's live list, and Anthropic's `models.list()` returns only
the dated pin (`claude-haiku-4-5-20251001`), not the rolling alias (`claude-haiku-4-5`),
while the catalog ships BOTH as priced rows — so the alias dimmed as `not-on-key` even
though the SAME key calls it (both resolve server-side to the same model).

Fix (pure merge, benefits CLI + desktop + VS Code alike): before dimming a live-omitted
model, `resolveAvailability` now consults `hasLiveSibling` — a live-omitted model is
available when its alias↔dated-pin sibling (`base` ↔ `base-YYYYMMDD`) IS in the live list
AND is itself a shipped catalog row of the same provider. The catalog-row gate stops this
fabricating availability for an arbitrary unpriced id; a model whose provider genuinely
does not serve it still dims. Scoped to Anthropic this round (the maintainer's call — the
OpenAI gpt-4o dated family is out of scope). Corrected the anthropic conformance fixture
to return the dated pin (real behavior; it had the reverse), so the suite exercises the
rescued-alias case. Refines ADR-0064 §6 (append-only note); does not reverse it.

Tests: the alias goes available when its dated pin is live and vice versa; a non-catalog
sibling id cannot fabricate availability. Toolchain green (llm 644, cli 2199).

Refs: ADR-0064, ADR-0071
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@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: 2

🧹 Nitpick comments (1)
apps/cli/src/chat/default-agent.ts (1)

50-52: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Minor: catalog lookup is case-sensitive, prefix fallback is not.

catalogModel(model) does a direct object-key lookup while the fallback lower-cases the id first (Line 53 m = model.toLowerCase()). A catalog-known id passed in non-canonical casing silently skips the "authoritative" catalog branch and falls to the heuristic — usually harmless since both agree for shipped ids, but not guaranteed if they ever diverge.

♻️ Optional normalization
-  const cataloged = catalogModel(model)?.provider;
+  const cataloged = catalogModel(model.toLowerCase())?.provider ?? catalogModel(model)?.provider;
🤖 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 `@apps/cli/src/chat/default-agent.ts` around lines 50 - 52, Update
inferProviderFromModel to normalize the model identifier before the catalogModel
lookup, matching the lowercase normalization already used by the prefix
fallback. Preserve the catalog result as authoritative and only apply the
fallback when the normalized lookup returns no provider.
🤖 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 `@apps/cli/src/chat/session-host.ts`:
- Around line 615-618: Update the resolveEndpoint callback in the session host
configuration to use the provider bound to the selected model rather than
catalogModel(model)?.provider with an OpenAI fallback. Thread that bound
provider through the surrounding model/governor setup so Anthropic and custom
providers resolve to the correct endpoint kind and retain proper clamping.

In `@apps/cli/src/config/resolve.ts`:
- Around line 169-190: Update the global fallback in the
defaultModel/defaultProvider resolution block so
global.preferences.default_provider is only returned when
global.preferences.default_model is defined. Preserve the existing project and
workspace coupling behavior, and otherwise leave defaultProvider undefined when
no global model is configured.

---

Nitpick comments:
In `@apps/cli/src/chat/default-agent.ts`:
- Around line 50-52: Update inferProviderFromModel to normalize the model
identifier before the catalogModel lookup, matching the lowercase normalization
already used by the prefix fallback. Preserve the catalog result as
authoritative and only apply the fallback when the normalized lookup returns no
provider.
🪄 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: 66ec1ec1-2d0a-4dc8-9a6f-df7fdfc83aed

📥 Commits

Reviewing files that changed from the base of the PR and between 5702ee0 and ff71911.

📒 Files selected for processing (28)
  • apps/cli/src/chat/agent-source.test.ts
  • apps/cli/src/chat/agent-source.ts
  • apps/cli/src/chat/default-agent.test.ts
  • apps/cli/src/chat/default-agent.ts
  • apps/cli/src/chat/effort-notice.test.ts
  • apps/cli/src/chat/persister.test.ts
  • apps/cli/src/chat/session-host.test.ts
  • apps/cli/src/chat/session-host.ts
  • apps/cli/src/commands/chat.test.ts
  • apps/cli/src/config/resolve.test.ts
  • apps/cli/src/config/resolve.ts
  • apps/cli/src/config/write.test.ts
  • apps/cli/src/config/write.ts
  • apps/cli/src/engine/media-wiring.test.ts
  • apps/cli/src/engine/model-catalog-view.test.ts
  • apps/cli/src/harness/session-chain.e2e.test.ts
  • apps/cli/src/home/drive-home.tsx
  • apps/cli/src/onboarding/wizard.test.ts
  • apps/cli/src/onboarding/wizard.ts
  • apps/cli/src/render/tui/home-controller.test.ts
  • apps/cli/src/render/tui/home-controller.ts
  • docs/decisions/0063-cli-config-write-contract.md
  • docs/decisions/0064-live-model-catalog.md
  • docs/reference/contracts/config-spec.md
  • packages/llm/src/conformance/fixtures/anthropic.ts
  • packages/llm/src/model-catalog.test.ts
  • packages/llm/src/model-catalog.ts
  • packages/shared/src/config.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • apps/cli/src/engine/media-wiring.test.ts
  • apps/cli/src/commands/chat.test.ts
  • apps/cli/src/chat/persister.test.ts
  • docs/decisions/0064-live-model-catalog.md
  • apps/cli/src/chat/effort-notice.test.ts

Comment thread apps/cli/src/chat/session-host.ts Outdated
Comment thread apps/cli/src/config/resolve.ts
cemililik and others added 4 commits July 14, 2026 00:35
…with negative tests

Folds the Bug-2 adversarial review (2 confirmed test-coverage findings; both no-op
guards today, but a deliberate decision with no regression net).

- The `provider !== 'anthropic'` scoping gate had no negative test: install a synthetic
  OpenAI alias+dated-pin pair (via installCatalogRefresh) and assert the alias is NOT
  rescued, so a future broadening — or a new non-Anthropic dated family — cannot pass
  silently.
- The catalog-row anti-fabrication gate was negatively tested in only the alias branch
  (the `99999999` case). Add the symmetric dated-pin case: a live base that is NOT a
  catalog row must not rescue its dated pin, exercising the line-146 conjunct in isolation.

llm 26 model-catalog tests green.

Refs: ADR-0064
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug 1 of the live model-picker reports, generalized (not haiku- or deepseek-specific).
The picker offered five effort rows to models that expose fewer real choices: several
normalized tiers can collapse onto one provider wire value (DeepSeek's low/medium/high
all send `high`; Gemini's `max` coarsens onto `high`), and a continuous token BUDGET has
no discrete rungs at all (claude-haiku-4-5 — the report: a 5-tier ladder where the model
is really on/off, à la Claude Code).

The seam (`acceptedTiers`, the wire truth the gate / failover / adapters read) is
UNCHANGED. A new presentation helper `reasoningControlShape` → graded | budget | none and
`CANONICAL_ON_TIER` (= medium) drive a CLI projection (`effortTiersFor`, `effortRowLabel`):
  - GRADED → deduped by distinct wire value (representative reads the name matching the
    wire): deepseek-v4-pro → off/high/max; gemini-3.5-flash → low/medium/high; all-distinct
    ladders (opus, gpt-5.4-pro) unchanged.
  - BUDGET → a two-row off/on ("on" = medium, a real accepted tier so the accept sends a
    value the gate accepts); a model that can't be turned off (gemini-2.5-pro) has nothing
    to toggle → no overlay.
  - NONE → nothing.
Every emitted row is still a valid accepted tier. `effortRowLabel` makes a budget model's
`medium` read "on" everywhere — the picker, the footer, and every effort notice — so the
surfaces cannot disagree.

Tests: reasoningControlShape (seam) + the full projection matrix (budget on/off, dedup,
gemini-2.5-pro empty, unchanged ladders). ADR-0066 §6 append-only presentation note.
Toolchain green (llm 649, cli 2203).

Refs: ADR-0066, ADR-0071
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…der-inference lookup

Folds a review pass on the Bug-3 provenance work (2 fixed, 1 skipped).

- config/resolve: the coupled (default_model, default_provider) resolution took the
  provider from the GLOBAL layer even when global set no MODEL, so a stray
  `[preferences].default_provider` (a hand-edited config — the picker/wizard always write
  the pair) would pair with the built-in default model → the wrong-adapter/404 the Bug-3
  fold closed, reintroduced at the global-model-absent boundary. Now global's provider is
  taken only when global also sets the model; else both stay undefined (inference).
  Regression test added.
- default-agent: `inferProviderFromModel` now lowercases ONCE before the catalog lookup
  (catalog ids are lowercase, and the prefix fallback already lowercased), so a mixed-case
  id resolves via the authoritative catalog, not only the prefix heuristic.

Skipped (functionally inert, not a bug): the session-host / build-engine `resolveEndpoint`
`?? 'openai'` fallback. It fires ONLY for an uncatalogued model, and `cappedMaxTokens`
returns the request UNCLAMPED for any model with no catalog ceiling regardless of endpoint
kind — so the endpoint never changes the estimate; a catalogued model already resolves its
correct provider from the catalog.

Refs: ADR-0059
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ls onto surviving rows

The general effort-presentation rule (d509f6b) collapses graded/budget models to
one row per distinct outcome, so `medium` is no longer guaranteed to be a picker
row. Five sites still assumed the fixed five tiers:

- HIGH — the opening highlight. `initialEffortPickerState` and the `/models`
  effort sub-step used `tiers.indexOf('medium')`, which returns -1 for a collapsed
  graded model (deepseek `[off,high,max]`) and clamps to 0 = `off`. A fresh picker
  opened on reasoning-DISABLED and an immediate Enter silently WROTE `off` on a
  model the user opened to turn reasoning up. New `projectEffortToRow(model, rows,
  tier)` folds a tier onto its surviving representative (graded → the row with the
  same wire value; budget → the canonical-on row), and every highlight/✓ now
  routes through it.
- the ✓ marker (effort-tier-list), the standalone-picker accept note (chat-ink),
  and the typed `/effort` bare-list + set confirmation (chat.ts) all printed the
  raw tier; they now read through `effortRowLabel`, so a budget model's `medium`
  reads "on".

Finding 5 (effortRejectedNote's raw tier) skipped: only OpenAI all-distinct-effort
models reject a tier, and their rows are unrelabelled, so the note is already
accurate.

Regression tests: `projectEffortToRow` matrix; the picker opens on a reasoning-ON
row (never `off`) for both a collapsed graded model and a budget model; and a
bare-Home immediate Enter on deepseek-v4-pro writes `high`, never `off`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cemililik

Copy link
Copy Markdown
Contributor Author

@coderabbitai review all

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

@cemililik Sure, I'll do a full re-review of all changes in this PR.

✅ Action performed

Full review finished.

@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: 7

🧹 Nitpick comments (2)
apps/cli/src/engine/catalog-refresh.ts (1)

165-167: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

as ReadableStream<Uint8Array> asserts a concrete element type the DOM lib does not guarantee. The rationale (DOM types body as ReadableStream<any>) is fair, but the coding guidelines discourage as. Consider narrowing the reader's value at the point of use (it is what actually needs the type) so the byte arithmetic stays type-checked without asserting the whole stream's element type:

♻️ Alternative without a concrete stream-generic assertion
-  const reader = (body as ReadableStream<Uint8Array>).getReader();
+  const reader = body.getReader();
   for (;;) {
     const { done, value } = await reader.read();
     if (done || value === undefined) break;
-    bytes += value.byteLength;
+    const chunk = value as Uint8Array; // narrowed only where read; still a cast, but scoped
+    bytes += chunk.byteLength;
     if (bytes > MAX_BYTES) {
       controller.abort();
       return undefined;
     }
-    out += decoder.decode(value, { stream: true });
+    out += decoder.decode(chunk, { stream: true });
   }

Non-blocking — if a scoped cast is no cleaner than the current one, leaving it is reasonable.

🤖 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 `@apps/cli/src/engine/catalog-refresh.ts` around lines 165 - 167, In the
catalog refresh reader flow, avoid asserting the entire body as
ReadableStream<Uint8Array>. Keep getReader() on the original body and narrow or
validate the returned value at the byte-count arithmetic use site, preserving
type-safe byte counting without a concrete stream-generic cast.

Source: Coding guidelines

packages/llm/src/reasoning-wire.ts (1)

71-72: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer a lookup map over the as assertion.

The upper-casing narrows via an unsafe assertion. A keyed Record yields the same result with no assertion and keeps the mapping exhaustive at the type level.

As per coding guidelines: "do not use any or unsafe type assertions such as as, and prefer type guards."

♻️ Assertion-free mapping
-export const toGeminiThinkingLevel = (wire: 'low' | 'medium' | 'high'): 'LOW' | 'MEDIUM' | 'HIGH' =>
-  wire.toUpperCase() as 'LOW' | 'MEDIUM' | 'HIGH';
+const GEMINI_THINKING_LEVEL: Record<'low' | 'medium' | 'high', 'LOW' | 'MEDIUM' | 'HIGH'> = {
+  low: 'LOW',
+  medium: 'MEDIUM',
+  high: 'HIGH',
+};
+export const toGeminiThinkingLevel = (wire: 'low' | 'medium' | 'high'): 'LOW' | 'MEDIUM' | 'HIGH' =>
+  GEMINI_THINKING_LEVEL[wire];
🤖 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 `@packages/llm/src/reasoning-wire.ts` around lines 71 - 72, Replace the unsafe
uppercase assertion in toGeminiThinkingLevel with an exhaustive typed lookup
Record mapping 'low' | 'medium' | 'high' to 'LOW' | 'MEDIUM' | 'HIGH', and
return the mapped value directly while preserving the function’s existing input
and output types.

Source: Coding guidelines

🤖 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 @.github/workflows/models-catalog.yml:
- Around line 40-46: Update the pinned actions in the workflow’s checkout, pnpm
setup, and Node setup steps—including the corresponding steps around the later
referenced block—from their v4 releases to the current Node 24-compatible major
releases, and replace each SHA with the matching verified commit for that major
while preserving the existing pinning and version comments.

In `@apps/cli/src/commands/models-pricing.ts`:
- Around line 229-244: Update the JSON branch of runClearPricing to determine
the source using the catalog’s actual pricing for args.model, matching
clearedPricingMessage: report source as "catalog" only when
catalogPricing(args.model) is defined, otherwise report null. Preserve the
existing cleared behavior and JSON response shape for models priced by the
catalog.

In `@apps/cli/src/home/drive-home.tsx`:
- Around line 556-567: Update noteToStore and the onceEffortNotice flow to
buffer notices emitted before storeRef.current is assigned instead of marking
them as permanently delivered through stderr. After assigning storeRef.current,
flush the queued notices through ChatStoreController.notice, preserving
deduplication while ensuring withheld effort notices reach the transcript.
- Line 507: Update the provider resolution around readEffectiveProvider so it
returns an explicit success/failure state instead of using undefined for both
cases. Preserve an intentional missing provider to allow model-ID inference, and
use config.chat.defaultProvider only when the fresh provider read fails; remove
the nullish-coalescing fallback that can restore stale configuration.

In `@docs/decisions/0066-normalized-reasoning-effort-control.md`:
- Line 40: Update the ADR blockquote in
0066-normalized-reasoning-effort-control.md so the blank line at the indicated
location remains part of the continuous blockquote by adding the blockquote
marker, or remove the blank line.

In `@docs/reference/cli/commands.md`:
- Around line 229-236: Update the canonical --json field reference for relavium
models refresh to document both record shapes: the existing per-provider fields
and the catalog record with source, status, models, and added. Keep the
reference consistent with the two-axis behavior described above.

In `@docs/reference/contracts/config-spec.md`:
- Line 154: Update the inline comment for [chat].default_provider in the
configuration example to state that it is resolved from the same layer as the
effective [chat].default_model, with provider inference from the model ID when
no provider is present at any layer. Remove the conflicting
fallback-to-global-[preferences].default_provider wording.

---

Nitpick comments:
In `@apps/cli/src/engine/catalog-refresh.ts`:
- Around line 165-167: In the catalog refresh reader flow, avoid asserting the
entire body as ReadableStream<Uint8Array>. Keep getReader() on the original body
and narrow or validate the returned value at the byte-count arithmetic use site,
preserving type-safe byte counting without a concrete stream-generic cast.

In `@packages/llm/src/reasoning-wire.ts`:
- Around line 71-72: Replace the unsafe uppercase assertion in
toGeminiThinkingLevel with an exhaustive typed lookup Record mapping 'low' |
'medium' | 'high' to 'LOW' | 'MEDIUM' | 'HIGH', and return the mapped value
directly while preserving the function’s existing input and output types.
🪄 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: f7716c69-155f-4890-b48e-6786c6c30f79

📥 Commits

Reviewing files that changed from the base of the PR and between 839f2bd and 78c5115.

📒 Files selected for processing (133)
  • .claude/skills/add-llm-adapter/SKILL.md
  • .github/workflows/models-catalog.yml
  • CLAUDE.md
  • apps/cli/src/chat/agent-source.test.ts
  • apps/cli/src/chat/agent-source.ts
  • apps/cli/src/chat/default-agent.test.ts
  • apps/cli/src/chat/default-agent.ts
  • apps/cli/src/chat/effort-notice.test.ts
  • apps/cli/src/chat/effort-notice.ts
  • apps/cli/src/chat/persister.test.ts
  • apps/cli/src/chat/session-host.test.ts
  • apps/cli/src/chat/session-host.ts
  • apps/cli/src/commands/agent-run.ts
  • apps/cli/src/commands/chat.test.ts
  • apps/cli/src/commands/chat.ts
  • apps/cli/src/commands/dispatch.ts
  • apps/cli/src/commands/gate.ts
  • apps/cli/src/commands/manifest.ts
  • apps/cli/src/commands/models-dispatch.test.ts
  • apps/cli/src/commands/models-pricing.test.ts
  • apps/cli/src/commands/models-pricing.ts
  • apps/cli/src/commands/models.test.ts
  • apps/cli/src/commands/models.ts
  • apps/cli/src/commands/run.ts
  • apps/cli/src/commands/specs-forwarding.test.ts
  • apps/cli/src/commands/specs.ts
  • apps/cli/src/config/load.ts
  • apps/cli/src/config/resolve.test.ts
  • apps/cli/src/config/resolve.ts
  • apps/cli/src/config/write.test.ts
  • apps/cli/src/config/write.ts
  • apps/cli/src/engine/build-engine.ts
  • apps/cli/src/engine/catalog-refresh.test.ts
  • apps/cli/src/engine/catalog-refresh.ts
  • apps/cli/src/engine/media-wiring.test.ts
  • apps/cli/src/engine/model-catalog-view.test.ts
  • apps/cli/src/engine/model-catalog-view.ts
  • apps/cli/src/engine/pricing-overlay.ts
  • apps/cli/src/engine/providers.ts
  • apps/cli/src/harness/regression.e2e.test.ts
  • apps/cli/src/harness/session-chain.e2e.test.ts
  • apps/cli/src/home/drive-home.test.ts
  • apps/cli/src/home/drive-home.tsx
  • apps/cli/src/onboarding/wizard.test.ts
  • apps/cli/src/onboarding/wizard.ts
  • apps/cli/src/render/sanitize.ts
  • apps/cli/src/render/tui/chat-ink.tsx
  • apps/cli/src/render/tui/chat-projection.test.ts
  • apps/cli/src/render/tui/chat-projection.ts
  • apps/cli/src/render/tui/effort-picker.test.ts
  • apps/cli/src/render/tui/effort-picker.ts
  • apps/cli/src/render/tui/effort-tier-list.tsx
  • apps/cli/src/render/tui/home-app.test.tsx
  • apps/cli/src/render/tui/home-app.tsx
  • apps/cli/src/render/tui/home-controller.test.ts
  • apps/cli/src/render/tui/home-controller.ts
  • apps/cli/src/render/tui/model-picker-view.tsx
  • apps/cli/src/render/tui/model-picker.test.ts
  • apps/cli/src/render/tui/model-picker.ts
  • apps/cli/src/run.ts
  • docs/decisions/0063-cli-config-write-contract.md
  • docs/decisions/0064-live-model-catalog.md
  • docs/decisions/0065-provider-economics-and-extensibility.md
  • docs/decisions/0066-normalized-reasoning-effort-control.md
  • docs/decisions/0071-models-dev-as-the-model-metadata-source.md
  • docs/decisions/README.md
  • docs/reference/cli/commands.md
  • docs/reference/contracts/config-spec.md
  • docs/reference/contracts/workflow-yaml-spec.md
  • docs/reference/desktop/database-schema.md
  • docs/reference/shared-core/llm-provider-seam.md
  • docs/roadmap/current.md
  • docs/roadmap/deferred-tasks.md
  • docs/roadmap/phases/phase-2.6-conversational-authoring.md
  • docs/runbooks/add-a-provider.md
  • docs/standards/security-review.md
  • eslint.config.mjs
  • package.json
  • packages/core/src/engine/agent-runner.e2e.test.ts
  • packages/core/src/engine/agent-runner.test.ts
  • packages/core/src/engine/agent-runner.ts
  • packages/core/src/engine/agent-session.test.ts
  • packages/core/src/engine/agent-session.ts
  • packages/core/src/engine/budget-governor.test.ts
  • packages/core/src/engine/budget-governor.ts
  • packages/core/src/engine/engine.ts
  • packages/core/src/engine/reasoning-effort.test.ts
  • packages/core/src/engine/reasoning-effort.ts
  • packages/core/src/index.ts
  • packages/db/drizzle/0011_odd_thunderbolt.sql
  • packages/db/drizzle/meta/0011_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/model-catalog-store.ts
  • packages/db/src/schema.ts
  • packages/llm/src/adapters/anthropic.test.ts
  • packages/llm/src/adapters/anthropic.ts
  • packages/llm/src/adapters/gemini.test.ts
  • packages/llm/src/adapters/gemini.ts
  • packages/llm/src/adapters/openai.test.ts
  • packages/llm/src/adapters/openai.ts
  • packages/llm/src/adapters/shared.ts
  • packages/llm/src/budget-estimator.test.ts
  • packages/llm/src/budget-estimator.ts
  • packages/llm/src/catalog/catalog-model.ts
  • packages/llm/src/catalog/catalog-providers.ts
  • packages/llm/src/catalog/deprecations.ts
  • packages/llm/src/catalog/lookup.test.ts
  • packages/llm/src/catalog/lookup.ts
  • packages/llm/src/catalog/models-dev-schema.test.ts
  • packages/llm/src/catalog/models-dev-schema.ts
  • packages/llm/src/catalog/pricing.ts
  • packages/llm/src/catalog/snapshot-guard.test.ts
  • packages/llm/src/catalog/snapshot-guard.ts
  • packages/llm/src/catalog/snapshot.ts
  • packages/llm/src/conformance/effort.conformance.test.ts
  • packages/llm/src/conformance/fixtures/anthropic.ts
  • packages/llm/src/cost-tracker.test.ts
  • packages/llm/src/cost-tracker.ts
  • packages/llm/src/fallback-chain.test.ts
  • packages/llm/src/fallback-chain.ts
  • packages/llm/src/index.ts
  • packages/llm/src/model-catalog.test.ts
  • packages/llm/src/model-catalog.ts
  • packages/llm/src/model-kind.ts
  • packages/llm/src/output-cap.test.ts
  • packages/llm/src/output-cap.ts
  • packages/llm/src/pricing.test.ts
  • packages/llm/src/pricing.ts
  • packages/llm/src/reasoning-wire.test.ts
  • packages/llm/src/reasoning-wire.ts
  • packages/shared/src/config.ts
  • packages/shared/src/workflow.ts
  • tools/sync-models-dev/sync.mjs

Comment on lines +40 to +46
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Set up pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Resolve current major-version tags to their commit SHAs and compare against the pinned ones.
gh api repos/actions/checkout/commits/v4 --jq '.sha'
gh api repos/actions/setup-node/commits/v4 --jq '.sha'
gh api repos/pnpm/action-setup/commits/v4 --jq '.sha'
gh api repos/actions/checkout/tags --paginate --jq '.[] | select(.name | startswith("v5") or startswith("v6")) | {name, sha: .commit.sha}'

Repository: HodeTech/Relavium

Length of output: 874


🌐 Web query:

GitHub Actions Node 20 deprecation actions/checkout v4 setup-node v4 pnpm action-setup v4 Node 24 runners June 2 2026 September 16 2026

💡 Result:

GitHub Actions has deprecated Node.js 20 in favor of Node.js 24 as part of the runner runtime migration [1][2]. The transition involves two key dates in 2026: 1. June 16, 2026: Node.js 24 became the default runtime for GitHub Actions [3][4]. Actions that rely on Node.js 20 will trigger deprecation warnings if they have not been updated to support Node.js 24 [5]. 2. September 16, 2026: Node.js 20 is scheduled to be removed from GitHub Actions runners [5]. To address deprecation warnings and ensure compatibility, maintainers must update their workflows to use versions of actions that support Node.js 24 (often v5 or higher for core actions like actions/checkout, actions/setup-node, and pnpm/action-setup) [6]. Migration details: - Environment Variables: During the transition, users could use FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true to force Node.js 24 usage or ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true to temporarily continue using Node.js 20 after it was deprecated [2][5]. - Action Compatibility: Standard actions such as actions/checkout@v4 and pnpm/action-setup@v4 may trigger warnings if they are not updated to a version explicitly configured for the Node.js 24 runtime environment [7][6][5]. Users are encouraged to bump these to the latest available major versions [6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the workflow locations mentioned in the comment.
sed -n '1,120p' .github/workflows/models-catalog.yml

# Check whether the pinned SHAs exist and which major tags currently point to them.
gh api repos/actions/checkout/commits/34e114876b0b11c390a56381ad16ebd13914f8d5 --jq '.sha'
gh api repos/actions/setup-node/commits/49933ea5288caeca8642d1e84afbd3f7d6820020 --jq '.sha'
gh api repos/pnpm/action-setup/commits/b906affcce14559ad1aafd4ab0e942779e9f58b1 --jq '.sha'

# Pull the action metadata from tags where available.
for repo in actions/checkout actions/setup-node pnpm/action-setup; do
  echo "== $repo =="
  gh api "repos/$repo/tags" --paginate --jq '.[] | select(.name=="v4" or .name=="v5" or .name=="v6") | {name, sha: .commit.sha}'
done

Repository: HodeTech/Relavium

Length of output: 5753


🌐 Web query:

actions/checkout v5 node24 runtime release notes actions/setup-node v5 node24 runtime release notes pnpm/action-setup v5 node24 runtime release notes GitHub Actions Node 20 deprecation warning current runners 2026

💡 Result:

As of July 13, 2026, the migration from Node.js 20 to Node.js 24 for GitHub Actions is in effect. Node.js 20 reached end-of-life in April 2026 [1][2][3], and GitHub has deprecated Node.js 20 support on GitHub Actions runners, with an enforcement date of June 2, 2026 [4][5]. Key Details: - Official Actions Support: The major versions of standard actions, including actions/checkout (v5.0.0+) [6][7] and actions/setup-node (v5.0.0+) [8][9], have been updated to use the Node.js 24 runtime. - Runner Requirement: To use these v5 versions, your GitHub Actions runner must be updated to at least version v2.327.1 [10][8][11]. - Deprecation Status: Node.js 20-based actions will trigger a deprecation warning annotation at the end of job runs [5]. Users are expected to migrate their workflows to use actions that support the Node.js 24 runtime [1][5]. - pnpm/action-setup: While specific v5 release notes for pnpm/action-setup were not explicitly surfaced in the same format as official actions/ repositories, the broader migration requires that all actions running on GitHub-hosted runners be compatible with the Node.js 24 environment [5]. Users should check the repository's latest release for v5+ updates to ensure Node.js 24 compatibility.

Citations:


Upgrade these pinned GitHub Actions off the Node 20 line .github/workflows/models-catalog.yml:40-46,70-76actions/checkout, actions/setup-node, and pnpm/action-setup are still on v4 releases; move them to the current Node 24-compatible majors to avoid deprecation warnings and future runner failures.

🤖 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 @.github/workflows/models-catalog.yml around lines 40 - 46, Update the pinned
actions in the workflow’s checkout, pnpm setup, and Node setup steps—including
the corresponding steps around the later referenced block—from their v4 releases
to the current Node 24-compatible major releases, and replace each SHA with the
matching verified commit for that major while preserving the existing pinning
and version comments.

Comment thread apps/cli/src/commands/models-pricing.ts
Comment thread apps/cli/src/home/drive-home.tsx Outdated
Comment thread apps/cli/src/home/drive-home.tsx
> canonical-wins-over-`providerOptions` precedence, the `/effort` overlay, and the host-gated design. What
> changes is only that a model now *declares* which tiers it accepts and which native shape carries them,
> instead of the adapter assuming both.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep the ADR blockquote continuous.

The blank line at Line 40 is inside the > block and triggers MD028. Add > to that blank line or remove it.

🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 40-40: Blank line inside blockquote

(MD028, no-blanks-blockquote)

🤖 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 `@docs/decisions/0066-normalized-reasoning-effort-control.md` at line 40,
Update the ADR blockquote in 0066-normalized-reasoning-effort-control.md so the
blank line at the indicated location remains part of the continuous blockquote
by adding the blockquote marker, or remove the blank line.

Source: Linters/SAST tools

Comment thread docs/reference/cli/commands.md Outdated
Comment thread docs/reference/contracts/config-spec.md Outdated
cemililik and others added 13 commits July 14, 2026 02:19
…so they actually run (review H1)

Both ADR-0071 §9 scheduled guards were red on every run for an infrastructure
reason, not a real signal:

- The weekly shipped-price-change guard runs `pnpm sync:models`, whose build step
  was `pnpm --filter @relavium/llm build` — a bare per-package `tsc` that does NOT
  build @relavium/llm's `@relavium/shared` dependency. `@relavium/*` exports resolve
  only to a gitignored `dist/`, so a fresh runner dies on TS2307 before the money
  guard is ever evaluated. sync.mjs then imports `packages/llm/dist/**`, which also
  needs the build. Route the script's build through turbo so `^build` pulls in
  @relavium/shared first (also fixes a fresh local `pnpm sync:models`).
- The nightly live effort-conformance job ran vitest with no build at all, so it
  could not resolve @relavium/shared and every conformance file errored out before
  a single live probe. Add a `pnpm turbo run build --filter=@relavium/llm` step.

A guard that is red no matter what trains the maintainer to ignore it — exactly the
erosion the workflow's own header warns against. Now both guards reach the thing
they protect: the shipped-price money guard and the live tier-acceptance probe.

Refs: ADR-0071 §9

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…r, not the model catalog (review M2)

The pre-egress cost estimate clamps an authored `max_tokens` to the model's
catalog ceiling on an OFFICIAL endpoint and — correctly — not on a CUSTOM one.
But it decided official-vs-custom from `catalogModel(model).provider`, the model's
fixed catalog provider, rather than the provider actually routing the call. A
custom `base_url` gateway (OpenRouter/LiteLLM) registered as `openai` and serving
a `deepseek-*`/`anthropic-*` model id is `custom` at the wire yet `official` by
catalog: the estimate clamped to the catalog ceiling while the adapter sent the
full request, so the governor under-authorized and waved through spend it exists
to stop — the exact under-authorization `resolveEndpoint` (ce6cfbf) was added to
prevent.

Thread the routing provider through the pre-egress seam so the estimate keys on
the same provider the adapter dials, per attempt:

- `FallbackChain.PreAttemptHook` info gains `provider` (the entry's provider.id);
  a failover moves it, so each attempt reports its own routing provider.
- `PreEgressHook` info gains an optional `provider`; agent-turn threads the
  active provider (updated with `activeModel` in `onAttempt`) on both the
  primary-model pre-check and the per-attempt chain hook. A media-only gate
  (`maxTokens: 0`) omits it harmlessly — the token estimate is 0 regardless.
- `BudgetGovernor.evaluatePreEgress`/`checkPreEgress` accept the provider and
  key the endpoint on it; `resolveEndpoint` is now `(provider) => EndpointKind`.
- The two CLI closures collapse to `resolveEndpoint: endpointKind` — the provider
  IS the key, so the catalog-provider derivation (and its `?? 'openai'` guess) is
  gone.

Regression tests: the governor routes the endpoint by the provider argument
(same model + maxTokens, opposite clamp, cap set between the two costs); a
media-only gate defaults to official harmlessly; and the chain's preAttempt
carries the primary then the failover provider across a failover.

Refs: ADR-0071 §7

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rice cannot resurrect them (review M3)

`clearUserPricing` soft-deactivates the `source='user'` row (never DELETEs — the
row id is an FK target from five history tables) but left the pricing columns
intact. `upsert` looks a row up by `(provider, model)` with `deletedAt IS NULL` —
it ignores `isActive` — so a later PARTIAL re-price (`models pricing --input X
--output Y`, no `--cached`) reuses that same FK-stable row and PRESERVES every
column it omits. The cleared `cachedInputCostPerMtokMicrocents` /
`cachedInputStated=true` therefore came back, silently billing cache-read tokens
at the rate the user had explicitly cleared, marked as if freshly user-stated.

Zero `inputCostPerMtokMicrocents`, `outputCostPerMtokMicrocents`,
`cachedInputCostPerMtokMicrocents` and set `cachedInputStated=false` alongside
`isActive=false`, so a reused row starts from a clean baseline: an omitted cache
rate on the re-price now derives from the catalog discount, not the stale value.

Regression test: price with a stated cache rate → clear → partial re-price
(input/output only) → the cache rate is 0 and no longer user-stated.

Refs: ADR-0065 §1, ADR-0071 §10

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ore reseat notices (review M5 + bot)

Two independent defects in drive-home's session builders:

(a) startChat read the fresh default_model and default_provider INDEPENDENTLY,
each `?? config.chat.*`. A user who hand-edits the global config mid-session to a
different-family model (e.g. claude-sonnet-4-6) without a provider line got the
fresh Claude model paired with the STALE startup `default_provider` (openai) —
dialing the OpenAI adapter with a Claude id → 404 on the first turn. Read the
effective [chat] ONCE and couple the pair: when the fresh read supplies a model,
take ITS provider (undefined ⇒ inferred from the model id, never back-filled from
startup); only with no fresh model fall back to the startup pair. Drops the stale
`?? config.chat.defaultProvider` fallback (and the now-unused readEffectiveProvider).

(b) reseatChat's noteToStore fell back to `writeErr` when a governor/effort
callback fired DURING the build (before the seeded store exists). On the
alt-screen renderer the next frame erases stderr, and onceEffortNotice had
already marked the note delivered — so a mid-build effort-withheld/unpriced notice
was silently lost. Buffer such notices and flush them through store.notice once
the store is wired.

Regression tests: a mid-session config rewrite to a provider-less Claude model
builds with defaultProvider undefined (not stale openai); and an onUnpriced note
fired during the reseat build reaches the transcript, not stderr.

Refs: ADR-0059, ADR-0071 §6

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… gpt-5.6's `max` (review M1)

`OPENAI_WIRE.max` was the fixed alias `'xhigh'`, and the adapter indexed it
directly. But the shipped catalog carries a distinct `'max'` ABOVE `'xhigh'` for
the gpt-5.6 family (`effortValues: [...,'xhigh','max']`), so the picker offered a
"max" row, the gate accepted it, and the adapter sent `reasoning_effort: 'xhigh'`
— one tier below the model's actual maximum, unreachable from any surface. gpt-5.4
-pro / gpt-5.5 (top `'xhigh'`) were correct; only a `'max'`-publishing model lost
its top.

New `openAiWireValue(tier, controls)` is the ONE home for OpenAI's per-model wire:
normalized `max` → the model's own `'max'` when published, else `'xhigh'`; every
other tier is the static map. `wireValueFor` (hence `acceptedTiers` /
`acceptedWireValue`) and the adapter all read it, so the accept decision and the
wire value can never disagree. The picker's dedup/projection path passes no
controls and keeps the default `'xhigh'` — which never collides with another tier,
so dedup is unchanged.

The adapter assignment carries one documented cast: `'max'` is a valid models.dev
wire value (the ADR-0071 source of truth) that the pinned OpenAI SDK's
`ReasoningEffort` union has not yet published (it tops at `'xhigh'`). Narrowed to
the field's own type; the value is always one `acceptedTiers` proved the model
takes.

Also folds a review-bot nitpick in the same file: `toGeminiThinkingLevel`'s
`.toUpperCase() as` becomes an exhaustive typed Record (no unsafe assertion).

Regression tests: openAiWireValue/wireValueFor/acceptedTiers/acceptedWireValue for
gpt-5.6 (max→max) vs gpt-5.5 (max→xhigh); the adapter sends `'max'` for gpt-5.6
and `'xhigh'` for gpt-5.5.

Refs: ADR-0066, ADR-0071 §6

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…apped` gate verdict (review M6)

A budget-shaped model (`claude-haiku-4-5`, `budgetTokens {min:1024}`, no ladder)
accepts every tier, so the effort gate said "send medium" — but the adapter then
computed `reasoningBudgetFor(medium, {min:1024}, thinkingCeiling(500)) = undefined`
and dropped `thinking` entirely. The turn ran at 500 output tokens with NO
reasoning despite the author selecting it, and nothing — transcript, stderr, run
event — said so. The gate's decision and the adapter's behavior disagreed in
silence.

Make the gate cap-aware so the two agree and the drop is voiced:

- `reasoningWithheldByCap(provider, controls, tier, maxTokens)` (llm) answers the
  same question the adapter acts on — true only on the budget path, when the
  model's floor cannot fit under `thinkingCeiling(maxTokens)`. It wraps the SAME
  `reasoningBudgetFor` the adapters call, so gate and wire cannot drift.
- `gateReasoningEffort` takes an optional `{ maxTokens, withheldByCap }` and, for
  an accepted non-`off` tier the cap withholds, returns a new `capped` verdict
  (carrying the offending `max_tokens`) instead of `send`. `effortToSend` omits
  the field, exactly as the adapter would.
- Both hosts inject the catalog-backed `reasoningWithheldByCapFor` and pass the
  turn/node `max_tokens`; `agent-session` and `agent-runner` fire `onEffortWithheld`
  for `capped` too. `effortCappedNote` names `max_tokens` as the fix ("reasoning on
  needs a larger max_tokens than 500 …"), routed through the one `effortWithheldNote`.

Regression tests: `reasoningWithheldByCap` (budget vs ladder vs effort-only
provider); the gate returns `capped`/`send` and never cap-checks `off` or a
rejected tier; `effortCappedNote` + `reasoningWithheldByCapFor`.

Refs: ADR-0066, ADR-0071 §6

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nge cannot evict a priced model (review M7)

`ModelSchema` validated `reasoning_options` as a whole-array `discriminatedUnion`
and `reasoning`/`limit` non-nullably — as strictly as the money fields. So an
upstream change to a field we only ENRICH with — a new `reasoning_options.type`,
a `reasoning: null` — failed `ModelSchema.safeParse` and DROPPED a fully-priced
model. A dropped already-shipped model reads to `diffCatalog` as a VANISHED price,
which throws the ADR-0071 §9 money guard red for an entirely non-money reason; the
tempting `--accept-removals` then deletes the model's price and silently disables
the cost cap for anyone on it.

Decouple enrichment from the money gate:
- `reasoning_options` is now `z.array(z.unknown())`; `toReasoningControls` validates
  each entry INDEPENDENTLY with `ReasoningOptionSchema.safeParse` and skips an
  unrecognized/malformed one — an unknown control shape thins the descriptor
  instead of evicting the model.
- `reasoning` tolerates `null` (→ treated as "no reasoning").
- `limit` tolerates `null` (→ treated as absent, a clean unpriceable-shape drop
  that is reported, not a fatal parse of the whole row).

`cost`/`limit` values stay authoritative — they ARE the money surface.

Regression tests: an unknown reasoning_options shape (and a malformed known one)
is skipped with the model kept + priced; `reasoning: null` admits an unreasoning
priced model; `limit: null` drops cleanly and is reported.

Refs: ADR-0071 §9, §11

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… never gets a param it rejects (capability gating)

The reported bug: `gpt-5.6-luna` has `temperature: false` on models.dev, and a
turn that sets any temperature 400s immediately. The pre-existing `CapabilityFlags`
is a per-PROVIDER struct — it cannot say that one model in a provider rejects a
parameter its siblings accept. This is the same "a table cannot say what is true"
class ADR-0071 already fixed for pricing and reasoning shape, one more axis:
temperature / tool_call / structured_output / attachment all vary per model.

Mechanism (ADR-0071 §12 amendment):
- `CatalogModel.requestCapabilities` carries the per-model booleans, parsed from
  models.dev and normalized like `reasoning` — NOT forced into the seam's
  `CapabilityFlags`. Enrichment, parsed leniently (M7 rule), stores ONLY a `false`;
  absent ⇒ accepted (the safe default — never withhold on missing metadata).
- `modelAccepts(model, param)` is the one predicate the adapters ask. All three
  adapters WITHHOLD `temperature` and `structured_output` when the model rejects
  them — a 400 becomes a served turn at the model's default, the same "withhold,
  never guess" posture §6 set for reasoning.
- Anthropic additionally withholds a caller `temperature` whenever it enabled
  extended thinking for the turn: `thinking:{enabled|adaptive}` pins temperature to
  1, so any other value 400s (review M4).

Scope, honest: `temperature`/`structured_output` are safe silent param-withholds;
`tool_call`/`attachment` change request MEANING (agent can't act / image ignored),
so their catalog data is carried but gating them is deferred to a louder signal
(ADR §12). A user-facing "your temperature was dropped" notice is a follow-up. The
snapshot is GENERATED (§3) and NOT hand-edited: these fields land on the next
`pnpm sync:models`, so the mechanism is inert-but-correct until then.

Tests (via the additive `installCatalogRefresh`, no snapshot edit): schema stores
only rejected params; `modelAccepts` defaults to accepted for absent/uncatalogued;
each adapter withholds temperature/structured_output when rejected and sends when
accepted; Anthropic withholds temperature under enabled thinking (M4).

Refs: ADR-0071 §6, §11, §12

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… the /models picker

Anthropic ships BOTH a rolling alias (`claude-opus-4-1`) and its dated pin
(`claude-opus-4-1-20250805`) as byte-identical priced catalog rows, so the picker
offered two selectable rows for one model. This is the DISPLAY half of the same
alias↔dated-pin relationship whose AVAILABILITY half ADR-0064's 2026-07-14
clarification already handled.

`collapseAliasDatedPinPairs` (@relavium/llm, reusing the existing `datedPinBase`)
drops the dated pin at the display boundary — in `buildMergedCatalog`'s host
projection, NOT in `mergeModelCatalog` (whose full-fidelity output other consumers
keep). It collapses ONLY when both members are present as catalog rows — a lone
dated pin with no alias sibling stays visible, or the model would vanish from the
picker with no substitute (the same "both present" gate `hasLiveSibling` uses).
Anthropic-only by construction: the `-YYYYMMDD` regex does not match OpenAI's
`gpt-4o-2024-05-13` (`YYYY-MM-DD`) shape.

This is IDENTITY dedup, not an availability change — deliberately distinct from the
picker's "never hide a dimmed/deprecated model" rule (documented at the function
and in the ADR). The ✓ marker normalizes a persisted DATED-PIN default onto the
surviving alias row rather than vanishing (the same model resolves either id
server-side, so the marker moves, the stored choice does not). No "(latest)" suffix
exists to drop — the surviving alias already reads as a plain name.

Tests: collapse drops the dated pin keyed on the alias; keeps a lone dated pin;
keeps a non-anthropic dated variant; does not cross providers on a shared base id;
buildMergedCatalog seeds the full registry minus the collapsed pairs (snapshot-
derived count, asserted non-zero).

Refs: ADR-0064 §6

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eading /effort affordance

Six small but real fixes surfaced by the 2.6.Q review:

- MONEY: the new-model refresh floor guarded only the OUTPUT price, so a model
  with `input: 0` was admitted and billed the input (and derived cached-input)
  side FREE — a silent undercharge in the exact mechanism the floor hardens. Guard
  both sides.
- MONEY: `models pricing --clear --json` reported `source: cleared ? 'catalog'`,
  so a cleared model the catalog does NOT price reported 'catalog' while the human
  line said the opposite. Decide `source` from `catalogPricing()`, matching the
  message.
- SECURITY: `unpricedModelNote` interpolated the provider-controlled model id into
  a terminal line UNSANITIZED (twice), while every sibling effort notice sanitizes
  it — a terminal-control-injection surface. Sanitize it.
- SECURITY: the build/CI `sync.mjs` read the upstream body UNBOUNDED, while its
  runtime sibling caps + aborts. Bound it (declared + actual length) so a
  misbehaving host cannot OOM the sync or write a nonsense catalog.
- ROBUSTNESS: `writeCache`'s temp-then-rename left an orphan pid-suffixed temp file
  on a write/rename fault. Best-effort unlink on failure (a hard kill can still
  orphan — noted).
- UX: the bare `/effort` list advertises an "on" row (budget models), but typed
  `/effort on` was rejected as an unknown tier. Parse the typed arg against the
  SAME labels the discovery list printed, so the affordance it shows actually works.

Also folds a review-bot nitpick: `readCatalogBody` narrows each stream chunk with
`instanceof Uint8Array` (annotating the read result `unknown`) instead of asserting
the whole body as `ReadableStream<Uint8Array>`.

Regression tests: input-0 floor rejection; `--clear --json` source (catalog vs
null); `unpricedModelNote` sanitization.

Refs: ADR-0071 §5, §9, §K7

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… §K7, and stale JSDoc/DDL

The review found the docs drifted from the shipped code, concentrated in the
money-precedence area (which rule 8 makes authoritative, so it is a defect class):

- H2 (commands.md): the `models pricing` reference still described the pre-ADR-0071
  precedence — a "canonical id is refused / the shipped price always wins / a user
  can never misprice a shipped model". All false since §5 (USER outranks catalog).
  Rewritten to the shipped rule, with `--clear` documented; the `--json refresh`
  schema now documents the catalog record it emits alongside the per-provider ones.
- The phantom `ADR-0071 §K7`: 45 citations (config spec, workflow spec, engine)
  pointed at a section that did not exist — `K7` was an implementation milestone
  label that leaked before `strict_cost_cap` was written up. Recorded now as
  ADR-0071 §K7 (append-only), so every citation resolves rather than renumbering 45.
- config-spec `[chat].default_provider`: rewritten to the COUPLED resolution (same
  layer as the effective default_model, never independently — the M5/Bug-3 rule),
  dropping the "falls back to global [preferences]" wording that implied the
  decoupling that 404s.
- Stale references: the `mergeModelCatalog` JSDoc said `price ← registry ?? user`
  (pre-§1 flip; now `user ?? catalog`); `installCatalogRefresh`'s doc claimed it
  "enriches thin shipped models" when it never touches a shipped row; the
  `model_catalog` DDL omitted the shipped `cached_input_stated` column (migration
  0011); the ADR-0059 link in ADR-0063 pointed at the wrong filename.

Deferred (tracked in deferred-tasks.md): the capability `tool_call`/`attachment`
gating + a withheld-parameter notice (ADR-0071 §12), and surfacing a user↔catalog
price divergence on the picker/`/cost`, not only at set-time (§5).

Refs: ADR-0071 §1, §5, §K7, §12

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sage, and a false-green nightly guard

Three consistency/observability nits from the review:

- Migration 0011 spelled the new boolean's default `DEFAULT false`, diverging from
  the `DEFAULT 0`/`1` integer idiom every other migration, the drizzle snapshot
  (which already records `"0"`), and the runtime use. Fixed to `DEFAULT 0` —
  identical to SQLite, consistent with the idiom, and db:generate stays clean
  (0011 is part of this unmerged PR, so the SQL is safe to correct).
- The catalog refresh reported a `redirect: 'manual'`-caught redirect (an OPAQUE
  response: `type: 'opaqueredirect'`, `status: 0`) via the generic status branch as
  "models.dev returned 0" — misleading, when the dedicated "redirected off-host"
  message exists for exactly this ADR-0071 §8 case. Detect the opaque redirect
  before the status check.
- The nightly effort-conformance job's live cases are `it.skipIf(<key> === '')`, so
  with NO provider secret every probe SKIPS and the suite passes — a green that
  proved nothing, indistinguishable from "all tiers verified". A guard step now
  fails loudly when no secret is configured, so a green nightly means probes ran.

Regression test: a manual-caught opaque redirect (status 0) is named "off-host".

Refs: ADR-0071 §8, §9, §10

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…versarial-review fold of M7)

The M7 leniency fix (8278057) made each `reasoning_options` ELEMENT tolerant
(safe-parsed one at a time) and named `reasoning: null` as a case it survives — but
left the CONTAINER strict: `z.array(z.unknown()).optional()` still FAILS on `null`,
`{}`, a string, or a number. So a `reasoning_options: null` from upstream (models.dev
already emits explicit `null` for reasoning/cost/limit), or an array→object change,
failed the whole `ModelSchema.safeParse` and DROPPED a fully-priced model — the exact
§9 vanished-price red CI M7 set out to prevent, one field over.

Give the container the same tolerance as its siblings: `.nullish().catch(() =>
undefined)` — `null` is symmetric with `reasoning`, and a wrong TYPE degrades to "no
controls" (`raw.reasoning_options ?? []` already handles the undefined) instead of
failing the row.

Regression test: `reasoning_options` = null / {} / string / number each keeps the
priced model with an empty reasoning descriptor, never dropped.

Refs: ADR-0071 §9, §11

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

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