Skip to content

feat(runtime-host): refresh the models.dev catalog at Host startup - #4467

Merged
Astro-Han merged 9 commits into
mainfrom
feat/4398-host-model-metadata-refresh
Sep 1, 2026
Merged

feat(runtime-host): refresh the models.dev catalog at Host startup#4467
Astro-Han merged 9 commits into
mainfrom
feat/4398-host-model-metadata-refresh

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

A Maka installed months ago describes every model by the models.dev snapshot compiled into that build. Since #4411 the Runtime Host is the sole authority for the model catalog, so that staleness is now one process's problem to fix: the Host fetches https://models.dev/api.json once at startup and, on a catalog it can project whole, makes it the metadata every connection resolves against. Clients keep reading Host-resolved catalog entries, so a Desktop or TUI on any version sees the refreshed facts without being updated itself.

The refresh reaches clients as a fifth HostChangeFeed frame, connection.catalog.changed, and they re-query. It is deliberately not configuration.changed: the user's settings did not move, and Desktop's handler for that frame raises a settings-changed-elsewhere notice which must not fire here.

Three things had to be true before a runtime refresh could exist at all, and they are the first three commits:

  1. The projection had to accept what models.dev actually publishes. Live upstream declares video and pdf modalities; the validator rejected them, so 324 models failed and refresh:model-metadata wrote nothing. Widening the vocabulary takes rejects to zero (1871 → 1906 models, +66 / −31). This is a wire vocabulary change, hence the compatibility epoch bump to 88.
  2. One projection, not two. The generator script owned the models.dev → ModelMetadata mapping. It now lives in packages/core/src/models-dev-projection.ts and both the script and the Host read it, so a model cannot mean one thing in the committed snapshot and another when the Host reads it live. Verified behaviour-preserving by running the pre- and post-refactor generators against the same live input: byte-identical projection, identical generated module.
  3. An install seam that replaces rather than merges. A merge would keep describing a model upstream has delisted. The committed snapshot is not a second opinion about a model upstream still publishes — it is what we fall back to when we have no answer at all.

Replacing the whole table is safe for the model list: which models a connection has comes from its stored rows, the registry's fallback list and the ids the user enabled — models.dev feeds only what is true about a model, never which models exist. That is also why a metadata swap mid-pagination is safe: catalogEntryCount and every catalog cursor are invariant under it, so only field values can differ across pages, and the frame makes the client re-read.

Failure is whole and quiet: offline, timeout, a non-200, an oversized body, or an upstream shape the projection refuses all leave the build's committed snapshot in place. There is no partial install and no per-model tolerance — the Host runs exactly the fail-loud projection the generator does.

The generator and the Host share the whole refresh, not just the projection. fetchModelsDevProjection in packages/core/src/models-dev-refresh.ts owns the three steps neither caller gets to decide alone — fetch under a byte bound, project, account for what upstream stopped carrying — and they differ only in the fourth. That difference is deliberate: the generator refuses a shrinking refresh until a human passes --accept-upstream-removals, because it is about to commit and redistribute the result; the Host records the removals to stderr and adopts them, because nothing it installs is committed and the next process start asks again. Both name a removal by the same path, so an operator reading the Host log and a maintainer reading the generator's refusal are looking at the same thing.

The byte bound itself is shared one level lower: readBoundedResponseText in packages/core/src/bounded-response.ts is the one implementation, used here and by the WebFetch tool. It refuses on the declared content-length before a byte arrives and again on the running byte count, because a body that never declared a length — or lied — is the case a single check misses.

The fetch goes through the same admission as the WebFetch tool: privacy mode refuses it outright and a configured proxy carries it. That operation is now named resolveHostOutboundExecution for what it admits rather than for its first caller.

Finally, the refresh retires static overrides that only existed because the snapshot they were written against lacked the fact: openrouter's deepseek-v4-pro effort set and the GPT-OSS branch of the Ollama Cloud thinking pin. Both now restate models.dev verbatim, in the committed snapshot and in the live catalog alike.

Deliberately excluded, per the issue: TTL, on-disk cache, conditional GET, and an opt-out toggle. Also not refreshed here, and unchanged by this PR: pricing (model-pricing.generated.ts), provider routing overrides, and provider facts. The connection editor's draft view still resolves locally once the draft diverges from what the Host committed, so an editor mid-edit can briefly show pre-refresh facts; that is the existing draft contract, not a regression.

Two things a live catalog makes reachable that a compiled snapshot never did are fixed here rather than deferred. The catalog's display names and descriptions are now truncated where the entry is built, to the same limits the wire codec enforces — before, an upstream string longer than either produced a page the Host could encode and no client could decode, losing every model on it rather than one long string. And the TUI now derives the selected model's context window and thinking levels from the live choice list instead of keeping mirrored copies, which a mid-session catalog refresh would otherwise leave stale.

One commit on this branch is unrelated to #4398: scripts/qualify-released-cli-state-root.test.mjs asserted POSIX-separator paths and failed the package job on Windows. The bug arrived on main with #4427, not with this PR; it is fixed here only because it was blocking this branch's CI.

Snapshot-vs-upstream drift check, a scheduled refresh job, and removing docsUrl from ModelMetadata belong to the parallel PR on ci/4398-model-metadata-drift-check.

Refs #4398

Verification

Run locally against this branch, all green:

  • npm run build
  • npm run typecheck
  • npm run format:check
  • npm run lint
  • npm run check:asf-headers
  • npm run check:model-metadata

Affected suites only (node --test, concurrency ≤ 4), 0 failures:

  • @maka/runtime-host: model-metadata-refresh (new, 6 tests), host-change-feed, web-fetch-tool, connection-session, handshake-compatibility, host-kernel, project-catalog-protocol, runtime-policy-protocol, reconnecting-connection, catalog-reader, runtime-policy-coordinator
  • @maka/core: models-dev-refresh (new, 4 tests), model-metadata, model-catalog, model-facts, model-thinking, llm-connections, provider-catalog-contract, runtime-policy-codec
  • @maka/runtime: local-web-fetch, web-fetch-tool, model-factory-thinking, provider-conformance
  • Generator: scripts/sync-model-metadata.test.mjs
  • @maka/storage: runtime-policy-stores
  • @maka/cli: pi-tui-runner, runtime-host-cli-context, runtime-host-onboarding, runtime-host-tui-context, tui-mcp-remote-publication
  • Desktop main: model-catalog-choices, relay-thinking-bulk, relay-profile-draft

Not run locally: the repo-wide suite, Windows and Electron packaging jobs. Left to CI.

Measurements backing the claims above, taken against live models.dev:

  • Before the modality widening: 324 models rejected, refresh:model-metadata writes nothing. After: 0 rejected, 1871 → 1906 models (+66 / −31).
  • Commit 2 behaviour: pre- and post-refactor generators run on the same live input produce a byte-identical projection and an identical generated module.
  • Commit 5 deletions: openrouter/deepseek/deepseek-v4-pro and ollama-cloud/gpt-oss:{120b,20b} resolve to the same thinkingOptions before and after the deletion, under both the committed snapshot and the live catalog. openrouter/openai/gpt-5.6-sol was checked and kept — it pins a toggle models.dev omits.

Compatibility

RUNTIME_HOST_COMPATIBILITY_EPOCH 87 → 88, for two reasons in one epoch: a connection model can now carry video or pdf in either modality direction, which an older peer's strict codec rejects, and the Host emits a connection.catalog.changed frame whose kind an older client's frame decoder does not know. Mismatched peers are refused at the handshake, as intended.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code wrote the implementation, tests and documentation edits in this PR under human direction and review. The affected commits carry Generated-by: Claude Code trailers; please keep the trailer on the squash commit.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

@Astro-Han
Astro-Han marked this pull request as ready for review September 1, 2026 11:47
@Astro-Han
Astro-Han force-pushed the feat/4398-host-model-metadata-refresh branch from e763324 to 01c3117 Compare September 1, 2026 11:52

@jackwener jackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I reviewed this at 01c31179ddb031578a9edefaca46b7e4af314287. No P0, P1, P2, or P3.

The Host now fetches models.dev once at startup through the same outbound admission as WebFetch. Privacy mode and a missing proxy credential skip the fetch and leave the bundled snapshot. Offline, timeout, a non-200, an oversized body, or a catalog the projector refuses all keep that snapshot and announce nothing. A catalog that projects whole replaces the table, then the Host publishes connection.catalog.changed so clients re-query. That frame is not configuration.changed, so Desktop does not raise a settings-changed-elsewhere notice. The decoder is exact on kind and revision. Epoch 88 against current main 87 covers both the new frame kind and video/pdf modalities.

Which models a connection has still comes from stored rows, fallbacks, and enabled ids. models.dev only answers what is true about a model. Desktop and TUI both re-read the catalog on the frame.

#4460 is also sitting on epoch 88. The first of these to merge is fine; the other must re-bump after main moves. This is a feature; I am not merging it.

简体中文

我审的是 01c31179ddb031578a9edefaca46b7e4af314287。没有 P0/P1/P2/P3。

Host 启动时经 WebFetch 同一套出站许可拉一次 models.dev。隐私模式和缺代理凭据会跳过。失败整表保持打包快照,不发通知。成功则整表替换,再发 connection.catalog.changed,不是 configuration.changed。epoch 88(main 87)。连接有哪些模型仍来自存储行和 fallback。#4460 也占着 88,后合的那个要再加。这是功能,我不合入。


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 hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No P0 or P1 findings remain on this exact head. I found two non-blocking P2 issues: the advertised response bound is enforced only after the full body is buffered, and the TUI can miss the one-shot catalog refresh between its initial query and late listener registration. The shared projection, whole-table install, protocol epoch/frame handling, Desktop invalidation path, privacy/proxy admission, and current-main compatibility otherwise held under review.

Local verification passed: clean install, build:test, full workspace typecheck, lint/format/ASF/diff checks, 418 affected tests, and focused Host refresh/change-feed/WebFetch/reconnect/policy tests. A streaming probe confirmed that a 40 MiB no-content-length response was read in full before the 32 MiB error. Hosted checks were still running when this review was submitted, so this approval is a code-review result rather than a statement that the merge gate is complete.

Review notice: This review was prepared by an automated review agent operated by hqhq1025 and is published at the direction of AstroHan, who has read these findings and is the human accountable for them.

if (!response.ok) {
throw new Error(`models.dev responded ${response.status}`);
}
const body = await response.text();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 — Enforce the response limit while streaming, before buffering. response.text() consumes the entire response before line 106 checks its length, so the stated 32 MiB bound does not constrain network input or peak Host memory. A no-content-length probe that supplied forty 1 MiB chunks pulled all 40 chunks before this path reported the size error; a fast upstream or configured proxy can therefore make the Host buffer arbitrarily more than the intended limit within the timeout. Please read response.body incrementally, count Uint8Array.byteLength, cancel as soon as the limit is crossed, and optionally reject an oversized Content-Length up front (the repository's readBoundedBytes helper in scripts/release-cli-publication.mjs demonstrates that shape).

model: selectedTarget.model,
...(modelContextWindow === undefined ? {} : { modelContextWindow }),
modelChoices,
subscribeModelCatalogChanges: (listener) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 — Close the initial-query/subscription gap. The TUI captures connected.catalog at line 142, performs several awaited setup/recovery operations, returns this subscription closure, and only invokes it later in runMakaPiTui (pi-tui-runner.ts:1252). The Host publishes this refresh once, and RuntimeHostConnection does not retain/replay the revision when its local listener set is empty. If the startup fetch completes in that interval, the frame is discarded and this TUI keeps the bundled metadata for its whole lifetime even though the Host installed the live table. Subscribe before accepting the initial snapshot, or subscribe first and immediately re-read/reconcile so a refresh in the gap cannot be lost.

@github-actions github-actions Bot added the effort/XL Over 1000 readable lines label Sep 1, 2026
@Astro-Han
Astro-Han force-pushed the feat/4398-host-model-metadata-refresh branch from a086d3c to 9e39240 Compare September 1, 2026 13:11
The Runtime Host is about to turn a models.dev response into `ModelMetadata`
at startup, and the generator already does exactly that. Two copies of that
projection would let one model mean one thing in the committed snapshot and
another when the Host reads it live, which is the class of split this issue
exists to close.

`packages/core/src/models-dev-projection.ts` now owns provider selection and
the per-model metadata projection. The generator keeps what only a build has:
pricing, provider facts, model-provider overrides, snapshot IO, and code
generation. Those stay out of the runtime parser deliberately — the Host
installs metadata alone, so a malformed `cost` field it never reads must not
be able to veto a metadata refresh.

`prepare` runs the generator before any workspace builds, so the script loads
the TypeScript source through its existing `loadTypeScriptModule` rather than
a dist build that does not exist yet. That path erases type-only imports but
cannot resolve a relative value import, which is why the new module keeps all
of its relative imports type-only.

The modality vocabulary is now a `Record<ModelModality, true>` derived from
`ModelInfo`, so a modality added to the type cannot reach the wire without a
decision in the projection.

Behaviour is unchanged and verified as such: refreshing the same models.dev
response through the previous and current generator yields a byte-identical
projection and byte-identical generated modules.

Refs #4398

Generated-by: Claude Code
The models.dev layer was a module constant, so the only way to change it
was to ship a new build. The Runtime Host is about to fetch the catalog at
startup and needs somewhere to put the answer.

The seam replaces the whole table rather than merging model by model. A
merge would keep describing a model upstream has delisted, and the
committed snapshot is not a second opinion about a model upstream still
publishes — it is what we fall back to when we have no answer at all.

The four tables that used to be derived at module init (Claude
subscription, OpenAI OAuth, SiliconFlow overrides, Ollama Cloud thinking)
now read the active table, and the static patch layer is memoized on that
table's identity so a lookup after an install pays the rebuild once.

Refs #4398

Generated-by: Claude Code
The Host owns the model catalog, so a Maka installed months ago described
every model by the snapshot compiled into that build. It now asks
models.dev once at startup and, on a whole catalog it can project, makes
that the metadata every connection resolves against — no reinstall, and no
client needing to be on a matching version.

The refresh is announced as a fifth `HostChangeFeed` frame,
`connection.catalog.changed`, rather than reusing `configuration.changed`:
the user's settings did not move, and a client showing a
settings-changed-elsewhere notice must not show it here. Desktop re-emits
its connection list on the frame; the TUI re-reads and adopts the Host's
projection wholesale.

The fetch goes through the same admission as the WebFetch tool — privacy
mode refuses it, a configured proxy carries it — which is why that
operation is now named for what it admits rather than for its first
caller. Any failure at all, offline through an upstream shape the
projection refuses, leaves the committed snapshot in place; there is no
partial install.

Mid-pagination the swap is safe: which models a connection has comes from
its stored rows, the registry's fallback list and the ids the user
enabled, never from models.dev, so `catalogEntryCount` and every cursor
survive. Only field values can differ across pages, and the frame makes
the client re-read.

Refs #4398

Generated-by: Claude Code
Two hand-written entries existed because the snapshot they were written
against did not carry the fact: openrouter's deepseek-v4-pro effort set,
and the GPT-OSS branch of the Ollama Cloud thinking pin. Both now restate
what models.dev publishes, verbatim, in the committed snapshot and in the
live catalog alike — verified by resolving each id both ways before and
after the deletion.

The groq comment claimed models.dev misapplies an effort set to
qwen/qwen3-32b; models.dev no longer lists that model at all, so the entry
is now the sole source rather than a correction, and says so.

Refs #4398

Generated-by: Claude Code
WebFetch bounded its response body with a private helper. The models.dev
refresh needed the same bound and grew a second one that counted
`String.length` — a UTF-16 unit count, which lets a multi-byte body pass a
limit it exceeds by up to three times, and only after the whole body was
already buffered.

Lift WebFetch's implementation to `@maka/core/bounded-response` and have both
call it. The bound stays enforced twice, before and during the read, in the
one place either caller can change it.

Generated-by: Claude Code
A models.dev refresh is four steps: fetch it under a byte bound, project it,
account for what upstream stopped carrying, then decide what to do with the
result. Only the projection was shared. The Host reimplemented the fetch bound
and skipped the accounting entirely, so it silently dropped models the
generator would have stopped a human over — and the two disagreed about which
responses are too large.

Move `collectProjectionRemovals` beside the projection it describes and add
`fetchModelsDevProjection`, which owns all three shared steps. The generator
and the Host now differ only in the fourth: the generator refuses a shrinking
refresh until a human acknowledges it, the Host records the removals and
adopts them, because nothing it installs is committed or redistributed.

Drops the Host's `onSkipped` callback, which no caller ever passed.

Generated-by: Claude Code
The refresh starts partway through composing the Host. A later step that
throws unwinds every other resource it had already built, but the refresh was
held in a `try`-scoped binding the catch block could not see, so its in-flight
fetch and proxy transport outlived the composition that started them.

Hoist the binding beside the other unwound resources and close it first.

Generated-by: Claude Code
The TUI kept its own copies of the selected model's context window and
thinking levels, resynchronized at three sites. A fourth was now needed: the
Host republishes its catalog mid-session, and a copy taken before that keeps
sizing the context bar and gating `/thinking` by the model's old properties.

Read both from the current choice at the moment they are used. There is
nothing left to resynchronize, and the compensation branch that cleared a
stale context window on a model switch goes with it.

Generated-by: Claude Code
The codec caps a model's display name at 512 characters and its description at
2048. Nothing upstream of it did. A refreshed models.dev row longer than either
built a catalog entry the Host could encode but no client could decode, and
the whole page was rejected — every model gone, not the one long string.

Name the two limits where the catalog is built, truncate there, and have the
codec read them. A producer that cannot exceed the limit is why the decoder
never has to reject a page.

Generated-by: Claude Code
@Astro-Han
Astro-Han force-pushed the feat/4398-host-model-metadata-refresh branch from 9e39240 to cba0090 Compare September 1, 2026 16:37
@Astro-Han
Astro-Han merged commit e0fdd32 into main Sep 1, 2026
2 checks passed
@Astro-Han
Astro-Han deleted the feat/4398-host-model-metadata-refresh branch September 1, 2026 17:24
liuxiaocs7 added a commit to liuxiaocs7/maka that referenced this pull request Sep 1, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XL Over 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants