Skip to content

ci: schedule model metadata upkeep and check the snapshot against upstream - #4460

Merged
Astro-Han merged 9 commits into
mainfrom
ci/4398-model-metadata-drift-check
Sep 1, 2026
Merged

ci: schedule model metadata upkeep and check the snapshot against upstream#4460
Astro-Han merged 9 commits into
mainfrom
ci/4398-model-metadata-drift-check

Conversation

@Astro-Han

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

Copy link
Copy Markdown
Contributor

Summary

Nothing ran refresh:model-metadata on a schedule and nothing ever compared the
committed snapshot against models.dev, so the snapshot could sit weeks behind
upstream with every check green. That is how glm-5.3, listed upstream from
2026-08-14, was still absent from a snapshot regenerated on 2026-08-29.

This adds the missing upkeep — a weekly job that refreshes the snapshot into a
draft pull request for review, and a snapshot-versus-upstream drift check — and
then uses it: the snapshot is refreshed here, against a live models.dev response.

One thing had to come first. models.dev now declares video on either side of a
model and pdf as an output. The projector named four input and three output
values and threw on anything else, so 324 of the 1906 models in the selected
providers took the whole refresh down with them: npm run refresh:model-metadata
could not write a byte. Admitting both values is a Client-Host wire change and
carries the epoch bump.

ModelMetadata.docsUrl goes too. It had no reader anywhere in packages or
apps and duplicated, once per model, the provider doc the generated provider
facts already carry once per provider.

Refs #4398

Review focus

  • Widening the catalog modality set is a compatibility break: the decoder rejects
    any value it does not name, so a newer Host describing a video model fails an
    older client's catalog decode outright. RUNTIME_HOST_COMPATIBILITY_EPOCH
    moves 87 → 88 to keep that pairing from forming.
  • That set had five hand-written copies and the widening reached four of them.
    ModelModality in llm-connections.ts owns it now, and the decoder, the
    model-facts overlay normalizer and model-fetcher's live-fetch reader all read
    that one predicate. The projector keeps its own copy because it runs before
    packages/core is built; a value that reaches the projection but not the type
    fails to compile, so the build is what holds those two together.
  • The refresh ran with --accept-upstream-removals for 31 models models.dev has
    retired. deepseek-chat and deepseek-reasoner are among them and stay
    selectable: provider-registry lists them under fallbackModels and
    builtin-pricing carries their rates, so only bundled display metadata goes.
  • 133 models now declare an output modality without text. model-catalog already
    refuses those as chat defaults; its own comment documented that it could not see
    the video ones, and it now can. One test asserted the old blind spot and is
    updated to assert the guard instead.
  • The drift check and the refresh share one projector. buildProjection takes its
    failure policy as an argument: a refresh aborts on the first shape it cannot
    project, and the report turns that shape into its own finding so a single
    surprising model cannot hide every other difference. Both walk the projection's
    four sections through one table rather than naming them, which is what keeps
    providerFacts and providerOverrides in the comparison.
  • The scheduled job passes --accept-upstream-removals. The guard's remediation
    is "inspect the change and rerun with the flag", which only a person at a
    terminal can do; upstream retires paths every day, so refusing here would have
    made the job red every week and it would never have opened a pull request at
    all. The review seat is the draft pull request instead: every removal is in its
    diff, the drift report is in its body, and a committer still approves before
    anything merges.

Verification

Ran on this branch:

  • npm run build (all workspaces) — pass
  • npm run typecheck — pass
  • npm run format:check, npm run lint — pass
  • npm run check:asf-source — pass (105 tests), covering check:model-metadata,
    sync-model-metadata.test.mjs and the new
    model-metadata-upkeep-workflow-policy.test.mjs
  • the same workspace set the heavy lane runs, through the same runner:
    node scripts/run-workspace-tests-parallel.mjs --concurrency=3 --workspaces="packages/core,packages/storage,packages/mcp,packages/runtime,packages/eval,packages/computer-use,packages/cli,packages/ui,apps/desktop"
    — all passed
  • node --test scripts/ci-test-plan.test.mjs — pass (58 tests)
  • node scripts/ci-test-plan.mjs --base origin/main --head HEAD selects
    asf_source, so ci.yml picks the new files up through the existing plan lane
    rather than a new pull_request workflow

The drift check separates its two outcomes by exit code, which is what the
scheduled job branches on:

$ node scripts/sync-model-metadata.mjs --drift >/dev/null; echo $?
2
$ node scripts/sync-model-metadata.mjs --drift --snapshot /nonexistent.json >/dev/null 2>&1; echo $?
1

Against live models.dev at the time of writing, the snapshot committed here is
three entries behind — one openrouter model retired since the refresh and two
repriced — which is the ordinary daily movement this job exists to collect:

$ npm run --silent check:model-metadata-drift
https://models.dev/api.json has drifted from the committed snapshot.
entries the snapshot has and upstream does not: 1
  openrouter/anthropic/claude-opus-4.7-fast
entries whose projection changed: 2
  openrouter/deepseek/deepseek-v4-flash-vision-exp: pricing.cacheReadUsdPer1M, pricing.inputUsdPer1M, pricing.outputUsdPer1M
  openrouter/deepseek/deepseek-v4-pro-0813: pricing.cacheReadUsdPer1M, pricing.inputUsdPer1M, pricing.outputUsdPer1M

Before the refresh in this branch the same command reported 66 models upstream had
and the snapshot did not (glm-5.3 among them), 31 the snapshot had and upstream
did not, and 468 whose projection had changed. Before the modality widening it
produced no report at all — the projection threw on alibaba/qwen-omni-turbo.

Not run: the scheduled workflow itself. schedule cannot fire from a pull
request, so it needs a workflow_dispatch run on main after merge. Its shape is
asserted by model-metadata-upkeep-workflow-policy.test.mjs, every run: block
passes bash -n, and the drift step's exit handling was exercised directly under
bash -e for statuses 0, 2 and 1 (continue, continue, fail).

One flaky test noticed while verifying, unrelated to this branch

runtime-host's production Host publishes and retires an implementation child patch is timing-sensitive: it fails at --test-concurrency=4 (3.1s) and passes
at 1 or 2 (1.1s). Not touched here.

AI use

Select exactly one:

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

Tool(s) and scope: Claude Code widened the modality set and gave it an owner,
wrote the drift check, the scheduled workflow and its policy test, performed the
docsUrl removal and ran the snapshot refresh, and drafted the commit messages
and this description. Every commit carries a Generated-by: Claude Code trailer.
All verification above was executed, not predicted.

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 force-pushed the ci/4398-model-metadata-drift-check branch from 9e909fa to ddaa0c7 Compare September 1, 2026 10:34
@github-actions github-actions Bot added the effort/L Under 1000 readable lines label Sep 1, 2026
@Astro-Han
Astro-Han force-pushed the ci/4398-model-metadata-drift-check branch from ddaa0c7 to 1a106c7 Compare September 1, 2026 11:12

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

Holding approval at head 1a106c7bfa1b3c911daf760b2ba7a49a5c0b0803: code review clean, but the PR is a draft and CI isn't fully green at this head (heavy queued, package/addon in progress). No P0/P1 found.

ci: schedule model metadata upkeep and check the snapshot against upstream (part 3 of #4398). Verified sound:

  • Protocol epoch correct. RUNTIME_HOST_COMPATIBILITY_EPOCH = 88, one above live main (87), for the catalog-modality expansion. No collision.
  • Video/pdf modalities are a correctness fix. modalities.output previously typed only text/image/audio, so a video-only model's real output had no representation and its empty list passed the chat guard as "no evidence"; now video is representable and a video-only model correctly fails that guard (regression test pins ok:false). Encode/decode are a true superset (both directions accept text|image|audio|pdf|video; pdf previously input-only), so no persisted connection breaks.
  • Wire/decoder is stricter in the safe direction. A modality value that reaches the projection but not the wire type fails the build; a value the projection can't carry drops the whole refresh rather than a single model.
  • Upkeep is guarded end-to-end. --drift reports without writing and can't combine with refresh/check; --accept-upstream-removals is capped at MAXIMUM_ACKNOWLEDGED_REMOVALS = 100 — a larger removal batch is treated as an upstream outage and fails; upstream reads track an ETag/retrievedAt so drift is observable; and the weekly workflow (cron + manual dispatch) only opens a draft PR, never auto-merges.
  • docsUrl removal is genuine dead-field cleanup (consistent with the earlier catalog-entry slimming in the same series).

Holding only on the two gate facts (not code): still a draft and exact-head CI not yet green. Bring it out of draft and get heavy/package green here, and I'll approve.

@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 1a106c7bfa1b3c911daf760b2ba7a49a5c0b0803. No P0, P1, P2, or P3.

The snapshot had no scheduled refresh and no comparison against models.dev, so a green tree could sit weeks behind upstream. This adds the missing upkeep and then uses it.

Video and pdf modalities now exist on both sides of the catalog decoder, the model-facts guard, and the projector. An unknown value still fails closed instead of dropping one field. That is a wire break, and the epoch moves 87 → 88 against current main 87, which is the right bump. A model that only answers in video is no longer invisible to the chat-default guard; an empty output list is still not treated as evidence.

--drift reports model by model, including shapes the projector rejects, and cannot combine with a write. --accept-upstream-removals still refuses a batch larger than 100. The weekly job never passes that flag, never triggers on a pull request, and only opens a draft. deepseek-chat and deepseek-reasoner stay in the DeepSeek fallback list and in builtin pricing after the 31 retired snapshot rows go. docsUrl has no remaining reader.

This PR is still a draft. Hosted package was green; heavy was still queued when I posted. I am not merging it.

简体中文

我审的是 1a106c7bfa1b3c911daf760b2ba7a49a5c0b0803。没有 P0/P1/P2/P3。

快照以前没有定时刷新,也不对 models.dev。video/pdf 进了解码器和投影器,未知值仍失败关闭。epoch 87→88,当前 main 是 87。只输出 video 的模型现在会被 chat 默认守卫拒绝。--drift 按模型报告,不能和写入一起跑。周任务不带 --accept-upstream-removals,只开 draft。deepseek-chat / reasoner 仍在 fallback 和定价里。docsUrl 没有读者了。还是 draft,我不合入。


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.

Reviewed exact head 1a106c7bfa1b3c911daf760b2ba7a49a5c0b0803. I found no P0/P1 blockers and am approving at AstroHan’s direction; two non-blocking P2 findings are inline.\n\nThis change widens catalog modalities to include video/pdf, advances the Runtime Host compatibility epoch from current-main 87 to 88, removes the unused per-model docsUrl, adds bounded upstream-removal handling and a model-level drift command, refreshes the committed models.dev projection, and introduces a weekly draft-PR upkeep workflow.\n\nI reviewed the full diff, projection/codec paths, workflow permissions and branch behavior, removal accounting, current-main protocol history, existing reviews, and hosted checks. Clean npm ci, build:test, full typecheck, 78 focused planner/upkeep/snapshot tests, lint, format, ASF headers, diff check, and a clean current-main synthetic merge with build/typecheck and 81 focused tests passed. The hosted heavy job is still running and the PR remains draft, so this approval is a code-review result rather than a statement that the merge gate is complete. I did not execute the scheduled workflow on the canonical repository.\n\n> 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.

- name: Detect a snapshot change
id: change
run: |
if git diff --quiet -- scripts/model-metadata/models-dev-api.snapshot.json; then

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] Detect projection changes, not the refresh timestamp\n\nrefresh:model-metadata always writes a fresh origin.retrievedAt, while this step compares the entire snapshot file. With identical upstream bytes I ran two refreshes: projectionSha256 stayed identical but the snapshot bytes changed solely because retrievedAt advanced, so this condition reports changed=true and the weekly job commits/opens or force-updates a draft PR even when the build input has not changed. Gate on the semantic projection digest (and, if desired, response hash/ETag changes deliberately) so a no-op weekly refresh remains a no-op.

Comment thread scripts/sync-model-metadata.mjs Outdated

async function collectDrift(snapshot, refreshInputPath) {
const catalog = JSON.parse((await readUpstream(refreshInputPath)).text);
const previousMetadata = snapshot.projection.metadata;

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] Include provider facts and provider overrides in drift reporting\n\nThis comparison only reads the previous metadata/pricing and only recomputes toMetadata/toPricing. The refresh also writes providerFacts (id, name, api, doc) and per-model providerOverrides, so those can change while --drift says the snapshot matches. I changed an Anthropic provider name/doc and a model provider API in an otherwise identical fixture; the command returned drifted: false and printed “matches the committed snapshot,” although a refresh changed the projection. Compare those two projection sections as well, or derive drift from the same complete projection used by refresh.

models.dev now declares video on either side of a model and pdf as an
output. The projector named four input and three output values and threw
on anything else, so 324 of the 1906 models in the selected providers
took the whole refresh down with them: `npm run refresh:model-metadata`
could not write a byte, and the snapshot could not be regenerated at all.

Admit both values across the three places that name them — the wire type,
the model-facts guard, and the catalog decoder — so a refresh reaches the
models that carry them. Both directions now accept the same set, so the
two decoders collapse into one that takes the direction; keeping two
copies only lets one drift behind the catalog it decodes.

The decoder rejects any value it does not name, so a newer Host
describing such a model fails an older client's catalog decode outright
rather than losing one field. That is a compatibility break, hence the
epoch bump: the handshake keeps the pairing from forming.

The snapshot itself is untouched here. Its content refresh belongs to the
scheduled job's own review pull request, not to this change.

Generated-by: Claude Code
Nothing in packages or apps ever read `docsUrl`. It was a per-model copy
of the provider's `doc` field, which the generated provider facts already
carry once per provider, so every model row paid for a duplicate no
renderer asked for.

The snapshot stores the projected shape rather than the upstream bytes,
so the field cannot leave the type without leaving the snapshot in the
same change: 1871 keys removed and the projection digest recomputed. That
is a shape migration of a generated artifact, the same kind its own
`origin.kind` already records. A refresh run against today's upstream
produces exactly this shape, and `check:model-metadata` re-derives the
digest, so the migrated file is not taken on trust.

The snapshot's content vintage is unchanged; only the field is gone.

Generated-by: Claude Code
`check:model-metadata` proves the generated modules match the committed
snapshot. Nothing proved the snapshot still matches models.dev, so the
snapshot could sit weeks behind upstream with every check green — which
is exactly what happened: models.dev listed glm-5.3 from 2026-08-14 and
the snapshot regenerated on 2026-08-29 without it.

`--drift` fetches upstream and compares model by model, printing what
differs and exiting non-zero. Per model rather than per projection on
purpose: a refresh aborts on the first shape it cannot project, so one
surprising model would otherwise hide every other difference. A rejected
shape becomes its own bucket instead.

The mode never writes, so it is safe to run against a checkout that must
stay clean. It stays out of check:asf-source and check:release: both run
on every affected pull request, and neither should need models.dev to be
reachable.

Generated-by: Claude Code
The refresh refuses to drop a committed projection path, and
--accept-upstream-removals waives that so a person can acknowledge the
handful of models a provider retired. Nothing bounded the waiver: an
upstream serving a truncated catalog removes paths exactly the same way,
and the flag would wave a gutted snapshot straight into the build input.

Give the acknowledgement a ceiling. Removals past it are an outage, not a
catalog change, and no flag clears them.

This bounds the one hole rather than standing a second size check beside
the guard that already computes the removals.

The required-provider half of the sanity floor already exists: PROVIDERS
is that list, and a provider that is missing or has no models fails the
projection before any of this runs.

Generated-by: Claude Code
…uest

Nothing ran refresh:model-metadata on a schedule, so the snapshot moved
only when someone happened to think of it. Weekly, not nightly: the
snapshot is a build input a person reviews, so a daily cadence stacks
five near-identical pull requests against one week of upstream movement,
on runners the whole foundation shares.

The job reports drift, refreshes, verifies the regenerated outputs, and
opens a draft pull request for review. It never merges its own work and
never passes --accept-upstream-removals: a model leaving upstream is a
decision for a person, so the job fails and says so.

No pull_request trigger. The drift check needs models.dev to be
reachable, which is not a precondition for reviewing a change, and a new
workflow on every pull request costs a queue slot on shared runners. The
workflow and its policy test join the existing asf_source lane instead,
so ci.yml runs them through check:asf-source when either file moves.

Generated-by: Claude Code
The snapshot was projected from a models.dev response old enough to
predate glm-5.3, and the epoch bump two commits back declared a wire
contract for video and pdf modalities that no committed data used. Both
are the same gap: the machinery around the snapshot is worth nothing
until the snapshot itself is current.

Refreshed against a live models.dev response, which the origin now
records with its etag rather than the migration marker it carried since
the projection moved out of generated output. 1871 models become 1906,
and 324 of them declare the modalities the projector could not represent
until this branch.

Ran with --accept-upstream-removals for 31 models models.dev has retired.
The two that a reader will look for, deepseek-chat and deepseek-reasoner,
stay selectable: provider-registry lists them under fallbackModels and
builtin-pricing carries their rates, so only the bundled display metadata
goes.

133 models now declare an output modality without text. model-catalog
already refuses those as chat defaults and documented why it could not
see the video ones; it can now.

Three runtime assertions move with the catalog they read. MiniMax-M3's
wire output limit is 512k upstream, not 128k. Tencent Token Plan's hy3
now documents `none` and `high` where it listed low/medium/high, so the
exposed variants become off/high and `off` names the provider's own
no-reasoning value instead of dropping the knob — the same shape the
Vercel case in that file already asserts.

Generated-by: Claude Code
Five hand-written copies of `text | image | audio | pdf | video` existed: the
wire type, the model-facts overlay normalizer, the catalog decoder, the
projector's MODALITIES guard, and model-fetcher's knownOutputModalities. The
epoch-88 widening updated four and missed the fifth, so a live-fetched model
declaring `output: ["video"]` is still offered as a chat default while the
same model from bundled metadata is excluded.

The type owns the set now and every validator that admits a modality reads
it. The projector keeps a copy because it runs before packages/core is built;
the build is what keeps that one honest, since a value that reaches the
projection but not the type fails to compile.

Generated-by: Claude Code
The drift report re-implemented buildProjection's provider loop and then
hand-named the sections it compared, so it saw metadata and pricing and was
blind to providerFacts and providerOverrides. A renamed provider or a swapped
npm package reported "matches the committed snapshot" and then landed in the
snapshot on the next refresh anyway.

buildProjection now takes its failure policy as an argument: a refresh passes
none and aborts on the first bad shape, the report passes one and turns a bad
shape into its own finding instead of stopping the comparison. Both read the
projection's sections through one table, so a section added to the projection
is compared without editing the comparison.

MAXIMUM_ACKNOWLEDGED_REMOVALS goes with it. It counted projection paths, not
retired models -- this repository's own refresh spent 68 of its 100 on 31
retirements -- and the truncated catalog it guarded against is already caught
per provider, where buildProjection refuses a missing or empty models object.

Drift exits 2. Drift and a crash both reported 1, which left the one job that
has to tell them apart unable to.

Generated-by: Claude Code
…o open

The job refused --accept-upstream-removals, and the removal guard's
remediation is "inspect the upstream change and rerun with the flag" -- an
action only a person at a terminal can take. Upstream retires roughly seven
projection paths a day, so a weekly run failed at the refresh and never
reached the step that opens the pull request. A snapshot one day old was
already enough: one retired openrouter model.

The review seat is the draft pull request. Every removal is in its diff, the
drift report is in its body, and a committer still approves before anything
merges; the flag says that is where the acknowledgement happens.

Three further repairs to the same step:

- `gh pr view` succeeds for a CLOSED pull request. A maintainer closing an
  unwanted refresh would have left every later run force-pushing, printing
  "updated the open pull request", creating nothing, and exiting green. Only
  an open-state listing decides now, and the body is rewritten on update so
  it cannot describe an older commit.
- The force push is leased, and the job refuses a branch tip it did not
  write, so a reviewer's commit on the open pull request survives.
- The token travels in a header rather than the remote URL, which git echoes
  back in its own error messages.

The drift step no longer swallows failure. Piping to tee discarded the exit
status under the default shell, which made continue-on-error dead
configuration; the step now tolerates the documented drift status and stops
the job on anything else.

Generated-by: Claude Code
@Astro-Han
Astro-Han force-pushed the ci/4398-model-metadata-drift-check branch from 03f2993 to b9e9bbb Compare September 1, 2026 14:21
@Astro-Han
Astro-Han marked this pull request as ready for review September 1, 2026 15:33

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

Approve at head b9e9bbb7ee (PR is out of draft and CI is green at this head: testpackageauditlabel✅ owner✅ — the head equals the one my code review was on). No P0/P1.

My prior review conclusion holds: the video/pdf modality representation is a correctness fix (a video-only model's output now fails the chat guard instead of slipping through an unrepresentable empty list; encode/decode are a true superset so no stored connection breaks); the weekly model-metadata upkeep is safety-railed end-to-end (report-only --drift, --accept-upstream-removals capped at 100 = upstream-outage guard, ETag/retrievedAt drift observability, and the upkeep workflow only opens a draft PR, never auto-merges); the docsUrl removal is genuine dead-field cleanup; and the protocol epoch is one above live main (88 vs 87).

The refinements since my first review only harden it: 104d2e5a gives the modality set one owner (the build keeps projection/type honest), aa30358f separates drift (exit 2) from crash (exit 1) so the upkeep job can tell them apart, and 03f29933 fixes a real automation bug (a tee pipeline was discarding the drift exit status, making continue-on-error dead config — now the job tolerates the documented drift status and stops on anything else).

Approving on code + green CI at the exact head.

@Astro-Han
Astro-Han merged commit 33e2bd6 into main Sep 1, 2026
6 checks passed
@Astro-Han
Astro-Han deleted the ci/4398-model-metadata-drift-check branch September 1, 2026 16:26
Astro-Han added a commit that referenced this pull request Sep 1, 2026
#4460 added this lane and #4483 banned `ubuntu-latest`, 35 minutes apart.
Both were green when they ran; main is red now that they sit together,
which blocks every merge because `test` is the only required context.

One-line fix, and exactly the drift the rule exists to catch: `ubuntu-latest`
is what every tutorial writes, so it comes back one new workflow at a time.
The rule caught it on the first one.

Refs #4480
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/L Under 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants