diff --git a/src/providers/model-rename-startup.ts b/src/providers/model-rename-startup.ts index 0f15b695cd..f846b6b9ac 100644 --- a/src/providers/model-rename-startup.ts +++ b/src/providers/model-rename-startup.ts @@ -1,13 +1,12 @@ import { mutatePersistedConfig } from "../config"; import { projectModelRenames } from "./model-rename-migration"; import { projectStaleContextWindows } from "./stale-context-window-migration"; -import { projectStaleVisionClassifications } from "./stale-vision-classification-migration"; import { projectDevinCliAuthMode } from "./devin-cli-authmode-migration"; import type { OcxConfig } from "../types"; /** - * The startup projection: registry model renames, then the context-window and - * vision-classification repairs. All three fix a saved row the registry can no longer reach on its own — + * The startup projection: registry model renames, then context-window and auth-mode repairs. + * These fix a saved row the registry can no longer reach on its own — * `enrichProviderFromRegistry` backfills a missing field and never rewrites a * present one — so they share this pass rather than adding a second boot step * with its own persistence, adopt, and failure handling. @@ -15,12 +14,11 @@ import type { OcxConfig } from "../types"; export function projectStartupConfigRepairs(config: OcxConfig): ReturnType { const renames = projectModelRenames(config); const windows = projectStaleContextWindows(renames.config); - const vision = projectStaleVisionClassifications(windows.config); - const devinCli = projectDevinCliAuthMode(vision.config); + const devinCli = projectDevinCliAuthMode(windows.config); return { config: devinCli.config, - changed: renames.changed || windows.changed || vision.changed || devinCli.changed, - warnings: [...renames.warnings, ...windows.warnings, ...vision.warnings, ...devinCli.warnings], + changed: renames.changed || windows.changed || devinCli.changed, + warnings: [...renames.warnings, ...windows.warnings, ...devinCli.warnings], }; } diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index f16f9f1568..40b54037f0 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -718,12 +718,9 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // HTTP 400 "Model only supports text input", which is what keeps the two // distinct here rather than collapsing them. // - // The declaration is what reaches an EXISTING install: derive.ts fills - // noVisionModels all-or-nothing, so a config persisted while the stale list - // was current keeps it forever, and modelInputModalities is filled per-key - // BENEATH the saved value. Both halves are repaired by - // stale-vision-classification-migration.ts; correcting the registry alone - // would fix new installs and leave existing ones stripping images. + // derive.ts fills missing registry metadata but does not replace saved values. + // That makes this declaration the default for new rows while preserving an + // existing install's operator-editable image-routing restrictions. "deepseek-v4.1-flash": ["text", "image"], // Muse Spark Contributor is natively multimodal on Zen Go: it accepts input_image // parts over /responses (probed 2026-08-26). Without this declaration the catalog @@ -1258,4 +1255,3 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ note: "Serverless Inference subscription API. Live discovery exposes only kimi-k2-instruct because Vultr documents it as the sole tool-calling model.", }, ]; - diff --git a/src/providers/stale-vision-classification-migration.ts b/src/providers/stale-vision-classification-migration.ts deleted file mode 100644 index e27cb18626..0000000000 --- a/src/providers/stale-vision-classification-migration.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Repair vision classifications a saved config inherited from a stale registry seed. - * - * `enrichProviderFromRegistry` is fill-only and asymmetric, which is right for a hand-tuned - * value but freezes a wrong one: - * - * - `noVisionModels` is filled ALL-OR-NOTHING (`if (!prov.noVisionModels && seed.noVisionModels)`), - * so a config saved while the list was current keeps that list forever. - * - `modelInputModalities` is filled per-key BENEATH the saved value, so a saved `["text"]` for - * one id survives every later registry correction. - * - * Correcting the registry therefore fixes new installs only. This projection is the other half. - * It repairs the two saved values a stale seed left behind, in both states that reach a running - * process: - * - * - the full pair: modalities still exactly the stale declaration, and the id in the list. Both - * are rewritten. - * - the half-repaired row: modalities already corrected but the id still in the list. The - * sidecar predicate reads `noVisionModels` BEFORE the modality list, so that row keeps - * stripping images until the name goes as well; this projection removes it and leaves the - * modalities untouched. - * - * The paired modalities value is the guard in both states, which is also why it has to be - * readable: a name in the list with no modality declaration beside it is ambiguous — either a - * half-finished repair or an entry the operator added on purpose — and this projection does not - * guess which. It leaves that row alone. - * - * Identity is the same rule enrichment uses, read from the same helper: the registry must still - * own the row (`providerMatchesRegistryTransport`, the check `enrichProviderFromRegistry` makes - * before it writes registry metadata) and the row must still be on the entry's adapter. - * `opencode-go` is an existing key preset without `preserveCustomDestination`, so the id alone - * claims a row — exactly as it does for enrichment — while an entry that opts into destination - * preservation narrows this projection for free. - * - * Nothing here writes `modelCapabilities`. That is the dedicated per-model axis, it outranks - * every source this file touches, and it is where a deliberate text-only override belongs - * (`ocx provider edit --model --text-only` writes it). - * - * Scope is deliberately one entry. The Go gateway's `deepseek-v4.1-flash` was declared text-only - * from jawcode metadata and was measured natively multimodal on 2026-09-19 (see the note at that - * entry in `registry/entries-core.ts`). Its sibling `deepseek-v4-flash` still rejects images - * upstream, and the sibling Zen tiers (`opencode-zen`, `opencode-free`) could not be probed at - * all — an unverified tier is not evidence, so neither is touched here. - */ -import { PROVIDER_REGISTRY, providerMatchesRegistryTransport } from "./registry"; -import type { OcxConfig } from "../types"; - -export interface StaleVisionClassification { - /** Registry provider id whose saved rows may carry the wrong classification. */ - provider: string; - model: string; - /** The stale saved modalities this migration is allowed to replace, and nothing else. */ - fromModalities: string[]; - toModalities: string[]; -} - -export interface StaleVisionClassificationProjection { - config: OcxConfig; - changed: boolean; - warnings: string[]; -} - -export const STALE_VISION_CLASSIFICATIONS: readonly StaleVisionClassification[] = [ - { - provider: "opencode-go", - model: "deepseek-v4.1-flash", - fromModalities: ["text"], - toModalities: ["text", "image"], - }, -]; - -/** - * Whether the registry still owns the row whose saved values would be rewritten. - * - * `providerMatchesRegistryTransport` is the rule enrichment applies, so this projection and the - * fill that put the stale values there answer identity the same way. Adapter equality stays as - * an additional tightening: a row moved onto another wire is not the row the seed described, - * however the registry entry is pinned. - */ -function providerStillMatchesRegistry(id: string, prov: OcxConfig["providers"][string]): boolean { - const entry = PROVIDER_REGISTRY.find(row => row.id === id); - if (entry === undefined || entry.adapter !== prov.adapter) return false; - return providerMatchesRegistryTransport(id, prov); -} - -function sameModalities(current: unknown, expected: readonly string[]): boolean { - return Array.isArray(current) - && current.length === expected.length - && current.every((value, index) => value === expected[index]); -} - -/** Pure projection. The caller decides whether to persist. */ -export function projectStaleVisionClassifications( - config: OcxConfig, - entries: readonly StaleVisionClassification[] = STALE_VISION_CLASSIFICATIONS, -): StaleVisionClassificationProjection { - const warnings: string[] = []; - const repaired = new Map(); - - for (const entry of entries) { - const prov = config.providers?.[entry.provider]; - if (!prov) continue; - if (!providerStillMatchesRegistry(entry.provider, prov)) continue; - const modalities = prov.modelInputModalities; - if (!modalities) continue; - // The paired modalities value is the guard, and it decides which of the two states above this - // row is in. Anything else is a declaration this file does not own. - const saved = modalities[entry.model]; - const stale = sameModalities(saved, entry.fromModalities); - const alreadyMigrated = sameModalities(saved, entry.toModalities); - if (!stale && !alreadyMigrated) continue; - const visionList = Array.isArray(prov.noVisionModels) ? prov.noVisionModels : undefined; - const listed = visionList !== undefined && visionList.includes(entry.model); - // Half-repaired rows have nothing left to do once the name is gone; the full pair is rewritten - // whether or not the name was ever listed, because `derive.ts` fills the two fields - // independently and a per-key modality fill can land without the all-or-nothing list. - if (!stale && !listed) continue; - if (stale) modalities[entry.model] = [...entry.toModalities]; - if (visionList !== undefined && listed) { - prov.noVisionModels = visionList.filter(id => id !== entry.model); - } - const repairedList = repaired.get(entry.provider) ?? []; - repairedList.push(stale - ? `${entry.model} ${entry.fromModalities.join("+")} -> ${entry.toModalities.join("+")}` - : `${entry.model} dropped from noVisionModels (modalities already ${entry.toModalities.join("+")})`); - repaired.set(entry.provider, repairedList); - } - - for (const [provider, list] of repaired) { - warnings.push( - `repaired the stale registry vision seed for ${list.length} model(s) on "${provider}": ` - + `${list.join(", ")}.`, - ); - } - - return { config, changed: repaired.size > 0, warnings }; -} diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index de68ef9f7a..ced78a9e32 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -78,27 +78,13 @@ answers HTTP 400 "Model only supports text input" and stays sidecar-backed. The (`opencode-zen`, `opencode-free`) were not measurable (HTTP 402) and keep their existing classification — an unverified tier is not evidence. -Because `enrichProviderFromRegistry` fills `noVisionModels` all-or-nothing and fills -`modelInputModalities` per-key beneath the saved value, both halves of a stale classification are -frozen into any config saved while it was current. `src/providers/stale-vision-classification-migration.ts` -repairs exactly those two saved values and runs inside the shared startup repair pass in -`src/providers/model-rename-startup.ts`. Correcting the registry alone fixes new installs only. - -It covers both states that reach a running process, because the sidecar predicate reads -`noVisionModels` before `modelInputModalities`: the full stale pair (modalities still the stale -declaration and the id listed, both rewritten) and the half-repaired row (modalities already -corrected but the id still listed, where removing the name is what stops the image from being -stripped). The paired modality declaration is the guard in both cases, which is why a name listed -without one is left alone — that row is either a half-finished repair or a deliberate operator -entry, and the projection does not guess which. The row must also still be the registry's own: -identity resolves through `providerMatchesRegistryTransport`, the rule `enrichProviderFromRegistry` -applies before it writes registry metadata, plus the entry's adapter. `opencode-go` is a pinned -key preset without `preserveCustomDestination`, so its id alone claims a row — exactly as it does -for enrichment — and an entry that opts into destination preservation narrows the projection with -it. `modelCapabilities` is never written: it is the -axis that outranks every source here, so it is where a deliberate text-only override belongs -(`ocx provider edit --model --text-only` writes it) and the one declaration a -restart cannot take back. +Because `enrichProviderFromRegistry` is fill-only, the corrected classification applies to new +rows and missing values. Startup does not rewrite an existing `noVisionModels` entry or +`modelInputModalities` value: those fields are operator-editable request-routing policy, and a +saved registry seed is indistinguishable from an intentional restriction without provenance. +Existing users can opt into the corrected native-vision classification by removing those saved +overrides. `modelCapabilities` remains the highest-precedence per-model axis, and +`ocx provider edit --model --text-only` writes an explicit restriction there. The BigModel Coding Plan Responses preset uses the separately documented `https://open.bigmodel.cn/api/v1` transport and a static catalog. Its provider row diff --git a/tests/providers/vision-classification-seed-repair.test.ts b/tests/providers/vision-classification-seed-repair.test.ts index 88cea682c9..642eeb2f35 100644 --- a/tests/providers/vision-classification-seed-repair.test.ts +++ b/tests/providers/vision-classification-seed-repair.test.ts @@ -1,158 +1,43 @@ -/** - * The saved-config half of the OpenCode Go DeepSeek reclassification. - * - * `enrichProviderFromRegistry` is fill-only and asymmetric: `noVisionModels` is filled - * all-or-nothing and `modelInputModalities` is filled per-key BENEATH the saved value. A config - * saved while the registry called `deepseek-v4.1-flash` text-only therefore keeps BOTH halves of - * that claim forever, and images are stripped for a route that reads them (probed 2026-09-19). - * Correcting the registry alone fixes new installs only. - */ import { describe, expect, test } from "bun:test"; -import { - projectStaleVisionClassifications, - STALE_VISION_CLASSIFICATIONS, -} from "../../src/providers/stale-vision-classification-migration"; -import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import { projectStartupConfigRepairs } from "../../src/providers/model-rename-startup"; import { requiresVisionPreprocessing } from "../../src/vision/plan"; import type { OcxConfig } from "../../src/types"; const MODEL = "deepseek-v4.1-flash"; -const SIBLING = "deepseek-v4-flash"; -/** A config saved while the stale seed was current. */ -function staleConfig( - modalities: Record = { [MODEL]: ["text"], [SIBLING]: ["text"] }, - noVisionModels: string[] = [MODEL, SIBLING], - adapter = "openai-chat", - baseUrl = "https://opencode.ai/zen/go/v1", -): OcxConfig { +function configuredPolicy(modalities: string[], noVisionModels: string[]): OcxConfig { return { providers: { - "opencode-go": { adapter, baseUrl, modelInputModalities: { ...modalities }, noVisionModels: [...noVisionModels] }, + "opencode-go": { + adapter: "openai-chat", + baseUrl: "https://opencode.ai/zen/go/v1", + modelInputModalities: { [MODEL]: modalities }, + noVisionModels, + }, }, - } as unknown as OcxConfig; + } as OcxConfig; } -describe("stale vision classification migration", () => { - test("repairs both halves of the stale claim", () => { - // Modalities alone would not be enough: the sidecar predicate checks noVisionModels FIRST and - // short-circuits, so a row left in that list stays text-only however it is declared. - const config = staleConfig(); - const projection = projectStaleVisionClassifications(config); - expect(projection.changed).toBe(true); - expect(projection.config.providers!["opencode-go"]!.modelInputModalities![MODEL]).toEqual(["text", "image"]); - expect(projection.config.providers!["opencode-go"]!.noVisionModels).not.toContain(MODEL); - expect(projection.warnings.join(" ")).toContain(MODEL); - }); - - test("leaves the sibling route classified text-only", () => { - // deepseek-v4-flash still answers HTTP 400 "Model only supports text input" on this gateway. - // A migration that widened the whole list would strip a real protection. - const projection = projectStaleVisionClassifications(staleConfig()); - expect(projection.config.providers!["opencode-go"]!.modelInputModalities![SIBLING]).toEqual(["text"]); - expect(projection.config.providers!["opencode-go"]!.noVisionModels).toContain(SIBLING); - }); - - test("leaves a modality value the operator chose alone", () => { - // The guard is an exact match on the stale declaration. Anything else is a deliberate - // override and outranks this migration. - const projection = projectStaleVisionClassifications( - staleConfig({ [MODEL]: ["text", "audio"], [SIBLING]: ["text"] }), - ); - expect(projection.changed).toBe(false); - expect(projection.config.providers!["opencode-go"]!.modelInputModalities![MODEL]).toEqual(["text", "audio"]); - }); - - test("finishes a half-repaired row whose name is still listed", () => { - // The sidecar predicate reads noVisionModels BEFORE the modality list, so a row whose - // modalities were already corrected but whose name is still listed keeps stripping images. - // Leaving it alone was the gap the maintainer review found on #5164. - const projection = projectStaleVisionClassifications( - staleConfig({ [MODEL]: ["text", "image"], [SIBLING]: ["text"] }), - ); - expect(projection.changed).toBe(true); - expect(projection.config.providers!["opencode-go"]!.modelInputModalities![MODEL]).toEqual(["text", "image"]); - expect(projection.config.providers!["opencode-go"]!.noVisionModels).not.toContain(MODEL); - // The sibling keeps both halves: its modalities are not the migrated value, so nothing fires. - expect(projection.config.providers!["opencode-go"]!.noVisionModels).toContain(SIBLING); - expect(projection.warnings.join(" ")).toContain("dropped from noVisionModels"); - }); - - test("leaves a listed name without a readable modality declaration alone", () => { - // Ambiguous on purpose: a half-finished repair and an entry the operator added by hand look - // identical without the paired value, so the projection does not guess. Flagged to the - // maintainer as an open question rather than decided here. - const config = staleConfig({ [SIBLING]: ["text"] }); - const projection = projectStaleVisionClassifications(config); - expect(projection.changed).toBe(false); - expect(projection.config.providers!["opencode-go"]!.noVisionModels).toContain(MODEL); - }); +describe("startup vision classification preservation", () => { + test("preserves an explicit text-only policy", () => { + const config = configuredPolicy(["text"], [MODEL]); + const projection = projectStartupConfigRepairs(config); + const provider = projection.config.providers["opencode-go"]!; - test("never writes the dedicated modelCapabilities axis", () => { - // `modelCapabilities` outranks every source this projection touches, so it is where a - // deliberate text-only override survives a restart. A repair that also rewrote it would make - // the operator's own `--text-only` decision unrecoverable. - const config = staleConfig(); - config.providers!["opencode-go"]!.modelCapabilities = { [MODEL]: { inputModalities: ["text"] } }; - const projection = projectStaleVisionClassifications(config); - expect(projection.config.providers!["opencode-go"]!.modelCapabilities![MODEL]!.inputModalities).toEqual(["text"]); - // The axis only matters because it is read FIRST on the request path: the repair rewrites the - // modality list and drops the name from `noVisionModels` around it, and the operator's - // declaration still routes the image through the vision sidecar rather than to the model. - const row = projection.config.providers!["opencode-go"]!; - expect(row.modelInputModalities![MODEL]).toEqual(["text", "image"]); - expect(row.noVisionModels ?? []).not.toContain(MODEL); - expect(requiresVisionPreprocessing({ providers: { "opencode-go": row } }, row, MODEL, "opencode-go")).toBe(true); - }); - - test("skips a row that no longer carries the registry adapter", () => { - const projection = projectStaleVisionClassifications(staleConfig(undefined, undefined, "anthropic")); expect(projection.changed).toBe(false); + expect(provider.modelInputModalities?.[MODEL]).toEqual(["text"]); + expect(provider.noVisionModels).toContain(MODEL); + expect(requiresVisionPreprocessing(projection.config, provider, MODEL, "opencode-go")).toBe(true); }); - test("follows the registry's destination rule where a preset opts into it", () => { - // baseten is a key preset with preserveCustomDestination, so the registry owns a same-named row - // only while it still points at the registry destination — the rule enrichProviderFromRegistry - // applies before it writes registry metadata. Claiming such a row by name alone would rewrite - // capability for an endpoint the registry does not describe. - const entry = { provider: "baseten", model: MODEL, fromModalities: ["text"], toModalities: ["text", "image"] }; - const atRegistry = { - adapter: "openai-chat", - baseUrl: "https://inference.baseten.co/v1", - modelInputModalities: { [MODEL]: ["text"] }, - noVisionModels: [MODEL], - }; - const atOwnHost = { ...atRegistry, baseUrl: "https://operator-gateway.example/v1" }; - const config = (row: typeof atRegistry): OcxConfig => ({ providers: { baseten: row } } as unknown as OcxConfig); - expect(projectStaleVisionClassifications(config(atRegistry), [entry]).changed).toBe(true); - expect(projectStaleVisionClassifications(config(atOwnHost), [entry]).changed).toBe(false); - }); + test("preserves an explicit noVisionModels policy beside native modalities", () => { + const config = configuredPolicy(["text", "image"], [MODEL]); + const projection = projectStartupConfigRepairs(config); + const provider = projection.config.providers["opencode-go"]!; - test("still repairs a pinned preset row at any destination", () => { - // opencode-go is an existing key preset without preserveCustomDestination: the registry claims - // that id itself, and enrichment fills its seed into such a row for the same reason. The - // projection follows that policy instead of inventing a narrower one of its own. - const projection = projectStaleVisionClassifications( - staleConfig(undefined, undefined, "openai-chat", "https://operator-gateway.example/v1"), - ); - expect(projection.changed).toBe(true); - }); - - test("is a no-op on a config without the provider", () => { - const projection = projectStaleVisionClassifications({ providers: {} } as unknown as OcxConfig); expect(projection.changed).toBe(false); - expect(projection.warnings).toEqual([]); - }); - - test("every entry names a real correction the registry now carries", () => { - // Guards against an entry that repairs a value the registry never claimed, or one whose - // target the registry does not declare — either would be a silent no-op forever. - for (const entry of STALE_VISION_CLASSIFICATIONS) { - const registry = PROVIDER_REGISTRY.find(row => row.id === entry.provider); - expect(registry, entry.provider).toBeDefined(); - expect(registry?.modelInputModalities?.[entry.model], entry.model).toEqual(entry.toModalities); - expect(registry?.noVisionModels ?? [], entry.model).not.toContain(entry.model); - expect(entry.fromModalities).not.toEqual(entry.toModalities); - } + expect(provider.modelInputModalities?.[MODEL]).toEqual(["text", "image"]); + expect(provider.noVisionModels).toContain(MODEL); + expect(requiresVisionPreprocessing(projection.config, provider, MODEL, "opencode-go")).toBe(true); }); });