From 1bb0d30f32d75cf1b2410068c754310c224dc3cb Mon Sep 17 00:00:00 2001 From: Valerio Coltre Date: Fri, 11 Sep 2026 23:36:27 +0700 Subject: [PATCH] fix(opencode): advertise per-model image capabilities in the exported config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenCode export emitted only `name` and `limit` per model, so opencode computed `capabilities.attachment` and `capabilities.input.image` as false for every opencodex model: the provider is absent from models.dev, and opencode's loader falls back to a hardcoded false for an entry that says nothing. Attachments were then refused client-side in the TUI before any request reached the proxy — including native OpenAI slugs that /api/models reports as ["text","image"], and text-only models the vision sidecar covers. Carry the catalog row's `inputModalities` through `OpencodeCatalogModel` and `opencodeCatalogFromProxyRows`, then serialize them as opencode's own per-model fields (`attachment`, `modalities`) as declared by opencode's published model schema. Both provider generations get them, so the two spellings of one model list cannot disagree; the V2 model schema expresses capabilities as `capabilities.{tools,input,output}` (which opencode fills by migrating this same `modalities` field) and its loader decodes with `onExcessProperty: "ignore"`. A row that declares nothing keeps the previous entry shape, which opencode already treats as text-only. Values outside opencode's enum (text|audio|image|video|pdf) are dropped rather than written through, the way `audio` had to be for Pi and Gajae; a row left with nothing acceptable keeps its entry without capability keys instead of being retyped as text. `exportModelsFromProxyRows` no longer re-joins modalities by `namespaced` — the catalog entry carries them now, from the same visibility-filtered row as the model itself, so a disabled duplicate cannot donate them. Closes #4286 --- docs-site/src/content/docs/guides/opencode.md | 28 ++++++ src/cli/export-command.ts | 30 ++---- src/cli/opencode.ts | 5 + src/clients/config-export.ts | 31 +++++- src/clients/config-export/contracts.ts | 7 ++ src/clients/config-export/model-metadata.ts | 33 +++++++ .../client-export-modality-enum.test.ts | 97 +++++++++++++++++++ tests/config/client-config-export.test.ts | 29 ++++++ tests/providers/opencode-cli.test.ts | 32 ++++++ .../management-client-config-route.test.ts | 10 +- 10 files changed, 275 insertions(+), 27 deletions(-) diff --git a/docs-site/src/content/docs/guides/opencode.md b/docs-site/src/content/docs/guides/opencode.md index 52c95445bf..2c6d7c7008 100644 --- a/docs-site/src/content/docs/guides/opencode.md +++ b/docs-site/src/content/docs/guides/opencode.md @@ -51,6 +51,34 @@ No model-level default effort is written. The proxy keeps applying its own confi default whenever a request carries no effort, so a default you change in opencodex stays in force instead of being frozen into the config. +## Images and attachments + +opencode decides whether a model takes an image from the model entry itself, and it cannot ask +models.dev about `opencodex` — this provider is not there. The generated blocks therefore +carry opencode's own per-model capability fields, `attachment` and `modalities`, taken from +the metadata the proxy reports at `GET /api/models`: + +```json +"gpt-5.6-luna": { + "name": "gpt-5.6-luna (native)", + "limit": { "context": 272000, "output": 32000 }, + "attachment": true, + "modalities": { "input": ["text", "image"], "output": ["text"] } +} +``` + +Without those fields opencode assumes the model is text-only and refuses the paste on the +client side, so the image never reaches the proxy. That applies to text-only models too: when +the catalog reports image input for a model the vision sidecar covers, opencode lets the +attachment through so the sidecar can describe it before the upstream call. + +What is written comes from the row's declared input modalities in `GET /api/models`. For a +discovered model the catalog adds `image` itself for the sidecar case; a custom row is written +from the modalities stored on it, so a custom entry that declares text only stays text-only +even when the sidecar would cover it. A row that declares none — an undeclared custom model, +for example — keeps the plain entry (`name`, plus `limit` when its context window is known), +and opencode treats it as text-only. + ## Your own config is never modified The launcher does not copy or rewrite `~/.config/opencode/opencode.json`, diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 123068f943..21d62f59c7 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -60,16 +60,6 @@ export interface ExportCommandDeps extends RuntimeApiDeps { configImpl?: () => OcxConfig; } -/** - * `/api/models` row plus the modality list Pi consumes. The launcher's row type predates - * the Pi exporter and stops at the fields OpenCode needs. - */ -type ExportProxyModelRow = OpencodeProxyModelRow & { - inputModalities?: string[]; - reasoningEfforts?: string[]; - defaultReasoningEffort?: string; -}; - /** * Export rows from proxy `/api/models` rows. * @@ -80,20 +70,13 @@ type ExportProxyModelRow = OpencodeProxyModelRow & { * row as the model itself: a second lookup over the raw rows would let a hidden or disabled * duplicate donate its ladder to the visible entry. * - * Only modalities are re-joined by `namespaced`, because the catalog type does not carry them. + * Modalities need no such lookup: `opencodeCatalogFromProxyRows` carries them on the catalog + * entry, so the clients that filter them are handed the same filtered, deduped row. */ export function exportModelsFromProxyRows( - rows: readonly ExportProxyModelRow[], + rows: readonly OpencodeProxyModelRow[], config: OcxConfig, ): ExportModel[] { - const modalities = new Map(); - for (const row of rows) { - const namespaced = row.namespaced?.trim(); - if (!namespaced || modalities.has(namespaced)) continue; - if (Array.isArray(row.inputModalities) && row.inputModalities.length > 0) { - modalities.set(namespaced, [...row.inputModalities]); - } - } return opencodeCatalogFromProxyRows(rows, config).map(entry => { const model: ExportModel = { namespaced: entry.namespaced, @@ -108,8 +91,9 @@ export function exportModelsFromProxyRows( model.reasoningEfforts = [...entry.reasoningEfforts]; } if (entry.defaultReasoningEffort) model.defaultReasoningEffort = entry.defaultReasoningEffort; - const input = modalities.get(entry.namespaced); - if (input) model.inputModalities = [...input]; + if (entry.inputModalities && entry.inputModalities.length > 0) { + model.inputModalities = [...entry.inputModalities]; + } return model; }); } @@ -188,7 +172,7 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep } built = { document: exported.config, text: exported.text }; } else { - const rows = await runtimeRequest("/api/models", {}, { ...deps, baseUrl: root }); + const rows = await runtimeRequest("/api/models", {}, { ...deps, baseUrl: root }); if (!Array.isArray(rows)) { throw new RuntimeApiError("Management API returned an unexpected /api/models payload.", 502, rows); } diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index adcdea1095..09ea024746 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -98,6 +98,8 @@ export interface OpencodeProxyModelRow { displayName?: string; displayNameSource?: "operator" | "provider" | "fallback"; contextWindow?: number; + /** Declared input modalities from `/api/models`; carried into opencode model capabilities. */ + inputModalities?: string[]; /** Declared effort ladder from `/api/models`; carried into opencode model variants. */ reasoningEfforts?: string[]; /** Declared default effort from `/api/models`. */ @@ -396,6 +398,9 @@ export function opencodeCatalogFromProxyRows( id: row.id, contextWindow: row.contextWindow, displayName: row.displayNameSource === "fallback" ? undefined : row.displayName, + ...(Array.isArray(row.inputModalities) && row.inputModalities.length > 0 + ? { inputModalities: [...row.inputModalities] } + : {}), ...(typeof row.fastRowAvailable === "boolean" ? { fastRowAvailable: row.fastRowAvailable } : {}), ...(Array.isArray(row.reasoningEfforts) && row.reasoningEfforts.length > 0 ? { reasoningEfforts: [...row.reasoningEfforts] } diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 8551f190a9..13d8f952e5 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -42,7 +42,7 @@ export { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } import type { OpencodeLaunchEnv, OpencodeCatalogModel, ExportContext, PiModelEntry, ManagedContribution, ManagedFragment, ExportClientId, ExportClientSpec } from "./config-export/contracts"; import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, GAJAE_API_KEY_ENV, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants"; -import { exportModelLabel, authoritativeContextWindow, outputBudgetFor, normalizeExportModels, inputModalitiesForClient, proxyAdmissionHeaders, singleFragment } from "./config-export/model-metadata"; +import { exportModelLabel, authoritativeContextWindow, outputBudgetFor, normalizeExportModels, inputModalitiesForClient, opencodeModelCapabilities, proxyAdmissionHeaders, singleFragment } from "./config-export/model-metadata"; import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./config-export/omp"; import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh"; import { buildMcodeClientConfig, summarizeMcode, buildMcodeContribution } from "./config-export/mcode"; @@ -54,6 +54,14 @@ import { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } export interface OpencodeModelEntry { name: string; limit?: { context: number; output: number }; + /** + * opencode's own capability fields, derived from the catalog row's declared input + * modalities. Written only when the row declares at least one — an entry without them is + * what opencode already treats as text-only, and omitting them keeps an undeclared model + * byte-identical to what shipped before. + */ + attachment?: boolean; + modalities?: { input: string[]; output: string[] }; } /** @@ -659,13 +667,30 @@ export function opencodeProviderBlocks( if (context !== undefined) { entry.limit = { context, output: outputBudgetFor(context) }; } + // `attachment` / `modalities` are fields of opencode's V1 model schema — the shape its + // published config.json defines and the one its loader reads (verified against opencode + // 1.18.30, src/provider/provider.ts: `model.attachment ?? …` / `model.modalities?.input`). + // They ride on both generations anyway: the two blocks are two spellings of one model list, + // and the V2 model schema (capabilities.{tools,input,output}, which opencode fills by + // migrating this same `modalities` field) ignores keys it does not define — its loader + // decodes with `onExcessProperty: "ignore"`. Same values on both, so a merge cannot make + // the two entries disagree. + const capabilities = opencodeModelCapabilities(model.inputModalities); + if (capabilities) { + entry.attachment = capabilities.attachment; + entry.modalities = capabilities.modalities; + } v1Models[key] = entry; const variants = opencodeEffortVariants(model); - // Own `limit` object, not a shared reference: the two blocks are serialized and reasoned - // about separately, and an in-place edit of one must never move the other. + // Own `limit` and `modalities` objects, not shared references: the two blocks are + // serialized and reasoned about separately, and an in-place edit of one must never move + // the other. v2Models[key] = { ...entry, ...(entry.limit ? { limit: { ...entry.limit } } : {}), + ...(entry.modalities + ? { modalities: { input: [...entry.modalities.input], output: [...entry.modalities.output] } } + : {}), ...(variants ? { variants } : {}), }; } diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts index dc33732fe2..aa3eab4320 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -38,6 +38,13 @@ export interface OpencodeCatalogModel { id?: string; contextWindow?: number; displayName?: string; + /** + * Declared input modalities, carried verbatim from `/api/models`. Serialized as opencode's + * per-model `attachment` + `modalities`, because opencode gates attachments CLIENT-side: + * without them every `opencodex` model is text-only in its picker and an image never + * reaches the proxy or the vision sidecar (#4286). + */ + inputModalities?: readonly string[]; /** Declared effort ladder. Exported as opencode model variants where the client reads them. */ reasoningEfforts?: readonly string[]; /** diff --git a/src/clients/config-export/model-metadata.ts b/src/clients/config-export/model-metadata.ts index 7b24390341..5c69af92ca 100644 --- a/src/clients/config-export/model-metadata.ts +++ b/src/clients/config-export/model-metadata.ts @@ -72,6 +72,39 @@ export function inputModalitiesForClient( return kept.length > 0 ? kept : null; } +/** + * Input modalities opencode's model schema accepts (opencode.ai/config.json, both + * `modalities.input` and `modalities.output`). Wider than our internal `text | image | audio` + * vocabulary, so unlike Pi and Gajae this filter can only drop a value no current ingress + * produces: `/api/custom-models`, `ocx models add` and the catalog writer all normalize to + * the internal three. It exists so a future ingress cannot do to opencode what `audio` did + * to Gajae, whose loader rejected the whole config file over one out-of-enum value. + */ +const OPENCODE_INPUT_MODALITIES: ReadonlySet = new Set(["text", "audio", "image", "video", "pdf"]); + +/** + * opencode's per-model capability fields for one catalog row, or `undefined` when the row + * declares nothing. + * + * `undefined` rather than `{ input: ["text"] }`: opencode already computes an entry without + * capabilities as text-only, and leaving the keys out keeps every model that declares + * nothing byte-identical to what shipped before. A declared list is carried across as-is, so + * an audio-only row keeps `attachment: true` instead of being rewritten to text it cannot + * read — the same call Pi's exporter makes, in the opposite direction. + */ +export function opencodeModelCapabilities( + modalities: readonly string[] | undefined, +): { attachment: boolean; modalities: { input: string[]; output: string[] } } | undefined { + const input: string[] = []; + for (const value of modalities ?? []) { + if (OPENCODE_INPUT_MODALITIES.has(value) && !input.includes(value)) input.push(value); + } + if (input.length === 0) return undefined; + // `attachment` is what opencode's client gates pasting on; `modalities` refines it into + // which kinds. Output is always text — nothing in the catalog declares otherwise. + return { attachment: input.some(value => value !== "text"), modalities: { input, output: ["text"] } }; +} + /** * Label shared by every client: `" ()"`. The * provider suffix is what makes two same-named models from different upstreams diff --git a/tests/clients/client-export-modality-enum.test.ts b/tests/clients/client-export-modality-enum.test.ts index 36473391c6..849b1e6518 100644 --- a/tests/clients/client-export-modality-enum.test.ts +++ b/tests/clients/client-export-modality-enum.test.ts @@ -6,6 +6,7 @@ import { type ExportModel, type GajaeGeneratedConfig, type HermesGeneratedConfig, + type OpencodeGeneratedConfig, type PiGeneratedConfig, } from "../../src/clients/config-export"; import type { OcxConfig } from "../../src/types"; @@ -52,6 +53,11 @@ function hermesModels(models: ExportModel[]) { .providers[OPENCODE_PROVIDER_ID].models; } +function opencodeModels(models: ExportModel[]) { + return (buildClientConfig("opencode", ctx(models)) as OpencodeGeneratedConfig) + .providers[OPENCODE_PROVIDER_ID].models; +} + /** The live failure, by its real id and real modality list. */ const MIXED: ExportModel = { namespaced: "zenmux/meta-muse-spark-1.1", @@ -147,3 +153,94 @@ describe("exported modalities stay inside the enum each client accepts", () => { } }); }); + +/** + * opencode is the third shape of this problem, and the only one where the fix is a + * capability field rather than a filter. + * + * Its model schema accepts a WIDER enum than our internal vocabulary + * (`text | audio | image | video | pdf`, opencode.ai/config.json), and its client gates + * pasting on `attachment` / `modalities.input` INSTEAD of rejecting the file we hand it. So + * an out-of-enum value is dropped, but a row left with nothing acceptable keeps its entry + * and carries no capability keys — never a fabricated `text`, which would advertise input + * the model cannot read. + */ +describe("opencode receives the capability fields its client gates attachments on", () => { + test("a declared model advertises attachment plus every modality opencode accepts", () => { + // The live catalog shape: meta-muse-spark-1.1 declares text|image|audio, and audio is + // INSIDE opencode's enum, so unlike Pi and Gajae nothing is dropped here. + expect(opencodeModels([MIXED])["zenmux/meta-muse-spark-1.1"]).toEqual({ + name: "meta-muse-spark-1.1 (zenmux)", + limit: { context: 1_048_576, output: 32_000 }, + attachment: true, + modalities: { input: ["text", "image", "audio"], output: ["text"] }, + }); + }); + + test("an audio-only row stays audio-only instead of being retyped as text", () => { + // opencode accepts audio, so the Pi/Gajae answer — omit the row — would lose a model for + // no reason. Faithfulness costs nothing here. + expect(opencodeModels([AUDIO_ONLY])["p/audio-only"]).toEqual({ + name: "audio-only (p)", + attachment: true, + modalities: { input: ["audio"], output: ["text"] }, + }); + }); + + test("a text-only declaration is advertised as text-only rather than omitted", () => { + const textOnly: ExportModel = { namespaced: "p/text", provider: "p", id: "text", inputModalities: ["text"] }; + expect(opencodeModels([textOnly])["p/text"]).toEqual({ + name: "text (p)", + attachment: false, + modalities: { input: ["text"], output: ["text"] }, + }); + }); + + test("a row that declares nothing carries no capability keys at all", () => { + // Not the same as `{ input: ["text"] }`: opencode already falls back to text-only for an + // entry without capabilities, and the omission keeps the pre-#4286 bytes for every model + // whose row says nothing. + const bare: ExportModel = { namespaced: "p/bare", provider: "p", id: "bare" }; + const empty: ExportModel = { ...bare, namespaced: "p/empty", id: "empty", inputModalities: [] }; + const models = opencodeModels([bare, empty]); + expect(models["p/bare"]).toEqual({ name: "bare (p)" }); + expect(models["p/empty"]).toEqual({ name: "empty (p)" }); + }); + + test("an out-of-enum value is dropped and duplicates collapse", () => { + const odd: ExportModel = { + namespaced: "p/odd", provider: "p", id: "odd", inputModalities: ["file", "image", "image"], + }; + expect(opencodeModels([odd])["p/odd"]).toEqual({ + name: "odd (p)", + attachment: true, + modalities: { input: ["image"], output: ["text"] }, + }); + }); + + test("a model whose only declaration is out of enum keeps its entry, without capabilities", () => { + const foreign: ExportModel = { namespaced: "p/foreign", provider: "p", id: "foreign", inputModalities: ["file"] }; + expect(opencodeModels([foreign])["p/foreign"]).toEqual({ name: "foreign (p)" }); + }); + + test("no emitted entry in a whole catalog carries a value opencode rejects", () => { + const catalog: ExportModel[] = [ + MIXED, + AUDIO_ONLY, + { namespaced: "p/bare", provider: "p", id: "bare" }, + { namespaced: "p/foreign", provider: "p", id: "foreign", inputModalities: ["file"] }, + { namespaced: "p/vision", provider: "p", id: "vision", inputModalities: ["text", "image"] }, + ]; + const models = opencodeModels(catalog); + // The entry survives where Pi and Gajae would have dropped it; only its bad value goes. + expect(Object.keys(models)).toContain("p/foreign"); + for (const entry of Object.values(models)) { + for (const value of entry.modalities?.input ?? []) { + expect(["text", "audio", "image", "video", "pdf"]).toContain(value); + } + for (const value of entry.modalities?.output ?? []) { + expect(["text", "audio", "image", "video", "pdf"]).toContain(value); + } + } + }); +}); diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 5524b1c338..644fb39f6e 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -752,11 +752,16 @@ describe("hub-resolved Fast exports", () => { expect(block.models["z/sparse--fast"]).toEqual({ name: "z/sparse Fast (routed)" }); } const expanded = opencodeProviderBlocks(BASE_URL, [eligible], cfg({ fastRows: false })); + // A Fast row is a second selector for the same model, so it inherits the capabilities the + // base row declared. Without them opencode would gate images on exactly the row a user who + // turned Fast on selects (#4286). expect(expanded.v1.models["remote/model--fast"]).toEqual({ name: "Remote Model Fast (remote)", limit: { context: 8192, output: 8192 }, + attachment: true, modalities: { input: ["text", "image"], output: ["text"] }, }); expect(expanded.v2.models["remote/model--fast"]).toEqual({ name: "Remote Model Fast (remote)", limit: { context: 8192, output: 8192 }, + attachment: true, modalities: { input: ["text", "image"], output: ["text"] }, variants: [ { id: "high", settings: { reasoningEffort: "high" } }, { id: "ultra", settings: { reasoningEffort: "ultra" } }, @@ -769,6 +774,12 @@ describe("hub-resolved Fast exports", () => { expect(remote.v2.settings).toEqual(remote.v1.options); }); + test("each generation owns its modalities map, so an edit to one cannot move the other", () => { + const blocks = opencodeProviderBlocks(BASE_URL, [eligible], cfg({ fastRows: false })); + blocks.v1.models["remote/model"]!.modalities!.input.push("audio"); + expect(blocks.v2.models["remote/model"]!.modalities!.input).toEqual(["text", "image"]); + }); + test("both CLI projections retain hub true/false/absence despite conflicting local settings", () => { for (const localFast of [false, true]) { for (const hubFast of [undefined, false, true]) { @@ -805,6 +816,24 @@ describe("hub-resolved Fast exports", () => { expect(Object.keys(blocks.v1.models)).toEqual(["remote/model"]); expect(Object.keys(blocks.v2.models)).toEqual(["remote/model"]); }); + + test("a disabled duplicate cannot donate its modalities to the visible row", () => { + // `exportModelsFromProxyRows` used to re-join modalities from the RAW `/api/models` rows, + // keyed by `namespaced` with the first row winning — so a hidden or disabled duplicate + // could hand its modality list to the visible entry, the same donation the availability and + // ladder rules already refuse. The catalog entry carries them now, so the row that is + // exported is the row that declares. + const shadowed = { ...eligible, fastRowAvailable: false, inputModalities: ["text"] }; + const rows = [ + { ...eligible, fastRowAvailable: false, disabled: true, inputModalities: ["text", "image", "audio"] }, + shadowed, + ]; + const config = cfg({ fastRows: false }); + expect(exportModelsFromProxyRows(rows, config)).toEqual([shadowed]); + const blocks = opencodeProviderBlocks(BASE_URL, opencodeCatalogFromProxyRows(rows, config), config); + expect(blocks.v1.models["remote/model"]!.modalities).toEqual({ input: ["text"], output: ["text"] }); + expect(blocks.v2.models["remote/model"]!.modalities).toEqual({ input: ["text"], output: ["text"] }); + }); }); describe("EXPORT_CLIENTS registry", () => { diff --git a/tests/providers/opencode-cli.test.ts b/tests/providers/opencode-cli.test.ts index 4483e5ea99..1edfe9284f 100644 --- a/tests/providers/opencode-cli.test.ts +++ b/tests/providers/opencode-cli.test.ts @@ -421,6 +421,38 @@ describe("ocx opencode proxy model catalog", () => { expect(Object.keys(blocks.v1.models)).not.toContain("opencode-go/hidden"); }); + test("carries /api/models modalities into the blocks the launcher injects", () => { + // Same failure mode as the ladder above, one field over: the management API reports + // image input for these rows and opencode gates attachments client-side, so dropping the + // field here leaves the image blocked before any request reaches the proxy (#4286). + const rows = [ + { namespaced: "gpt-5.6-luna", native: true, provider: "openai", id: "gpt-5.6-luna", inputModalities: ["text", "image"] }, + { namespaced: "opencode-go/glm-5.3", provider: "opencode-go", id: "glm-5.3", inputModalities: ["text", "image"] }, + { namespaced: "opencode-go/text-only", provider: "opencode-go", id: "text-only", inputModalities: ["text"] }, + { namespaced: "opencode-go/undeclared", provider: "opencode-go", id: "undeclared" }, + { namespaced: "opencode-go/hidden", provider: "opencode-go", id: "hidden", disabled: true, inputModalities: ["text", "image"] }, + ]; + const catalog = opencodeCatalogFromProxyRows(rows, cfg()); + const blocks = buildOpencodeProviderBlocksFromCatalog(10100, catalog, undefined, cfg()); + + for (const block of [blocks.v1, blocks.v2]) { + expect(block.models["gpt-5.6-luna"]).toMatchObject({ + attachment: true, modalities: { input: ["text", "image"], output: ["text"] }, + }); + expect(block.models["opencode-go/glm-5.3"]).toMatchObject({ + attachment: true, modalities: { input: ["text", "image"], output: ["text"] }, + }); + expect(block.models["opencode-go/text-only"]).toMatchObject({ + attachment: false, modalities: { input: ["text"], output: ["text"] }, + }); + // A row that declares nothing keeps the exact entry shape opencode already reads as + // text-only — the pre-#4286 bytes, not a synthesized capability list. + expect(block.models["opencode-go/undeclared"]).not.toHaveProperty("attachment"); + expect(block.models["opencode-go/undeclared"]).not.toHaveProperty("modalities"); + expect(Object.keys(block.models)).not.toContain("opencode-go/hidden"); + } + }); + test("the launcher's V1 and V2 blocks share one connection", () => { const blocks = buildOpencodeProviderBlocksFromCatalog( 10100, diff --git a/tests/server/management-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts index 9fcb92e81d..45514c9754 100644 --- a/tests/server/management-client-config-route.test.ts +++ b/tests/server/management-client-config-route.test.ts @@ -286,7 +286,15 @@ describe("GET /api/client-config", () => { const document = body.config as OpencodeGeneratedConfig; expect(document.$schema).toBe(OPENCODE_CONFIG_SCHEMA); const models = document.provider[OPENCODE_PROVIDER_ID].models; - expect(models["a/m1"]).toEqual({ name: "m1 (a)", limit: { context: 128_000, output: 32_000 } }); + // The row's declared modalities now reach opencode's own capability fields; without them + // opencode gates attachments client-side and the image never leaves the TUI (#4286). + expect(models["a/m1"]).toEqual({ + name: "m1 (a)", limit: { context: 128_000, output: 32_000 }, + attachment: true, modalities: { input: ["text", "image"], output: ["text"] }, + }); + // m2 declares text-only in `modelInputModalities`, which is exactly what routes it through + // the vision sidecar: the catalog advertises image so the attachment can reach the proxy. + expect(models["a/m2"]!.modalities).toEqual({ input: ["text", "image"], output: ["text"] }); expect(models["b/no-context"]).toEqual({ name: "no-context (b)" }); }, 15_000);