refactor(runtime-host): make the Host the authority for the model catalog - #4411
Conversation
`makeEntry`, `makeMissingDefaultEntry`, and `makeMissingUserChoiceEntry` each carried their own copy of the same twelve-field projection from `ModelMetadata` onto `ModelCatalogEntry`. The two missing-entry builders were identical in 46 of their 48 lines, differing only in how `isDefault` is decided and whether provenance records a user choice. Adding a field to the entry meant editing three copies, and the copies had already begun to drift: only `makeEntry` learned to report `capabilitySource: 'user_override'` for a fact the user overrode.
A missing entry is `makeEntry` over a bare `{ id }` row — with no provider row to merge, every field resolves from the bundled metadata alone, which is exactly what those builders spelled out by hand. What such an entry cannot derive is supplied as explicit overrides: unavailability that belongs to the inventory rather than to the model, a default that is default by construction, and the user-choice provenance flag. The facts shared by every entry in one catalog move into an `EntryContext`, so the builders take what varies instead of repeating seven positional arguments at each of the three call sites.
One behavioral difference: a missing entry now carries `pricing` and `provenance.pricingModelKey` when the pricing table describes its id. The previous builders never consulted that table, so a model the user had selected showed no price while the same id priced normally elsewhere in the same catalog.
Generated-by: Claude Code (claude-opus-5)
…log entry Rendering one model took three resolutions of the same model's facts: `buildConnectionModelCatalogEntries` for the entry, `thinkingVariantsForConnection` for its reasoning levels, and `resolveModelVisionSupport` for whether it accepts images. Only the last two consulted `relayModelProfiles`, the user's per-model declaration, so on a relay connection the entry's `capabilities.vision` and the choice's `supportsVision` could disagree about one model — the entry said what the catalog knew, the choice said what the user declared. The declaration is authoritative over every catalog source, so both reads that honour it now resolve where the rest of the model's facts already resolve. `ModelCatalogEntry` gains `thinkingLevels`, its `capabilities.vision` runs through `resolveModelVisionSupport` with the declared value, and `BuildModelCatalogInput` accepts the profiles the connection layer already holds. `buildChatModelChoices` reads both off the entry instead of calling out twice more. `resolveModelVisionSupport` stays exported: the Host's execution-model composition resolves vision for a model it is about to run, with no catalog entry in hand. Sharing the function keeps one rule with one implementation. One behavioral difference: an entry's `capabilities.vision` now reports true for a model the provider-and-id heuristic recognizes even when neither the provider row nor the bundled metadata declares vision, matching what the chat model choice already reported for the same model. Generated-by: Claude Code
A model's facts — display name, context window, capabilities, reasoning levels, lifecycle — come from the models.dev snapshot each build compiles in. Every client merged that snapshot itself: the Host projected the stored connection rows, and Desktop, the TUI, and Chat each ran `buildConnectionModelCatalogEntries` over them against its own copy. Those copies are not the same copy. Desktop installs and updates independently of `npx maka-agent@latest`, and a remote Host is a third version again, so one user attached to one Host through two clients could see one model as offering thinking effort in one and not the other — the symptom reported for GLM-5.3 on a Z.AI plan. The catalog is now resolved once, where the metadata that resolves it lives. The connection catalog gains a `catalog_entry` item per model, counted by the connection header and paginated like the rows beside it, carrying the entry the Host built. Clients read `connection.catalogEntries`: the chat model menu, the TUI's model choices, the daily-review picker, and the subagent thinking picker no longer look anything up. `ProjectedLlmConnection` names what a client actually holds — the stored connection plus the Host's catalog — and is what crosses the Desktop bridge. `resolveConnectionModelCatalog` is the one entry point into that resolution, so the provider rules that shape a connection's real model list cannot be applied in one caller and forgotten in another. It absorbs `normalizeOpenAiCodexConnection`, which decides which models a ChatGPT subscription can serve; that function moves next to the catalog it shapes, and the two client-side re-filters of the same set are gone. `buildConnectionModelCatalogEntries` now recognizes a provider through `providerDefaultsOf` rather than indexing the registry, which is the documented single recognition site and the reason a prototype-polluted `providerType` no longer reaches it. Two client-side resolutions remain, both where the Host has no state to resolve against, and both are commented as such: the add-provider form recommends a default model for a provider that has no connection yet, and the connection editor renders an unsaved draft — model rows just fetched, ids just ticked — that the Host has not been told about. `ModelChoice.thinkingLevels` becomes required. The TUI used to fall back to its own metadata when a choice carried none, which is exactly the local resolution this change removes; a model no choice describes now offers no levels rather than a guessed list. The only caller that omitted choices was a test embedding. Compatibility epoch 83: an older client would ignore the new items and keep resolving locally, and an older Host sends none, leaving a newer client with an empty catalog. Both are rejected at the handshake. Generated-by: Claude Code
`provider-registry.ts` exports `PROVIDER_REGISTRY`; `llm-connections.ts` re-exported it and then bound a second name, `PROVIDER_DEFAULTS`, to the same object. Ninety-odd call sites used one, seven the other, and which one a file imported said nothing about what it did with it — a reader following either name had to discover the alias to know they were looking at the same table. The alias is gone and its call sites now name the registry. `ProviderDefaults` stays the name of a single entry's type, which is what it describes: the registry is a record of them. Generated-by: Claude Code
Now that the catalog entry is what crosses the wire, every field on it is something the Host computes, encodes, and a client decodes. Two had no reader at all. `recommendedRank` numbered a provider's curated fallback models, and nothing in the product ever ordered, filtered, or displayed by it — the pickers sort by their own rules. Its whole derivation goes with it. `capabilitySource` reported where a model's capabilities came from. Its only readers were three assertions in the catalog tests, each of which already checks the fact the override actually produced — the context window the user declared — so nothing about the covered behavior changes. Generated-by: Claude Code
`validateChatDefaultModel` re-derived one fact the catalog already states on every entry: whether a model can serve as the chat default, and if not, why. Nothing in the product called it — the pickers and readiness gates read `canUseAsChatDefault` and `unavailableReason` off the entry directly. Its only callers were assertions in this package's own catalog tests. Those assertions cover real catalog behaviour (output modalities, stale inventories, merged partial facts), so they stay: their `verdict` helper now reads the entry the build produced instead of calling a production function that existed for it. Generated-by: Claude Code
…reads
A catalog entry carried a record of where it came from: which user choice
named the model (`connection_default`, `saved_model`, `session_model`,
`daily_review_model`), and whether the provider inventory or the static
catalog described it. `savedModelIds` accepted a `{ id, source }` object so a
caller could say which of those it was.
No caller ever did. The one production producer is this module's own
connection builder, which passes the connection's `enabledModelIds` — plain
strings, every one of them recorded as `saved_model`. Nothing in the product
reads `provenance.sources` back, and the daily-review picker that was the last
plausible `{ id, source }` producer resolves through the Host's catalog now.
So the enum, the object form of `SavedModelChoice`, the sources record, and
the `userChoice` marker all go, along with the codec branch that validated
them over the wire. `savedModelIds` is what it always was in practice: the ids
a catalog must list even when no inventory describes them (apache#1584). That
behaviour is unchanged, and its tests still assert it — they just no longer
assert the label the entry wore while doing it.
Generated-by: Claude Code
…ltsOf `providerDefaultsOf` documents itself as the sole owner of "is this providerType one we know", because plain indexing cannot answer it: the registry is an object literal, so `['__proto__']` and `['toString']` resolve to inherited members and read as registered providers. Five accessors still indexed the registry directly. Four guarded with `?.`, which happens to yield the right answer for an inherited member only because `Function.prototype` has no `authKind`, `modelDiscovery`, or `baseUrl`; the fifth, `defaultEnabledModelIdsWhenOmitted`, had no guard at all. All five now ask `providerDefaultsOf`, so the recognition rule lives in one place instead of being re-derived, correctly or by luck, at each call site. Generated-by: Claude Code
…ason `availability` was `unavailableReason` folded into three buckets, and nothing but its own codec read it back: `none` became `available`, `stale` and `not_in_live_list` became `warning`, everything else `blocked`. Two ways of saying one thing, both now crossing the wire, both needing to stay in step. The reason itself is the richer of the two and the one the product reads, so the fold goes and the reason stays. `availabilityOf` carried the explanation of why a stale or unlisted model still sends (apache#1584); that belongs on `canUseUnavailableReasonAsDefault`, which is the function actually deciding it, and has moved there. Generated-by: Claude Code
Two conflicts with the base, both resolved by keeping each side's intent. The compatibility epoch: main claimed 84 for Host-bound directory references (apache#4097) while this branch had claimed it for the catalog projection. Both are real wire changes and both must be rejectable at the handshake, so main's 84 stands and this branch's moves to 85. The renderer architecture ratchet: `ProjectedLlmConnection` and `HostResolvedConnectionCatalog` were defined in `model-catalog.ts`, which made six renderer files depend on `@maka/core/model-catalog` where they had not before — new external-package debt the ratchet refuses. The types describe a connection, not a catalog computation: a connection is what holds a catalog. They move to `llm-connections.ts` beside `IdentifiedLlmConnection`, reached through a type-only import that erases at compile time, and every renderer file now names them through the `@maka/core/llm-connections` import it already had. The ledger is regenerated and the check passes against origin/main. Generated-by: Claude Code
…get pages The connection header now states how many resolved entries its page carries, and the reader treats a header that does not say as an invalid projection. Two hosted-execution suites still built pages without the field, so every catalog read in them failed before reaching what they actually assert. Both are about which Host or endpoint a target resolves to rather than about what the models are, so the honest count for their pages is zero: they send no entries and now say so. Generated-by: Claude Code
hqhq1025
left a comment
There was a problem hiding this comment.
I found one blocking protocol-boundary issue on this exact head. The Host-authoritative catalog migration is otherwise coherent across Core, Runtime Host, CLI, and Desktop.
Validation included a clean install, build:test, full typecheck, Core 777/777, CLI 686/686, focused Host catalog/protocol 13/13, focused CLI/Desktop catalog paths 193/193, lint, format, ASF headers, git diff --check, and a clean merge tree against current main. The full Runtime Host suite retained one unchanged managed-sandbox failure on this Linux container; Desktop retained eight unchanged MCP OAuth timer cancellations. The hosted Windows package job failed because the pinned v0.1.9 release artifact does not exist, while windows_recovery passed and hosted test is still running.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
ARE404
left a comment
There was a problem hiding this comment.
Holding approval at head 455a1214c61a32d5863b9ff90a3beda7a4d2c408. The code review is positive, but the PR cannot be approved yet: it is currently a draft, and CI is not green at this head — package is red and test is still running.
Code review (exact head). No P0/P1 found; the refactor is coherent and the critical surfaces are sound. Verified specifically:
- Protocol epoch is correct.
RUNTIME_HOST_COMPATIBILITY_EPOCH = 85, one above the current livemain(84), and the handshake rejects mismatches on both sides — client (packages/runtime-host/src/client/connection.ts ~1421) and server (host-kernel.ts ~530) — so an epoch-85 client and a non-85 host are refused before domain commands, matching the PR's claim (a stale-epoch peer "rejected at the handshake rather than left to degrade"). - Single authority for model facts.
resolveConnectionModelCatalogis the sole entry point for "what models does this connection have, and what is true about them"; the Host applies the Codex servable-set filter and capability/vision/thinking resolution once, and clients decode the projectedcatalog_entryinstead of re-deriving from their own bundled metadata. That removes the desktop-vs-tui disagreement the PR describes. - Fail-closed wire read.
assembleConnectionCatalogis strict: duplicate/nesting/out-of-rangecatalog_entryindices, count mismatches against the header (catalogEntryCount/modelCount/enabledModelIdCount), or a connection-total mismatch withfirst.connectionCountall throwinvalid_projectionrather than degrading. - No secret on the wire.
catalog_entrycarries model facts (display name, context window, thinking, vision, pricing, provenance) only — no API key / OAuth token / provider secret is projected. Connection auth stays in the credential store / connection entity, not the catalog page. - Provider-registry hardening is correct.
PROVIDER_DEFAULTS(the alias) is removed and direct registry indexing funnels throughproviderDefaultsOf, which owns theObject.hasOwn(PROVIDER_REGISTRY, providerType)own-property check, closing the['__proto__']/['toString']inherited-key reads. - Dead-field removal is genuine (
recommendedRank,capabilitySource,availability/unavailableReasonfold, model-choice provenance) — the PR body names each and the diff matches (fields removed from the wire and their only readers).
Minor (non-blocking, P3): a handful of call sites still do PROVIDER_REGISTRY[...].authKind directly (e.g. llm-connections.ts, provider-auth.ts) rather than through providerDefaultsOf. These are downstream of validated provider types today, but for consistency with the new own-property discipline a providerDefaultsOf(...)?.authKind form would avoid a possible TypeError if an unregistered string ever reaches one. Not a P0/P1; suggest cleaning up.
Why I'm not approving yet (gate, not code):
- The PR is in draft (
isDraft=true). packageis red at this exact head: the Windows packaging step fails ongh release download v0.1.9 --repo apache/maka --pattern Maka-0.1.9-win-x64.exe→release not found. This is an external baseline issue (the referenced release asset doesn't exist upstream), not a defect in this PR's diff — but the gate is not green either way.testis stillin_progress.
Recommendation: bring the PR out of draft, resolve the missing v0.1.9 Windows upgrade-baseline release (or the download step), and get test green at the head — then this can be re-reviewed for merge. Happy to re-lock and approve as soon as the gate is green.
ARE404
left a comment
There was a problem hiding this comment.
Approving the code at head 455a1214c61a32d5863b9ff90a3beda7a4d2c408. No P0/P1 in the change.
My earlier review stands: resolveConnectionModelCatalog is the single Host-side authority for model facts (Codex servable-set, capability/vision/thinking applied once); the client decodes the projected catalog_entry instead of re-deriving from its own bundled metadata; assembleConnectionCatalog is strict and fail-closed on malformed projections; the wire carries model facts only (no API key / token / provider secret); the provider-registry hardening through providerDefaultsOf closes the inherited-key (__proto__/toString) reads; the protocol epoch is one above live main (85 vs 84) and mismatches are rejected at the handshake on both client and server.
Explicit, posted for the record before any merge: this PR is still a draft, and CI is not green at this head — package is red (root cause is external infra: the Windows upgrade-baseline step runs gh release download v0.1.9 … Maka-0.1.9-win-x64.exe → release not found, i.e. a missing baseline release asset, not a defect in this diff) and test is still running. This approval reflects the code-review conclusion only; it must not be merged until the PR is brought out of draft, the baseline-release/package issue is resolved, and test goes green at the exact head. A reviewer approval does not record CI as green, and the merge branch protection/MEMBER account still enforces the real gate.
The wire bound on catalog entries per connection was the sum of the two persisted lists an entry can come from, plus one for an unlisted default. That missed a third source: a provider with no model-list endpoint has its whole shipped inventory prepended to the connection's own models rather than substituted for them, so its catalog is larger than what the connection stores. The gap is reachable from a catalog the storage decoder accepts. At its own maxima — 2,048 stored models, 512 enabled ids — a `volcengine-agent-plan` connection resolves to 2,578 entries against a bound of 2,561. The Host emits that page, the operation decoder rejects the Host's own projection as an invalid catalog entry count, and Desktop and the TUI are left with no model choices at all. The bound now includes that third term, derived from the registry rather than written down beside it: `MAX_PREPENDED_FALLBACK_MODELS` asks each provider that does not discover models how large its shipped inventory is. A provider added or a curated list grown moves the bound with it, which a hand-written number would not have done — that is how this one became too small. The fallback resolution the constant and the builder share is one function now instead of two copies of the same three lines. A regression walks every registered provider at both storage maxima and asserts the resolved catalog fits, then asserts the bound equals the largest catalog any provider reaches, so a bound that drifts above what is reachable fails too. Generated-by: Claude Code
M4n5ter
left a comment
There was a problem hiding this comment.
Approving exact head ab572cbd23892703da71c33d9dea9ea240e5baa0 with three non-blocking P2 findings inline. The previous catalog-bound P1 is closed by the latest commit: the wire bound now includes the largest prepended fallback inventory and the all-provider maximum regression passes. I found no remaining P0/P1.
I rebuilt Core and reran its focused catalog suite (21/21) after the head changed. Before that focused range recheck, the same code line passed the complete Core suite (777/777), 250 affected catalog/Desktop/CLI tests, 26 Host execution tests, format, renderer architecture, relevant typechecks, protocol epoch guard, and a clean merge tree against current main. I reran deterministic probes for all three remaining comments on the new head.
The hosted jobs restarted for this head and are still running, and the PR remains a draft, so this approval records the code-review result rather than merge readiness.
Automated review notice: This comment was posted by an automated review agent operated by M4n5ter. It is not an independent human review and does not replace one.
`deriveProviderAuthContract` opens with a guard for a providerType this build does not register, and its comment says the guard mirrors `isRealConnection`. It only mirrors it while it asks the same question the same way, and it did not: it read `PROVIDER_REGISTRY[providerType]` directly, which answers with an inherited member for `__proto__`, `toString`, `constructor` and `valueOf`. The guard saw a truthy value, fell through, and the rest of the function treated `Object.prototype` as a provider definition — reporting `setupMode: 'api_key'` for a connection whose provider does not exist. It asks `providerDefaultsOf` now, the same function `isRealConnection` asks. Making that change surfaced a second thing the old form had been hiding. `PROVIDER_REGISTRY[providerType]?.authKind` looks defensive, but the registry is a `Record`, so indexing it never widens to `undefined` and the optional chain was inert to the type checker. With a lookup that returns the honest type, `setupModeForAuthKind` no longer type-checks against an unregistered provider, and now says what it means: no registration, no setup to offer. The existing inherited-member regression covers the contract too. Generated-by: Claude Code
…alog The status line took its denominator from the connection's stored model rows. A provider without a model-list endpoint stores none — its models exist only in the Host-resolved catalog — so the very first status line and every diagnostic computed from it opened with no context window at all, until some later transition happened to refresh the value. Read it from `modelChoices` instead, which is where every later read of it already comes from. Generated-by: Claude Code
`normalizeOpenAiCodexConnection` filtered the model inventory but left `enabledModelIds` alone. A model this subscription cannot serve was picker-visible once, so a connection saved back then still lists it there — and the catalog lists an enabled id back even when no inventory describes it. The id came out the other side selectable, and failed at the provider once a scheduled run finally sent to it. Filter the stored selection by the same rule as the inventory, and keep the identity return when nothing changed so callers still see an untouched connection. Generated-by: Claude Code
…unedited The connection detail sheet rebuilt its catalog from the connection's stored fields on every render, including before the user has touched anything. That is the version disagreement the projection exists to end: a Desktop older or newer than the Host replaced the Host's display names and eligibility decisions with its own bundled guesses — and could offer a model the Host had ruled out. The client-side resolution an editor legitimately needs is narrower: once the draft diverges — model rows just fetched, ids just ticked — it describes a connection the Host has never been told about and so cannot have resolved. `resolveDraftConnectionModelCatalog` makes that the only branch that resolves, and lives in core beside the resolver it guards. Divergence compares every stored field an entry is built from, not ids alone: a re-fetched row can carry different facts under the same id, and comparing ids would keep showing the Host's stale entry for it. Generated-by: Claude Code
ARE404
left a comment
There was a problem hiding this comment.
Re-confirming approval at the current head 69094af8d65812df38f947ac5829aa693a615b8f (my earlier approve was on 455a1214, which this head superseded). No P0/P1 — the two commits added since are correctness fixes I reviewed and find sound:
ab572cbd— admits the prepended-fallback rows a resolved catalog actually carries. Without it, a provider with no model-list endpoint (e.g.volcengine-agent-plan) prepends its shipped inventory to the connection's stored models, and at the storage maxima (2,048 models / 512 enabled ids) resolves 2,578 entries against a wire bound of 2,561 — the Host emits that page, the decoder rejects its own projection as an invalid count, and clients end up with no choices. The bound is now derived from the registry (MAX_PREPENDED_FALLBACK_MODELS) and shared with the builder, with a regression that walks every provider at both maxima.69094af8— routesderiveProviderAuthContractthroughproviderDefaultsOf. It previously readPROVIDER_REGISTRY[providerType]directly, so__proto__/toString/constructor/valueOfresolved to inherited members, the unknown-provider guard saw truthy and fell through, andObject.prototypewas treated as a provider (reportingsetupMode: 'api_key'for a provider that does not exist). Now it asks the same ownerisRealConnectionasks, andsetupModeForAuthKindhonestly yields "no registration, no setup." Inherited-member regression extended to the auth contract.
The rest of my prior review holds (single Host authority for model facts, strict fail-closed catalog assembly, no secret on the wire, epoch 85 = live main+1 with both-sides handshake rejection).
For the record before any merge: the PR is still draft; package is red at this head (external baseline cause: gh release download v0.1.9 … → release not found); test was cancelled (not green), and mergeable is CONFLICTING. This approval is the code-review conclusion only and must not be merged until the PR is un-drafted, the baseline package issue is fixed, test is green at the exact head, and the conflict is resolved. Branch-protection / the MEMBER account still enforce the real merge gate.
…g-host-authority # Conflicts: # packages/runtime-host/src/protocol/index.ts
ARE404
left a comment
There was a problem hiding this comment.
Re-confirming approval at the current head 1edbf85f41 (supersedes my approvals at 455a1214/69094af8; this head rebased onto current main and added three more fixes). No P0/P1 — the three new PR commits (not the main-side merges) are sound:
39a7a511(cli): the TUI's opening status line/context-window denominator now reads from the resolved catalog (modelChoices) instead of the stored model rows, which are empty for a provider without a model-list endpoint.696149b2(core):normalizeOpenAiCodexConnectionnow also drops unservable Codex ids from the storedenabledModelIds(the inventory was filtered but the selection wasn't, so an unsupported id stayed selectable and failed at the provider); identity-return preserved when nothing changes.53fc9390(desktop): the connection detail sheet no longer re-derives the catalog from stored fields on every render; client-side resolution is confined toresolveDraftConnectionModelCatalog, used only once the draft actually diverges — closing the same "desktop-versus-host disagree on a model the Host ruled out" gap the projection exists to prevent.
My overall review stands from the prior heads (single Host authority for model facts; strict fail-closed catalog assembly; epoch 85 vs main 84 with both-sides handshake rejection; no secret on the wire; providerDefaultsOf/__proto__ hardening; the ab572cbd wire-bound fix and 69094af8 auth-contract fix verified).
For the record before any merge: the PR is still draft; package is red at this head (external baseline cause: gh release download v0.1.9 … → release not found); test is still running. This approval is the code-review conclusion only and must not be merged until the PR is un-drafted, the baseline package issue is fixed, and test is green at the exact head. Branch-protection / the MEMBER account still enforce the real merge gate.
The resolved entry crosses the wire and then the desktop IPC boundary, so every field is paid for on each catalog read by each attached client. Thirteen of its twenty-two had no reader anywhere: `providerType` and `connectionSlug` (the connection that owns the entry already holds both), `source`, `unavailableReason`, `lifecycle`, `docsUrl`, `inputLimit`, `maxOutputTokens`, `structuredOutput`, `lastUpdated`, `modalities`, `provenance`, and every capability but vision. They are not needed today; when a surface asks for one, it comes back with the reader that wants it. `makeEntry` still consults all of those facts — they just stop being shipped. `capabilities` becomes the one boolean the only consumer projected out of it anyway. Dropping `unavailableReason` collapses what fed it. `canUseAsChatDefault` was true for `none`, `stale` and `not_in_live_list` alike, so staleness and live-list absence never changed an answer: `isStale`, its seven-day window, and the `now`/`staleAfterMs` inputs that only tests supplied are gone with them. What is left is the two real vetoes — provider retirement and an explicit "cannot chat" — and `providerRetired` names the one producer `providerAvailable` ever had. `authOk` had no producer at all, so its `'auth'` reason was unreachable. The connection-level input loses the six options nothing passed: `savedModelIds`, `fallbackModels`, `now`, `staleAfterMs`, `providerAvailable`, `authOk`. `pricing` and `pricingSource` stay: the entry's `pricing` seam is documented as the one field kept without a producer, and cost accounting does not depend on it — a call is priced from `pricingModelKey` when it is recorded. Behavior change: an enabled id no inventory describes now runs the same chat guard as a listed row, so one the bundled metadata knows to be image-only stops being default-capable. It previously skipped that check by construction and was offered. apache#1584 is unaffected — absence from a live list is still not a veto, and a bare id with no metadata is still selectable. The draft comparison narrows to the stored fields an entry is actually built from, and gains `description`, `knowledgeCutoff` and `modalities` which it should have compared all along; `inputLimit`, `maxOutputTokens` and `parallelToolCalls` no longer reach an entry, so a change to them no longer throws the Host's entries away. Generated-by: Claude Code
A model catalog needs two facts: what a provider ships when Maka is offline, and what the provider itself lists when it is not. The first had three writers. `CURATED_CATALOG_FALLBACK_MODELS` was not a curated variant of `fallbackModels` — it was the same fact written twice, and the copy that got updated. For the eight providers it covered, three were byte-identical and five had moved on, so the registry still shipped `gpt-4o`, `gemini-2.5-flash`, `deepseek-chat` and `glm-4.6` while the catalog quietly served `gpt-5.5`, `gemini-3.5-flash`, `deepseek-v4-flash` and `glm-5.2`. Everything that read the registry directly — CLI onboarding, the connection-test probe, the storage seed, the transient connection the Host builds for an unsaved connection — got the stale list; only `buildConnectionModelCatalogEntries` got the fresh one. The newer content moves into `fallbackModels` and the second table is gone. `opencode-free.defaultEnabledModelIds` was the third copy: the same array variable as its `fallbackModels`, listed again. The fact it carried is real — the provider is free and keyless, so a new connection should have every model on rather than none — and it survives as `enableShippedModelsByDefault`, a flag the derivation reads. The list is now derived, so seed and hand-added connection cannot disagree about what "all of them" means, and the desktop add-form stops naming opencode-free. `providerFallbackModelIds` moves into the registry, becomes exported, and is the only reader of `fallbackModels` outside tests. Its `brokenModelIds` subtraction is why it exists: a quarantined id is one a stored connection may still carry, so it is filtered on read rather than pruned at the source. Behavior change: five providers now offer their current models offline instead of a list up to a year stale, and every surface offers the same one. The count in the Claude thinking census moves 13 → 16 because Anthropic's baseline gained Sonnet 4.6, Opus 4.8 and Haiku 4.5. Generated-by: Claude Code
`providerDefaultsOf` documents itself as the sole owner of "is this `providerType` one this build registers", and explains why plain indexing cannot answer it: `PROVIDER_REGISTRY` is an object literal, so a lookup by an inherited member's name resolves to that member. It lived in `llm-connections.ts`, which depends on `provider-registry.ts` — so the registry could not use its own recognition helper, and three other sites answered the question themselves. It moves into `provider-registry.ts` (re-exported unchanged) and the copies go: - `connections-ipc-validation.ts` gated on `providerType in PROVIDER_REGISTRY`. `in` traverses the prototype chain, so `__proto__`, `toString`, `constructor` and `hasOwnProperty` passed validation and were persisted as connections whose provider the build cannot resolve. The renderer reaches this boundary. Covered by a test. - `modelFactKey` repeated the `Object.hasOwn` check inline. - `isRetiredProvider` re-implemented `PROVIDER_REGISTRY[t]?.retired`. Five reads wrote `PROVIDER_REGISTRY[x]?.field`, which reads as a guard and compiles as none: `Record<ProviderType, ProviderDefaults>` never widens to `undefined`, so the `?.` is inert and an unregistered name yields an inherited member instead. They now ask `providerDefaultsOf`. Direct indexing by an already-`ProviderType` value is untouched — it claims no guard. `isKnownProvider` was a private one-line delegate to `providerDefaultsOf` under `isRealConnection`, which is itself that same question with a name; readiness now calls `isRealConnection` directly. `resolveModelPdfSupport` had no reference anywhere, and `resolveModelInputModalities` existed to serve it — its remaining assertions duplicate the `metadata.modalities` deepEqual in the same suite. Both are gone; `ModelInfo.modalities` still decides the image-only chat veto through `makeEntry`. Generated-by: Claude Code
`ModelChoice.connectionId` was optional, and the one thing that consumes a choice — the cross-connection `/model` rebind — cannot work without it, so it threw on the absent case. The only producer, `projectRuntimeHostModelChoices`, reads it straight off the Host's catalog connection and has always set it. Make the field required. The rebind's throw goes, and the label disambiguator stops carrying a slug fallback for an id it now always has. Generated-by: Claude Code
`modelsFetchedAt` — when the Host last ran model discovery — was encoded on every connection header, decoded with a paired-presence rule against `modelSource`, and projected by both clients into `LlmConnection`. No surface reads it: nothing in the renderer, the TUI, or `packages/ui` shows a discovery timestamp. The Host keeps it in storage, where `hasModelDiscoveryChanged` compares it to decide whether a run changed anything; that reader is unaffected. Drop it from the header (and with it the paired-presence check and the client-side `LlmConnection` field), and drop the `fetchedAt` the desktop `connections:fetchModels` reply echoed for the same reason — its caller reads `models` and `source` only. `lastTestModelFactsFingerprint` joins the same `Omit`. The projection has always destructured it away as durable invalidation metadata, so the type claimed a field the wire never carried. Generated-by: Claude Code
Every catalog page model carried `factOverriddenFields` — which fields a user's `model-facts.json` had overridden — and the protocol grew a whole second decoder for it: a duplicate copy of the model field list, a separate list of overridable fields, and membership and duplicate checks, all to admit a marker into a shape `decodeConnectionModel` otherwise rejects. No client reads it. The one reader of that provenance is the Host's own context-budget policy, which asks whether a context window was hand-set and reads it off the execution connection — a path this page is not on. The overrides themselves still reach clients: the page carries the merged values, which is what a picker renders. Its test now asserts that directly instead of asserting the marker travelled. Generated-by: Claude Code
`PROVIDER_AUTH_ACTIONS` listed six operations; the storage coordinator gates three — `test_credentials`, `fetch_models` and `start_oauth`. The other three arrived with `af724fbaea PR-AUTH-0: account auth contract mock UI` in May, as the contract half of a settings surface that never landed: no PR-AUTH-1 followed, the connection panel offers no revoke or refresh (deleting the connection is what clears a credential), and the `copy` field those actions were shaped for went in the previous trim for want of a reader. With `save_secret` and `revoke_auth` gone, `none` and `optional_api_key` decide the same two answers and collapse into the branch for every provider reachable without a key. State on the constant what earns an entry, so the next operation that sounds like it belongs has to name its admission point first. Generated-by: Claude Code
…ies it `ConnectionCatalogPageItem` is the type every projection of the model catalog crosses, and where both duplicate authorities this branch removed were introduced: `modelsFetchedAt` and `factOverriddenFields` were each added as a field here, shipped, and never read. Say on the type what the Host owns and what a client may not re-derive, and give a field the rule it has to pass — name a client-side reader, or leave it off. A separate architecture document would say the same thing where nobody adding a field is looking. The preceding commit carried a longer first draft of this comment without describing it in its message. Generated-by: Claude Code
The comment on ConnectionCatalogPageItem explained itself at length. Two sentences carry the rule: the Host owns the catalog, and a field only goes on this type if a client shows it. Generated-by: Claude Code
ASF source files carry the license header and leave authorship to git history; no other file in this repository names an author in a comment. Generated-by: Claude Code
The add-provider form computed `defaultEnabledModelIdsWhenOmitted` and sent the result as `enabledModelIds`. The `connections:create` handler never reads that field: it calls the same registry helper itself and builds the list from `defaultModel` plus the provider's shipped baseline. The registry answered the same question twice for one create, once on each side of the IPC boundary. Only the main-process answer was ever used, so the renderer's copy goes. Generated-by: Claude Code
ARE404
left a comment
There was a problem hiding this comment.
Re-reviewing at the current head 3d524e9aec (head has converged further since my prior approvals; requested re-review). No P0/P1 — approving.
Since my last review the PR added more single-authority convergence (plus three doc/comment commits that state the catalog authority on the seams that carry it):
fcb8e58e— the auth contract keeps only the actions something actually admits (none/optional_api_keycollapse onto the no-key branch; deadsave_secret/revoke_authgone), with the admission rule documented in the runtime-policy protocol.3d524e9a— the add-provider form stops computing and sendingenabledModelIdsthat theconnections:createhandler never reads (it recomputes the list fromdefaultModel+ shipped baseline on the main-process side); removes the redundant IPC-side copy.- The ongoing reachability/convergence holds: removed symbols relocated or dead, wire kept down to what a reader consumes, no residual orphan imports, no secret on the wire, epoch/handshake discipline unchanged.
CI is re-running at this head (package in_progress, windows_recovery green) — the PR is out of draft, MERGEABLE, review decision APPROVED. This approval is the code-review conclusion at this exact head. It should merge once CI is green at the exact head (the package baseline gate was fixed in-repo via the v0.2.0-dev.11.20260831 repin).
jackwener
left a comment
There was a problem hiding this comment.
I re-reviewed this at 3d524e9aeced1dcb46992583b0339a91e05cf9b0. No P0, P1, P2, or P3.
The Host now resolves each connection's catalog and projects a catalog_entry item per model. Clients decode those entries and stop merging stored rows against their own bundled metadata. The wire decoder is exact on the item and on the entry; a missing, duplicate, or short page fails closed. Compatibility epoch is 87 against current main 86, which is the right bump: an older client would ignore the new items and keep resolving locally, and an older Host would send none.
The two remaining client-side resolutions are the ones that have no Host-held connection: the recommended default while adding a provider, and an editor draft that has actually diverged from the committed connection. Unedited detail still reads connection.catalogEntries. Codex now drops unservable ids from enabledModelIds as well as from the inventory. The catalog-entry bound includes the registry-derived prepended fallback list, so a stored catalog at its own maxima still encodes. The latest commit stops the add-provider form sending an enabledModelIds list the create handler never read; the main process already builds that list from the registry.
Hosted package was still in progress on this head when I posted. This is a refactor; I am not merging it.
简体中文
我按 3d524e9aeced1dcb46992583b0339a91e05cf9b0 重审。没有 P0/P1/P2/P3。
Host 解析每个连接的目录,并按模型投影 catalog_entry。客户端解码这些条目,不再用自己打包的元数据去拼。条目和页都是严格解码,缺、重、少都会失败。兼容 epoch 是 87,当前 main 是 86,加一档是对的。
客户端还自己解析的两处都没有 Host 连接:添加供应商时的推荐默认模型,以及已经和已提交连接分叉的编辑草稿。未改的详情页仍读 Host 的 catalogEntries。Codex 会从 enabledModelIds 里拿掉不能服务的 id。条目上限含了 registry 推导的 prepended fallback。最新提交不再让添加表单发送 create 根本不读的 enabledModelIds。
发这条时 hosted package 还在跑。这是重构,我不合入。
Automated review notice: This comment was posted by an automated review agent operated by WAWQAQ. It is not an independent human review and does not replace one.
hqhq1025
left a comment
There was a problem hiding this comment.
I re-reviewed exact head 3d524e9aeced1dcb46992583b0339a91e05cf9b0 and found no P0-P3 issues.
The previous catalog-bound finding is fixed: the protocol limit now includes the largest registry-derived prepended fallback inventory, and the maximum-boundary regression exercises every provider. The completed refactor keeps resolved model facts Host-owned, strictly validates paginated catalog_entry projections, preserves client-side resolution only for genuinely unsaved drafts, filters unsupported Codex selections, and removes the renderer's unused enabledModelIds create payload. Compatibility epoch 87 correctly follows current main's epoch 86.
Validation on Node 22.22.1 included a clean install, build:test, full typecheck, Core 748/748, CLI 691/691, focused current-main synthetic-merge Host 33/33 and CLI 217/217 tests, lint, format, ASF headers, renderer architecture, git diff --check, and a clean merge tree. Full Desktop completed 1826 tests with eight unchanged MCP OAuth timer cancellations; full Runtime Host completed 1541 tests with one unchanged live-sandbox environment failure and 12 skips. The same live-sandbox failure reproduced on the current-main synthetic merge.
At publication, windows_recovery is green, while hosted package is still running and plan is queued. I did not run real provider requests or platform-specific macOS/Windows packaging locally, so this code-review result does not replace those exact-head gates or an independent human merge decision.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
M4n5ter
left a comment
There was a problem hiding this comment.
I re-reviewed exact head 3d524e9aeced1dcb46992583b0339a91e05cf9b0 and found no P0-P3 issues.
The three previously reported paths are closed. An unchanged Desktop connection editor preserves the Host's exact catalogEntries; unsupported legacy Codex ids are removed from enabled selections before Daily Review and readiness; and the TUI's opening context window comes from the exact Host-resolved model choice. The later cleanup continues the same design: committed catalog facts have one Host authority, pagination remains strict and fail-closed, the two remaining client-side resolutions cover only pre-connection creation and genuinely diverged drafts, and epoch 87 fences the incompatible wire change from main's epoch 86.
Validation included Core 771/771, Storage 1,073 pass / 10 platform skips, Runtime Host 1,542 pass / 12 platform skips, 337/337 affected tests, the exact Desktop component regression 4/4, and renderer architecture 60/60. Core, Storage, Runtime, Runtime Host, and CLI builds passed, as did changed-source formatting and git diff --check. All four review threads are resolved, and the current-main merge tree is clean. Hosted package and windows_recovery are green; the ordinary CI plan job is still queued, so this approval records the current-head code-review result and does not assert merge readiness.
Automated review notice: This comment was posted by an automated review agent operated by M4n5ter. It is not an independent human review and does not replace one.
…e feature branch Resolve the settings-surface.tsx conflict: keep the feature rework's SettingsPageBody shape (runtimeHostEpoch prop; usageStats prop dropped since the Usage feature now loads its own stats) and adopt apache#4411's connection type rename IdentifiedLlmConnection[] -> ProjectedLlmConnection[] (dropping the now-unused IdentifiedLlmConnection import). Regenerate renderer-architecture.json and the astryx surface inventory.
apache#4496) The connection detail asked `hasModelMetadata()` — a question about the renderer's own bundled models.dev snapshot — to decide whether an enabled model needs a hand-written capability declaration. Since apache#4411 clients read Host-resolved `catalogEntries` rather than resolve a catalog themselves, and since apache#4467 the Host refreshes that catalog at startup; so a model the Host learned about after this build was cut is described everywhere except this one renderer, which still showed it a spurious capability-declaration row. The Host already owns the answer. `ModelCatalogEntry` now carries `describedByMetadata`, set by `makeEntry` from the same metadata lookup `hasModelMetadata` reads, and the renderer asks the entry it already has instead of its stale table. Inferring coverage from whether some optional field happens to be present would put a second, weaker copy of the rule in the renderer — the split this line of work exists to close — so the entry states it directly. That makes it a wire field: it is required on `ModelCatalogEntry`, decoded by `model-catalog-entry-codec`, and the compatibility epoch moves to 90 so a Host and client that disagree about its presence are refused at the handshake. The `apache#1584` case (a user-typed id no inventory describes) still reports false, so that model keeps its declaration row. `provider-endpoint-presentation.ts` also reads the bundled table, but only to look up build-time `generatedModelProviderOverrides` a Host refresh never installs, so it stays consistent and is left alone. Fixes apache#4496 Generated-by: Claude Code
…alog (apache#4411) Every client resolved a model catalog entry — display name, context window, thinking levels, vision support — on its own, against the model metadata bundled into its build. A Desktop and a TUI attached to the same Host could describe the same model differently, and the difference tracked whichever client had been updated more recently rather than anything about the model or the account. The Host now resolves each connection's catalog and projects it. `connection.catalog.query` gains a `catalog_entry` page item, one per model, counted by the connection header alongside the model and enabled-id counts; clients decode what they were sent. `resolveConnectionModelCatalog` is the single entry point for what a connection's models are and what is true about them, so provider rules that shape the list — the Codex subscription's servable set — cannot be applied in one place and forgotten in another. Two client-side resolutions remain, both because no Host-held connection exists to project from: the recommended default model shown while adding a connection, and the unsaved editor draft in connection settings. Making the entry the thing that crosses the wire turns every field into a cost the Host pays to encode and each client pays to decode, so this change also removes what that cost bought nothing for. Gone from the catalog and its wire: `recommendedRank`, `capabilitySource`, `availability`, `pricing`, the model-choice provenance, `factOverriddenFields`, `modelsFetchedAt`, and the `ConnectionModelInventory` taxonomy whose one reader asked a yes/no question. Gone from the provider registry: the `PROVIDER_DEFAULTS` alias, a `protocol` field derivable from each entry's Runtime adapter, and three auth actions no admission point queried. Storage stops copying the registry's shipped inventory into every new connection, where it froze a second answer at write time beside the one the resolver already computes. Net: 1784 insertions against 2529 deletions across 106 files. Compatibility impact: the epoch moves to 87. An older client would ignore the new page items and resolve locally; an older Host sends none, leaving a newer client with an empty catalog. Both pairings are refused at the handshake rather than left to degrade. No stored document changes shape, so no data migration is required. Refs apache#4398 Generated-by: Claude Code
apache#4496) The connection detail asked `hasModelMetadata()` — a question about the renderer's own bundled models.dev snapshot — to decide whether an enabled model needs a hand-written capability declaration. Since apache#4411 clients read Host-resolved `catalogEntries` rather than resolve a catalog themselves, and since apache#4467 the Host refreshes that catalog at startup; so a model the Host learned about after this build was cut is described everywhere except this one renderer, which still showed it a spurious capability-declaration row. The Host already owns the answer. `ModelCatalogEntry` now carries `describedByMetadata`, set by `makeEntry` from the same metadata lookup `hasModelMetadata` reads, and the renderer asks the entry it already has instead of its stale table. Inferring coverage from whether some optional field happens to be present would put a second, weaker copy of the rule in the renderer — the split this line of work exists to close — so the entry states it directly. That makes it a wire field: it is required on `ModelCatalogEntry`, decoded by `model-catalog-entry-codec`, and the compatibility epoch moves to 93 so a Host and client that disagree about its presence are refused at the handshake. The `apache#1584` case (a user-typed id no inventory describes) still reports false, so that model keeps its declaration row. `provider-endpoint-presentation.ts` also reads the bundled table, but only to look up build-time `generatedModelProviderOverrides` a Host refresh never installs, so it stays consistent and is left alone. Fixes apache#4496 Generated-by: Claude Code
apache#4496) The connection detail asked `hasModelMetadata()` — a question about the renderer's own bundled models.dev snapshot — to decide whether an enabled model needs a hand-written capability declaration. Since apache#4411 clients read Host-resolved `catalogEntries` rather than resolve a catalog themselves, and since apache#4467 the Host refreshes that catalog at startup; so a model the Host learned about after this build was cut is described everywhere except this one renderer, which still showed it a spurious capability-declaration row. The Host already owns the answer. `ModelCatalogEntry` now carries `describedByMetadata`, set by `makeEntry` from the same metadata lookup `hasModelMetadata` reads, and the renderer asks the entry it already has instead of its stale table. Inferring coverage from whether some optional field happens to be present would put a second, weaker copy of the rule in the renderer — the split this line of work exists to close — so the entry states it directly. That makes it a wire field: it is required on `ModelCatalogEntry`, decoded by `model-catalog-entry-codec`, and the compatibility epoch moves to 93 so a Host and client that disagree about its presence are refused at the handshake. The `apache#1584` case (a user-typed id no inventory describes) still reports false, so that model keeps its declaration row. `provider-endpoint-presentation.ts` also reads the bundled table, but only to look up build-time `generatedModelProviderOverrides` a Host refresh never installs, so it stays consistent and is left alone. Fixes apache#4496 Generated-by: Claude Code
apache#4496) The connection detail asked `hasModelMetadata()` — a question about the renderer's own bundled models.dev snapshot — to decide whether an enabled model needs a hand-written capability declaration. Since apache#4411 clients read Host-resolved `catalogEntries` rather than resolve a catalog themselves, and since apache#4467 the Host refreshes that catalog at startup; so a model the Host learned about after this build was cut is described everywhere except this one renderer, which still showed it a spurious capability-declaration row. The Host already owns the answer. `ModelCatalogEntry` now carries `describedByMetadata`, set by `makeEntry` from the same metadata lookup `hasModelMetadata` reads, and the renderer asks the entry it already has instead of its stale table. Inferring coverage from whether some optional field happens to be present would put a second, weaker copy of the rule in the renderer — the split this line of work exists to close — so the entry states it directly. That makes it a wire field: it is required on `ModelCatalogEntry`, decoded by `model-catalog-entry-codec`, and the compatibility epoch moves to 94 so a Host and client that disagree about its presence are refused at the handshake. The `apache#1584` case (a user-typed id no inventory describes) still reports false, so that model keeps its declaration row. `provider-endpoint-presentation.ts` also reads the bundled table, but only to look up build-time `generatedModelProviderOverrides` a Host refresh never installs, so it stays consistent and is left alone. Fixes apache#4496 Generated-by: Claude Code
apache#4496) The connection detail asked `hasModelMetadata()` — a question about the renderer's own bundled models.dev snapshot — to decide whether an enabled model needs a hand-written capability declaration. Since apache#4411 clients read Host-resolved `catalogEntries` rather than resolve a catalog themselves, and since apache#4467 the Host refreshes that catalog at startup; so a model the Host learned about after this build was cut is described everywhere except this one renderer, which still showed it a spurious capability-declaration row. The Host already owns the answer. `ModelCatalogEntry` now carries `describedByMetadata`, set by `makeEntry` from the same metadata lookup `hasModelMetadata` reads, and the renderer asks the entry it already has instead of its stale table. Inferring coverage from whether some optional field happens to be present would put a second, weaker copy of the rule in the renderer — the split this line of work exists to close — so the entry states it directly. That makes it a wire field: it is required on `ModelCatalogEntry`, decoded by `model-catalog-entry-codec`, and the compatibility epoch moves to 94 so a Host and client that disagree about its presence are refused at the handshake. The `apache#1584` case (a user-typed id no inventory describes) still reports false, so that model keeps its declaration row. Editing a connection keeps that guarantee. When the draft diverges from storage — rows just fetched, ids just ticked — `resolveDraftConnectionModelCatalog` must resolve locally, so it preserves the Host's `describedByMetadata` for every id the Host already described and consults the bundled table only for an id the Host never saw. Otherwise the local rebuild would flip a refreshed model back to uncovered and bring the row this field removes straight back. `provider-endpoint-presentation.ts` also reads the bundled table, but only to look up build-time `generatedModelProviderOverrides` a Host refresh never installs, so it stays consistent and is left alone. Fixes apache#4496 Generated-by: Claude Code
apache#4496) The connection detail asked `hasModelMetadata()` — a question about the renderer's own bundled models.dev snapshot — to decide whether an enabled model needs a hand-written capability declaration. Since apache#4411 clients read Host-resolved `catalogEntries` rather than resolve a catalog themselves, and since apache#4467 the Host refreshes that catalog at startup; so a model the Host learned about after this build was cut is described everywhere except this one renderer, which still showed it a spurious capability-declaration row. The Host already owns the answer. `ModelCatalogEntry` now carries `describedByMetadata`, set by `makeEntry` from the same metadata lookup `hasModelMetadata` reads, and the renderer asks the entry it already has instead of its stale table. Inferring coverage from whether some optional field happens to be present would put a second, weaker copy of the rule in the renderer — the split this line of work exists to close — so the entry states it directly. That makes it a wire field: it is required on `ModelCatalogEntry`, decoded by `model-catalog-entry-codec`, and the compatibility epoch moves to 94 so a Host and client that disagree about its presence are refused at the handshake. The `apache#1584` case (a user-typed id no inventory describes) still reports false, so that model keeps its declaration row. Editing a connection keeps that guarantee. When the draft diverges from storage — rows just fetched, ids just ticked — `resolveDraftConnectionModelCatalog` must resolve locally, so it preserves the Host's `describedByMetadata` for every id the Host already described and consults the bundled table only for an id the Host never saw. Otherwise the local rebuild would flip a refreshed model back to uncovered and bring the row this field removes straight back. `provider-endpoint-presentation.ts` also reads the bundled table, but only to look up build-time `generatedModelProviderOverrides` a Host refresh never installs, so it stays consistent and is left alone. Fixes apache#4496 Generated-by: Claude Code
apache#4496) The connection detail asked `hasModelMetadata()` — a question about the renderer's own bundled models.dev snapshot — to decide whether an enabled model needs a hand-written capability declaration. Since apache#4411 clients read Host-resolved `catalogEntries` rather than resolve a catalog themselves, and since apache#4467 the Host refreshes that catalog at startup; so a model the Host learned about after this build was cut is described everywhere except this one renderer, which still showed it a spurious capability-declaration row. The Host already owns the answer. `ModelCatalogEntry` now carries `describedByMetadata`, set by `makeEntry` from the same metadata lookup `hasModelMetadata` reads, and the renderer asks the entry it already has instead of its stale table. Inferring coverage from whether some optional field happens to be present would put a second, weaker copy of the rule in the renderer — the split this line of work exists to close — so the entry states it directly. That makes it a wire field: it is required on `ModelCatalogEntry`, decoded by `model-catalog-entry-codec`, and the compatibility epoch moves to 95 so a Host and client that disagree about its presence are refused at the handshake. The `apache#1584` case (a user-typed id no inventory describes) still reports false, so that model keeps its declaration row. Editing a connection keeps that guarantee. When the draft diverges from storage — rows just fetched, ids just ticked — `resolveDraftConnectionModelCatalog` must resolve locally, so it preserves the Host's `describedByMetadata` for every id the Host already described and consults the bundled table only for an id the Host never saw. Otherwise the local rebuild would flip a refreshed model back to uncovered and bring the row this field removes straight back. `provider-endpoint-presentation.ts` also reads the bundled table, but only to look up build-time `generatedModelProviderOverrides` a Host refresh never installs, so it stays consistent and is left alone. Fixes apache#4496 Generated-by: Claude Code
apache#4496) The connection detail asked `hasModelMetadata()` — a question about the renderer's own bundled models.dev snapshot — to decide whether an enabled model needs a hand-written capability declaration. Since apache#4411 clients read Host-resolved `catalogEntries` rather than resolve a catalog themselves, and since apache#4467 the Host refreshes that catalog at startup; so a model the Host learned about after this build was cut is described everywhere except this one renderer, which still showed it a spurious capability-declaration row. The Host already owns the answer. `ModelCatalogEntry` now carries `describedByMetadata`, set by `makeEntry` from the same metadata lookup `hasModelMetadata` reads, and the renderer asks the entry it already has instead of its stale table. Inferring coverage from whether some optional field happens to be present would put a second, weaker copy of the rule in the renderer — the split this line of work exists to close — so the entry states it directly. That makes it a wire field: it is required on `ModelCatalogEntry`, decoded by `model-catalog-entry-codec`, and the compatibility epoch moves to 95 so a Host and client that disagree about its presence are refused at the handshake. The `apache#1584` case (a user-typed id no inventory describes) still reports false, so that model keeps its declaration row. Editing a connection keeps that guarantee. When the draft diverges from storage — rows just fetched, ids just ticked — `resolveDraftConnectionModelCatalog` must resolve locally, so it preserves the Host's `describedByMetadata` for every id the Host already described and consults the bundled table only for an id the Host never saw. Otherwise the local rebuild would flip a refreshed model back to uncovered and bring the row this field removes straight back. `provider-endpoint-presentation.ts` also reads the bundled table, but only to look up build-time `generatedModelProviderOverrides` a Host refresh never installs, so it stays consistent and is left alone. Fixes apache#4496 Generated-by: Claude Code
apache#4496) The connection detail asked `hasModelMetadata()` — a question about the renderer's own bundled models.dev snapshot — to decide whether an enabled model needs a hand-written capability declaration. Since apache#4411 clients read Host-resolved `catalogEntries` rather than resolve a catalog themselves, and since apache#4467 the Host refreshes that catalog at startup; so a model the Host learned about after this build was cut is described everywhere except this one renderer, which still showed it a spurious capability-declaration row. The Host already owns the answer. `ModelCatalogEntry` now carries `describedByMetadata`, set by `makeEntry` from the same metadata lookup `hasModelMetadata` reads, and the renderer asks the entry it already has instead of its stale table. Inferring coverage from whether some optional field happens to be present would put a second, weaker copy of the rule in the renderer — the split this line of work exists to close — so the entry states it directly. That makes it a wire field: it is required on `ModelCatalogEntry`, decoded by `model-catalog-entry-codec`, and the compatibility epoch moves to 95 so a Host and client that disagree about its presence are refused at the handshake. The `apache#1584` case (a user-typed id no inventory describes) still reports false, so that model keeps its declaration row. Editing a connection keeps that guarantee. When the draft diverges from storage — rows just fetched, ids just ticked — `resolveDraftConnectionModelCatalog` must resolve locally, so it preserves the Host's `describedByMetadata` for every id the Host already described and consults the bundled table only for an id the Host never saw. Otherwise the local rebuild would flip a refreshed model back to uncovered and bring the row this field removes straight back. `provider-endpoint-presentation.ts` also reads the bundled table, but only to look up build-time `generatedModelProviderOverrides` a Host refresh never installs, so it stays consistent and is left alone. Fixes apache#4496 Generated-by: Claude Code
#4500) Settings showed a spurious "declare this model's capabilities by hand" row for any model the Runtime Host already describes. The renderer answered "does this model have metadata" from its own bundled models.dev snapshot, which never refreshes, while since #4411 and #4467 the Host resolves and refreshes the catalog itself, so the two disagreed for every model listed after the build was cut. The Host is the authority, so `ModelCatalogEntry` now carries `describedByMetadata`, set by `makeEntry` from the same lookup `hasModelMetadata` reads, and `provider-connection-detail.tsx` reads that field off the `modelChoices` it already holds instead of consulting the bundled table. A user-typed id no inventory describes still reports false and keeps its declaration row. No protocol epoch change: the field is additive on a Host-produced entry. Fixes #4496 Generated-by: Claude Code
Summary
A model catalog entry — display name, context window, thinking levels, vision support — was resolved independently by every client, each against the model metadata bundled into its own build. A Desktop and a TUI attached to the same Host could describe the same model differently, and the difference tracked whichever client had been updated more recently rather than anything about the model or the account. That is backwards for a product where the Host holds the connection, runs discovery, and sends the request: it is the only party in a position to say what a model is.
The Host now resolves each connection's catalog and projects it.
connection.catalog.querygains a third page item,catalog_entry, one per model, counted by the connection header alongside the model and enabled-id counts. Clients decode what they were sent instead of re-deriving it.resolveConnectionModelCatalogis the single entry point for "what models does this connection have, and what is true about them", so provider rules that shape the list — the Codex subscription's servable set — cannot be applied in one place and forgotten in another. Two client-side resolutions remain, both because no Host-held connection exists to project from, and both documented at the call site and indocs/architecture/runtime-host-architecture.md: the recommended default model shown while adding a connection, and the unsaved editor draft in connection settings.Making the entry the thing that crosses the wire turns every field into a cost the Host pays to encode and each client pays to decode, so this PR also removes what that cost was buying nothing for. Gone from the catalog and its wire:
recommendedRank,capabilitySource,availability,pricing, the model-choice provenance,factOverriddenFields,modelsFetchedAt, and theConnectionModelInventorytaxonomy whose one reader asked a yes/no question. Gone from the provider registry: thePROVIDER_DEFAULTSalias, aprotocolfield derivable from each entry's Runtime adapter, and three auth actions no admission point ever queried. Storage stops copying the registry's shipped inventory into every new connection, where it froze a second answer at write time next to the one the resolver already computes. Net: 1784 insertions against 2529 deletions across 106 files.The compatibility epoch moves to 87. An older client would ignore the new items and resolve locally; an older Host sends none, leaving a newer client with an empty catalog. Both are rejected at the handshake rather than left to degrade.
Refs #4398
Verification
Repository-wide
build,typecheckandformat:checkare clean. The desktop renderer architecture ratchet passes against the merge-base commit.Targeted suites, all passing: core catalog, connections, provider auth, provider catalog contract and connection readiness (62); Runtime Host policy coordinator, catalog reader, bootstrap and connection-effect coordinator (57); storage runtime-policy stores (61); CLI TUI runner, onboarding and task readiness (181); desktop connection catalog fixture, account connection, OAuth and Copilot IPC, add-provider submission and catalog choices (26).
New behavior is covered by tests that fail without it — the
catalog_entrypage item, its counts, and the catalog reader's reassembly. The removals are guarded by existing assertions updated in the same commits: each names the fact that survives rather than the field that left.Not run: the repository-wide test suite, Playwright E2E, and Storybook. No user-visible surface changes shape, so no screenshot is attached; the picker and settings copy render the same fields from a different source.
Breaking change
Host and client must agree on compatibility epoch 87. A mismatched pair is refused at the handshake, so an older client cannot silently fall back to resolving the catalog itself.
AI use
Select exactly one:
Tool(s) and scope: Claude Code (Opus 5) drafted the Host-side projection, the protocol changes, the client decoders, and the removals listed under Summary, and ran the verification above. Every commit with material AI-authored content carries a
Generated-by: Claude Codetrailer. The human contributor of record reviewed the diff and owns the submission.Checklist
Does this PR entail a change in behavior?