From a65cbbcee0432a555223968c66c91a8d310c450d Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 4 Aug 2026 05:20:07 -0300 Subject: [PATCH] fix(opencode): propagate vision capability from live catalog into opencode.json (#8960) The opencode config generator fetched the live /v1/models catalog but only extracted context_length for new model entries, discarding capabilities (capabilities.vision, input_modalities, etc.) that OpenCode uses to gate clipboard/image input. Newly discovered vision-capable models were presented as text-only, causing OpenCode to reject attachments before sending the HTTP request. - Add input_modalities/output_modalities to CatalogModelEntry - Add deriveOpenCodeCapabilities() helper mapping catalog capabilities to OpenCode fields (attachment, reasoning, temperature, tool_call) with explicit user override precedence - Replace the existing round-trip-only flag loop in buildModelEntry() with the new helper so catalog-derived values fill in for new models --- changelog.d/fixes/8960-fix.plan.md | 1 + .../cli-helper/config-generator/opencode.ts | 75 +++++++++++++++++-- .../unit/cli-helper/config-generator.test.ts | 40 ++++++++++ 3 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fixes/8960-fix.plan.md diff --git a/changelog.d/fixes/8960-fix.plan.md b/changelog.d/fixes/8960-fix.plan.md new file mode 100644 index 0000000000..2dca8c7069 --- /dev/null +++ b/changelog.d/fixes/8960-fix.plan.md @@ -0,0 +1 @@ +- fix(opencode): propagate vision capability from live catalog into opencode.json (#8960) diff --git a/src/lib/cli-helper/config-generator/opencode.ts b/src/lib/cli-helper/config-generator/opencode.ts index 4081cc32a2..845e53f821 100644 --- a/src/lib/cli-helper/config-generator/opencode.ts +++ b/src/lib/cli-helper/config-generator/opencode.ts @@ -53,6 +53,9 @@ interface CatalogModelEntry { tool_calling?: boolean; vision?: boolean; }; + /** OpenAI-compatible modality arrays; some upstreams return these. */ + input_modalities?: string[]; + output_modalities?: string[]; } /** Per-model override carried over from the user's existing opencode.json. */ @@ -167,6 +170,64 @@ export async function fetchOmniRouteCatalog( * window. The user can override per-model via `limit.context` in their * existing opencode.json, or fix the upstream catalog. */ +/** + * Map catalog capabilities/modalities to OpenCode model capability fields. + * Preserves explicit user-set booleans (including `false`) over any catalog + * value -- a deliberate local restriction must never be overwritten. + * + * Mapping rules per field: + * - `attachment`: explicit user flag; then catalog `capabilities.attachment`; + * then `capabilities.vision`; then `input_modalities` containing `image`. + * - `reasoning`: explicit user flag; then `capabilities.reasoning`. + * - `temperature`: explicit user flag; then `capabilities.temperature`. + * - `tool_call`: explicit user flag; then `capabilities.tool_calling`. + */ +function deriveOpenCodeCapabilities( + catalog: CatalogModelEntry | undefined, + existing: ExistingModelEntry | undefined +): Pick { + const result: Pick = {}; + + // attachment: explicit user flag wins, then catalog attachment, then vision, then image modality. + if (typeof existing?.attachment === "boolean") { + result.attachment = existing.attachment; + } else if (catalog?.capabilities) { + if (typeof catalog.capabilities.attachment === "boolean") { + result.attachment = catalog.capabilities.attachment; + } else if (catalog.capabilities.vision === true) { + result.attachment = true; + } else if ( + Array.isArray(catalog.input_modalities) && + catalog.input_modalities.includes("image") + ) { + result.attachment = true; + } + } + + // reasoning: explicit user flag wins, then catalog reasoning. + if (typeof existing?.reasoning === "boolean") { + result.reasoning = existing.reasoning; + } else if (catalog?.capabilities?.reasoning === true) { + result.reasoning = true; + } + + // temperature: explicit user flag wins, then catalog temperature. + if (typeof existing?.temperature === "boolean") { + result.temperature = existing.temperature; + } else if (catalog?.capabilities?.temperature === true) { + result.temperature = true; + } + + // tool_call: explicit user flag wins, then catalog tool_calling. + if (typeof existing?.tool_call === "boolean") { + result.tool_call = existing.tool_call; + } else if (catalog?.capabilities?.tool_calling === true) { + result.tool_call = true; + } + + return result; +} + function resolveContextLength(entry: CatalogModelEntry): number | undefined { const candidates = [entry.context_length, entry.max_context_window_tokens]; for (const c of candidates) { @@ -196,11 +257,15 @@ function buildModelEntry( const entry: ExistingModelEntry = { name }; - // Round-trip capability flags from the existing config (if any). - for (const flag of ["attachment", "reasoning", "temperature", "tool_call"] as const) { - const value = existing?.[flag]; - if (typeof value === "boolean") entry[flag] = value; - } + // Derive capability flags from the catalog, preserving explicit user overrides. + // Explicit user booleans (including `false`) always win; catalog capabilities + // fill in missing values so newly discovered models are not presented as + // text-only to OpenCode clients. + const caps = deriveOpenCodeCapabilities(catalog, existing); + if (typeof caps.attachment === "boolean") entry.attachment = caps.attachment; + if (typeof caps.reasoning === "boolean") entry.reasoning = caps.reasoning; + if (typeof caps.temperature === "boolean") entry.temperature = caps.temperature; + if (typeof caps.tool_call === "boolean") entry.tool_call = caps.tool_call; // Preserve any extra top-level keys the user set (variants, headers, etc.) // that we don't model explicitly. diff --git a/tests/unit/cli-helper/config-generator.test.ts b/tests/unit/cli-helper/config-generator.test.ts index ccc0989652..20742d4ec4 100644 --- a/tests/unit/cli-helper/config-generator.test.ts +++ b/tests/unit/cli-helper/config-generator.test.ts @@ -494,6 +494,46 @@ describe("config-generator", () => { } }); + it("propagates vision capability from the live catalog for issue #8960", async () => { + const modelId = "cx/gpt-5.6-sol-medium-issue-8960"; + const stub = stubFetchOnce( + makeCatalogResponse([ + { + id: modelId, + owned_by: "codex", + context_length: 272000, + max_output_tokens: 128000, + capabilities: { + vision: true, + reasoning: true, + tool_calling: true, + }, + input_modalities: ["text", "image"], + output_modalities: ["text"], + }, + ]) + ); + try { + const { generateOpencodeConfig } = await import( + "../../../src/lib/cli-helper/config-generator/opencode.ts" + ); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + }); + const cfg = JSON.parse(out); + const model = cfg.provider.omniroute.models[modelId]; + + assert.strictEqual( + model.attachment, + true, + "a catalog model with vision/image input must remain attachment-capable in opencode.json" + ); + } finally { + stub.restore(); + } + }); + it("auto-pulls the Opencode FREE Omni combo context (the user-reported case)", async () => { // Regression guard: the catalog's min-of-targets for combos must be // reflected verbatim. No hardcoded 128K, no fallback that overrides