diff --git a/docs/design/2026-08-23-models-dev-registry.md b/docs/design/2026-08-23-models-dev-registry.md index 02c119cf4a0..b9f543b1b78 100644 --- a/docs/design/2026-08-23-models-dev-registry.md +++ b/docs/design/2026-08-23-models-dev-registry.md @@ -30,7 +30,7 @@ Only the supported first-party provider allowlist feeds the default catalog's to Model identifiers use the existing normalization rules. A projected key must be a fixed point of the normalizer and must not be a numeric tag, an `@` alias, a generic router label, or an otherwise ambiguous short key. The same rule filters caches written by older versions. All token limits that normalize to one key must agree, including dated and provider-qualified variants. If any disagree, omit the limits and preserve the existing regex/default behavior. A bare identifier must not erase conflicting endpoint evidence. This is deliberately conservative: it does not certify every possible private gateway or correct endpoint-specific limitations already present in the regex tables. -Each entry is additionally committed under the other spelling of its version, because models.dev publishes one spelling per provider while vendors and the proxies in front of them accept either (`qwen2-5-72b-instruct` and `qwen2.5-72b-instruct` are one model). This extends the agreement rule above rather than relaxing it: the two spellings normalize to different keys, so they are not limits disagreeing under one key. An alias is skipped when the projection already considered that key — whether it wrote it or dropped it because its allowlisted providers disagreed on limits — when the alias is not itself a normalization fixed point, or when two spellings claim one alias with different values. Only a digit run short enough to be a minor version is respelled, and a key ending in a release date is never respelled at all, so a size suffix and a release date get no alias. Key-specific adjustments — the client-owned context corrections, the curated output pins in `tokenLimits.ts`, and the DashScope pdf carve-out — must walk both spellings, or the twin keeps serving the unadjusted numbers. +Each entry is additionally committed under the other spelling of its version, because models.dev publishes one spelling per provider while vendors and the proxies in front of them accept either (`qwen2-5-72b-instruct` and `qwen2.5-72b-instruct` are one model). This extends the agreement rule above rather than relaxing it: the two spellings normalize to different keys, so they are not limits disagreeing under one key. An alias is skipped when the projection already considered that key — whether it wrote it or dropped it because its allowlisted providers disagreed on limits — when the alias is not itself a normalization fixed point, or when two spellings claim one alias with different values. Only a digit run short enough to be a minor version is respelled. It must be followed by a separator, the end of the id, or a trailing vision `v` followed by a separator or the end (`glm-4.5v` and `glm-4-5v`). Other attached letters, such as the `b` in a model size, are not accepted. A key ending in a release date is never respelled at all, so a size suffix and a release date get no alias. Key-specific adjustments — the client-owned context corrections, the curated output pins in `tokenLimits.ts`, and the DashScope pdf carve-out — must walk both spellings, or the twin keeps serving the unadjusted numbers. Provider-aware lookup would require changing the model-resolution contract and its callers. It is deferred rather than approximated by first-provider precedence. Regeneration and runtime refresh use the same projection. @@ -38,7 +38,7 @@ The draft-only `model.customCatalog` option was removed: it loaded too late for ## Storage and refresh -A trimmed JSON snapshot ships with the CLI and has a 200 KiB generation budget. The full upstream download has a separate 16 MiB limit. Refresh starts in the background after proxy initialization, uses a ten-second timeout and a 24-hour cache interval, revalidates with ETag, and atomically writes the cache under `Storage.getGlobalQwenDir()`. Cache reads and ETag reuse require the current projection version and a reachable model key. Older or unstamped caches require a full download and re-projection; bump `MODEL_CATALOG_PROJECTION_VERSION` when projection rules change. Projection 2 is the dual-spelling keying above, so a projection-1 cache must not keep winning over the bundled snapshot and silently drop the aliases. Concurrent refreshes share an in-flight request. Failure leaves the previous data usable and is logged only at debug level. +A trimmed JSON snapshot ships with the CLI and has a 200 KiB generation budget. The full upstream download has a separate 16 MiB limit. Refresh starts in the background after proxy initialization, uses a ten-second timeout and a 24-hour cache interval, revalidates with ETag, and atomically writes the cache under `Storage.getGlobalQwenDir()`. Cache reads and ETag reuse require the current projection version and a reachable model key. Older or unstamped caches require a full download and re-projection; bump `MODEL_CATALOG_PROJECTION_VERSION` when projection rules change. Projection 2 introduced dual-spelling keying; projection 3 also accepts the trailing vision `v`. Older caches cannot displace the bundled snapshot or reuse their ETag, and the next refresh downloads the full payload. The projection-3 bundle extends the committed projection-2 snapshot with three GLM vision aliases while preserving all existing records and the upstream timestamp; no live regeneration or unrelated metadata update is needed for this fix. Concurrent refreshes share an in-flight request. Failure leaves the previous data usable and is logged only at debug level. `QWEN_CODE_MODELS_DEV=off` restores regex-only behavior. `QWEN_CODE_MODELS_DEV_REFRESH=off` disables upstream refresh. `QWEN_CODE_MODELS_DEV_URL` selects a mirror with the same provider filtering. No request-time network lookup, cross-process lock, scheduled regeneration service, or new dependency is introduced. diff --git a/docs/design/2026-08-23-models-dev-registry.zh-CN.md b/docs/design/2026-08-23-models-dev-registry.zh-CN.md index 121d4437c17..7e027c9598b 100644 --- a/docs/design/2026-08-23-models-dev-registry.zh-CN.md +++ b/docs/design/2026-08-23-models-dev-registry.zh-CN.md @@ -30,7 +30,7 @@ preset 或显式配置的上下文窗口仍以该配置为准。自动检测使 模型标识使用现有归一化规则。投影键必须是归一化不动点,且不能是纯数字标签、带 `@` 的别名、通用路由标签或其他有歧义的短键;读取旧版本缓存时也应用同一规则。归一化到同一个 key 的所有 token 上限必须一致,包括带日期和 provider 前缀的变体。只要存在分歧,就丢弃这些上限,保持现有正则或默认行为。裸模型名不能抹掉不同 endpoint 的冲突证据。这是保守策略:它不保证任意私有网关都适用,也不修正正则表已有的 endpoint 限制问题。 -每个 entry 还会以其版本的另一种拼写提交,因为 models.dev 每个 provider 只发布一种拼写,而厂商及其前面的代理两种都接受(`qwen2-5-72b-instruct` 与 `qwen2.5-72b-instruct` 是同一个模型)。这是对上一条一致性规则的延伸而非放宽:两种拼写归一化到不同的 key,因此不属于同一个 key 下的上限分歧。以下情形跳过别名:投影考虑过该 key(无论最终写入,还是因白名单 provider 对上限存在分歧而丢弃)、别名本身不是归一化不动点、或两种拼写以不同值争用同一个别名。只有短到可以充当次版本号的数字段才会被改写,且以发布日期结尾的 id 一律不改写,因此尺寸后缀和发布日期都不会产生别名。按具体 id 生效的调整——客户端维护的上下文纠正、`tokenLimits.ts` 中人工维护的输出上限钉值与 DashScope 的 pdf 特例——必须同时走过两种拼写,否则孪生 key 仍会返回未纠正的数值。 +每个 entry 还会以其版本的另一种拼写提交,因为 models.dev 每个 provider 只发布一种拼写,而厂商及其前面的代理两种都接受(`qwen2-5-72b-instruct` 与 `qwen2.5-72b-instruct` 是同一个模型)。这是对上一条一致性规则的延伸而非放宽:两种拼写归一化到不同的 key,因此不属于同一个 key 下的上限分歧。以下情形跳过别名:投影考虑过该 key(无论最终写入,还是因白名单 provider 对上限存在分歧而丢弃)、别名本身不是归一化不动点、或两种拼写以不同值争用同一个别名。只有短到可以充当次版本号的数字段才会被改写,数字段后必须是分隔符、id 结尾,或后面紧跟分隔符或结尾的视觉变体 `v`(例如 `glm-4.5v` 与 `glm-4-5v`)。不接受其他粘连字母,例如模型尺寸中的 `b`。以发布日期结尾的 id 一律不改写,因此尺寸后缀和发布日期都不会产生别名。按具体 id 生效的调整——客户端维护的上下文纠正、`tokenLimits.ts` 中人工维护的输出上限钉值与 DashScope 的 pdf 特例——必须同时走过两种拼写,否则孪生 key 仍会返回未纠正的数值。 按 provider 查询需要修改模型解析契约及其调用方,留待后续,不用“第一个 provider 优先”近似实现。快照生成与运行时刷新使用同一套投影。 @@ -38,7 +38,7 @@ preset 或显式配置的上下文窗口仍以该配置为准。自动检测使 ## 存储与刷新 -裁剪后的 JSON 快照随 CLI 发布,生成体积预算为 200 KiB;完整上游响应使用独立的 16 MiB 下载上限。后台刷新在代理初始化之后启动,超时为十秒,缓存间隔为 24 小时,通过 ETag 条件请求重新验证,在 `Storage.getGlobalQwenDir()` 下原子写入缓存。读取缓存与复用 ETag 均要求投影版本一致且存在可达的模型标识;旧版本或未标版本的缓存须完整下载并重新投影。调整投影规则时递增 `MODEL_CATALOG_PROJECTION_VERSION`。投影 2 即上述双拼写建键规则,因此投影 1 的缓存不能继续压过内置快照并悄悄丢掉别名。并发刷新共享进行中的请求。失败保留原有可用数据,仅记录 debug 日志。 +裁剪后的 JSON 快照随 CLI 发布,生成体积预算为 200 KiB;完整上游响应使用独立的 16 MiB 下载上限。后台刷新在代理初始化之后启动,超时为十秒,缓存间隔为 24 小时,通过 ETag 条件请求重新验证,在 `Storage.getGlobalQwenDir()` 下原子写入缓存。读取缓存与复用 ETag 均要求投影版本一致且存在可达的模型标识;旧版本或未标版本的缓存须完整下载并重新投影。调整投影规则时递增 `MODEL_CATALOG_PROJECTION_VERSION`。投影 2 引入双拼写建键,投影 3 还接受尾随的视觉变体 `v`。旧缓存不能压过内置快照,也不能复用其 ETag;下次刷新须完整下载上游响应。投影 3 的内置快照只在已提交的投影 2 快照上补充三个 GLM 视觉别名,保留全部既有记录及上游时间戳;本次修复不需要实时重新生成或更新无关模型数据。并发刷新共享进行中的请求。失败保留原有可用数据,仅记录 debug 日志。 `QWEN_CODE_MODELS_DEV=off` 恢复仅使用正则表。`QWEN_CODE_MODELS_DEV_REFRESH=off` 禁止刷新上游。`QWEN_CODE_MODELS_DEV_URL` 选择镜像,仍使用相同 provider 过滤。本次不引入请求时联网、跨进程锁、定时生成服务或新依赖。 diff --git a/docs/verification/models-dev-catalog/README.md b/docs/verification/models-dev-catalog/README.md index 62cfb38a03d..7af84a0bc9d 100644 --- a/docs/verification/models-dev-catalog/README.md +++ b/docs/verification/models-dev-catalog/README.md @@ -49,7 +49,7 @@ For a real lifecycle check, resolve qwen3.8-max with an explicit context and mod ## Current-head evidence and explicit output budgets -The snapshot regenerated with the projection shipped on 2026-09-30 contained 203 entries (27,711 bytes), including modality-only entries such as `inkling`; the artifact this branch ships is the projection-2 one recorded under "Dual-spelling projection (2026-10-03)" below. The 2026-09-30 regeneration also dropped `modelscope` from the limits voter list (a pure mirror whose hosted numbers vetoed first-party GLM and supplied dated `-2507` qwen3 snapshots under bare ids), which restored `glm-4.6` to its first-party Z.ai figures. Catalog context windows below 32,000 are omitted so the 85% auto-compaction threshold still leaves room for the 4,000-token minimum output request; those models retain the existing family-table fallback. DeepSeek modality claims are accepted only for explicit `vision` variants. The earlier 194/195/205-entry measurements above describe historical payloads before the reachability, trust, and agent-window guards. Public [round-2 verification](https://github.com/QwenLM/qwen-code/pull/11959#issuecomment-5851908713) and its [scripts and raw results](https://github.com/wenshao/qwen-code/tree/assets-pr11959/pr11959/round2) cover the real CLI, endpoint benefits, cache recovery and project environment isolation at `9088f00b`. +The snapshot regenerated with the projection shipped on 2026-09-30 contained 203 entries (27,711 bytes), including modality-only entries such as `inkling`; the current artifact extends that projection-2 snapshot as recorded under "Vision variant aliases (2026-10-08)" below. The 2026-09-30 regeneration also dropped `modelscope` from the limits voter list (a pure mirror whose hosted numbers vetoed first-party GLM and supplied dated `-2507` qwen3 snapshots under bare ids), which restored `glm-4.6` to its first-party Z.ai figures. Catalog context windows below 32,000 are omitted so the 85% auto-compaction threshold still leaves room for the 4,000-token minimum output request; those models retain the existing family-table fallback. DeepSeek modality claims are accepted only for explicit `vision` variants. The earlier 194/195/205-entry measurements above describe historical payloads before the reachability, trust, and agent-window guards. Public [round-2 verification](https://github.com/QwenLM/qwen-code/pull/11959#issuecomment-5851908713) and its [scripts and raw results](https://github.com/wenshao/qwen-code/tree/assets-pr11959/pr11959/round2) cover the real CLI, endpoint benefits, cache recovery and project environment isolation at `9088f00b`. The remaining explicit-budget regression is corrected: catalog-only output limits provide defaults without clipping explicit request or environment budgets. Existing curated output caps and context-window clamping remain. Real bundled-data provider tests cover QwQ-32B, Kimi-K2-Thinking and QVQ-Max defaults and explicit 32,768 requests, plus the unchanged GLM-4.7 cap. An explicitly excessive budget can still be rejected by its backend, as before this PR. @@ -72,3 +72,13 @@ PR #13299 raised `MODEL_CATALOG_PROJECTION_VERSION` to 2 — every entry is addi Measured against merge base `2a6879e6` (203 keys, projection 1, `fetchedAt` `2026-09-30T18:38:13.405Z`): 93 keys added, 0 removed, 0 changed. Each added key is the other spelling of an existing version and carries a byte-identical record, so the regeneration added aliases and nothing else. Three of them — `kimi-k2-5`, `kimi-k2-6`, `qwen3-7-plus` — are modality-only, because their allowlisted providers disagreed on limits and the disagreement veto dropped them. The projection-1-cache rejection the version-2 bump depends on needed no new evidence: `does not revalidate a mismatched projection on 304` in `packages/core/src/models/model-catalog-refresh.test.ts` is an `it.each` over `undefined`, `0`, `MODEL_CATALOG_PROJECTION_VERSION - 1` and `+ 1`, so at version 2 its third arm is projection 1, and `reprojects an old cache without sending its ETag` covers the re-projection path. The mismatch arms are written against the constant, so they keep covering the previous version after the next bump; the re-projection case pins `projection: 0` instead — an arbitrary stale version, not the immediately previous one. + +## Vision variant aliases (2026-10-08) + +Issue #13414 remained reproducible on main `fe4d4e345c`: the real CLI reported a 64,000-token context for `glm-4.5v` and 202,752 for `glm-4-5v`. An identical valid PNG attachment reached the loopback model endpoint as one image for the dotted id and zero images for the dashed id, with output requests of 16,384 and 32,000 respectively. These are client-side configuration and delivery observations, not live vendor capacity or recognition tests. + +Projection 3 accepts the trailing vision `v` in both directions and adds `glm-4-5v`, `glm-4-6v` and `glm-4-6v-flash` to the bundle. The committed projection-2 snapshot is the pinned input (SHA-256 `d0d0f813da0ceeccb4a05c78fe165bb10e4c4c00b3903c2522abcb46997bd0ee`): 296 → 299 keys, three additions, no removed keys and no changed existing records. Each addition equals its dotted source. The source URL and `fetchedAt` remain unchanged because no new upstream facts were fetched. The indented artifact is 41,555 bytes, within the existing budget. + +Upgrading rejects projection-2 caches and their ETags using the existing compatibility checks. A successful full fetch writes projection 3; an offline start retains the bundled metadata, and an already running process keeps its selected data. The same shared alias rule serves runtime refresh. Date and size suffix exclusions remain in force, and the previously added limit-less alias regression from #13539 remains covered. + +The two catalog suites gain five cases that fail on the previous implementation: three exercise bundled vision aliases through token limits and modality defaults, and two exercise fresh projection in both directions. The existing mismatched-projection cases now cover projection 2, so stale caches cannot hide the new bundled aliases. diff --git a/packages/core/src/models/generated/model-registry.json b/packages/core/src/models/generated/model-registry.json index d6d87c3b9bf..bc6dc86d36c 100644 --- a/packages/core/src/models/generated/model-registry.json +++ b/packages/core/src/models/generated/model-registry.json @@ -1,7 +1,7 @@ { "source": "https://models.dev/api.json", "fetchedAt": "2026-10-03T13:57:42.041Z", - "projection": 2, + "projection": 3, "models": { "claude-fable-5": { "context": 1000000, @@ -675,11 +675,35 @@ "output": 98304, "modalities": {} }, + "glm-4-5v": { + "context": 64000, + "output": 16384, + "modalities": { + "image": true, + "video": true + } + }, "glm-4-6": { "context": 204800, "output": 131072, "modalities": {} }, + "glm-4-6v": { + "context": 128000, + "output": 32768, + "modalities": { + "image": true, + "video": true + } + }, + "glm-4-6v-flash": { + "context": 128000, + "output": 32768, + "modalities": { + "image": true, + "video": true + } + }, "glm-4-7": { "context": 204800, "output": 131072, diff --git a/packages/core/src/models/model-catalog-refresh.test.ts b/packages/core/src/models/model-catalog-refresh.test.ts index 9ca01d47750..90e0e02dec4 100644 --- a/packages/core/src/models/model-catalog-refresh.test.ts +++ b/packages/core/src/models/model-catalog-refresh.test.ts @@ -388,6 +388,33 @@ describe('trimModelsDevCatalog', () => { expect(models['glm-5-3-flash']).toEqual(models['glm-5.3-flash']); }); + it.each(['glm-4.5v', 'glm-4-5v'])( + 'projects both spellings of the vision model %s', + (id) => { + const entry = { + context: 64_000, + output: 16_384, + modalities: { image: true, video: true }, + }; + const models = trimModelsDevCatalog( + { + zai: { + models: { + [id]: chat(id, { context: 64_000, output: 16_384 }, [ + 'text', + 'image', + 'video', + ]), + }, + }, + }, + NOW, + ).models; + expect(models['glm-4.5v']).toEqual(entry); + expect(models['glm-4-5v']).toEqual(entry); + }, + ); + it('never aliases over a key the projection committed itself', () => { // Both spellings are real keys with different endpoint limits here, so // each keeps its own numbers instead of one aliasing over the other. diff --git a/packages/core/src/models/model-catalog.test.ts b/packages/core/src/models/model-catalog.test.ts index 8026963fba9..edacf285f8b 100644 --- a/packages/core/src/models/model-catalog.test.ts +++ b/packages/core/src/models/model-catalog.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { Config } from '../config/config.js'; import { DefaultOpenAICompatibleProvider } from '../core/openaiContentGenerator/provider/default.js'; import { AuthType } from '../core/contentGenerator.js'; +import { defaultModalities } from '../core/modalityDefaults.js'; import { clampOutputTokensToWindow, defaultOutputCeiling, @@ -618,6 +619,22 @@ describe('model catalog', () => { expect(lookupModelCatalog('glm-5.3-flash')?.modalities?.image).toBe(true); }); + it.each([ + ['glm-4.5v', 'glm-4-5v', 64_000, 16_384], + ['glm-4.6v', 'glm-4-6v', 128_000, 32_768], + ['glm-4.6v-flash', 'glm-4-6v-flash', 128_000, 32_768], + ] as const)( + 'preserves vision limits and modalities under the alias of %s', + (dotted, dashed, context, output) => { + expect(versionSpellingAlias(dotted)).toBe(dashed); + expect(versionSpellingAlias(dashed)).toBe(dotted); + expect(lookupModelCatalog(dashed)).toEqual(lookupModelCatalog(dotted)); + expect(tokenLimit(dashed)).toBe(context); + expect(tokenLimit(dashed, 'output')).toBe(output); + expect(defaultModalities(dashed)).toEqual({ image: true, video: true }); + }, + ); + it('keeps a release date off the alias machinery', () => { // models.dev publishes dated ids whose last dash-then-digits boundary is // the release date, not a minor version. Respelling it commits a spelling @@ -638,6 +655,8 @@ describe('model catalog', () => { // alone reads it as a minor version. Three committed gpt-4o ids carry one. expect(versionSpellingAlias('gpt-4o-2024-11-20')).toBeUndefined(); expect(versionSpellingAlias('gpt-4.1-2025-04-14')).toBeUndefined(); + expect(versionSpellingAlias('gemma-4-26b-a4b-it')).toBeUndefined(); + expect(versionSpellingAlias('gemma-4.26b-a4b-it')).toBeUndefined(); // The guard stays narrow enough to keep respelling real minor versions, // including a leading-zero one (`0` is a date digit but not a date run). expect(versionSpellingAlias('qwen2-5-72b-instruct')).toBe( diff --git a/packages/core/src/models/model-catalog.ts b/packages/core/src/models/model-catalog.ts index d111d21d2e7..58b34f55f08 100644 --- a/packages/core/src/models/model-catalog.ts +++ b/packages/core/src/models/model-catalog.ts @@ -32,7 +32,8 @@ export interface ModelCatalog { // 2: entries are also committed under the other spelling of their version // (`qwen2-5-72b-instruct` <-> `qwen2.5-72b-instruct`), so a projection-1 // cache must not keep winning and silently drop the aliases (#13209). -export const MODEL_CATALOG_PROJECTION_VERSION = 2; +// 3: minor versions with a trailing vision `v` receive aliases too (#13414). +export const MODEL_CATALOG_PROJECTION_VERSION = 3; /** `QWEN_CODE_MODELS_DEV=off` restores the regex-only model tables. */ export const MODEL_CATALOG_ENV = 'QWEN_CODE_MODELS_DEV'; @@ -212,7 +213,7 @@ export function versionSpellingAlias(key: string): string | undefined { if (/-\d{4}-\d{1,2}-\d{1,2}$/.test(key)) { return undefined; } - const dotted = /^(.*\d)-(\d+)(?=-|$)/.exec(key); + const dotted = /^(.*\d)-(\d+)(?=v?(?:-|$))/.exec(key); if (dotted) { // A date run returns here instead of falling through to the dashed branch // below: that branch respells the *other* boundary of the same id, so @@ -223,7 +224,7 @@ export function versionSpellingAlias(key: string): string | undefined { ? `${dotted[1]}.${dotted[2]}${key.slice(dotted[0].length)}` : undefined; } - const dashed = /^(.*\d)\.(\d+)(?=-|$)/.exec(key); + const dashed = /^(.*\d)\.(\d+)(?=v?(?:-|$))/.exec(key); return dashed && isMinorVersionRun(dashed[2]) ? `${dashed[1]}-${dashed[2]}${key.slice(dashed[0].length)}` : undefined;