diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 739889a0265..5c12aa6ef62 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -58,12 +58,8 @@ 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. - */ +/** `/api/models` row as every client exporter reads it. */ type ExportProxyModelRow = OpencodeProxyModelRow & { - inputModalities?: string[]; reasoningEfforts?: string[]; defaultReasoningEffort?: string; }; @@ -76,22 +72,13 @@ type ExportProxyModelRow = OpencodeProxyModelRow & { * here is the only thing keeping a disabled model out of a client's picker. It also carries * the effort ladder, so the ladder a client receives comes from the same filtered, deduped * 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. + * duplicate donate its ladder to the visible entry. Input modalities ride the same row since + * the OpenCode serializer reads them too (#4286). */ export function exportModelsFromProxyRows( rows: readonly ExportProxyModelRow[], 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, @@ -106,8 +93,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; }); } diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index adcdea1095e..1a84ce054d7 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -82,6 +82,8 @@ export interface OpencodeRoutedModel { contextWindow?: number; /** Authoritative display label (CatalogModel.displayName); optional. */ displayName?: string; + /** Declared input modalities; exported as opencode capability fields when present. */ + inputModalities?: readonly string[]; /** Declared effort ladder; exported as opencode model variants when present. */ reasoningEfforts?: readonly string[]; } @@ -98,6 +100,8 @@ export interface OpencodeProxyModelRow { displayName?: string; displayNameSource?: "operator" | "provider" | "fallback"; contextWindow?: number; + /** Declared input modalities from `/api/models`; carried into opencode capability fields. */ + inputModalities?: string[]; /** Declared effort ladder from `/api/models`; carried into opencode model variants. */ reasoningEfforts?: string[]; /** Declared default effort from `/api/models`. */ @@ -234,6 +238,9 @@ function opencodeLaunchCatalog( id: model.id, contextWindow: model.contextWindow, displayName: model.displayName, + ...(model.inputModalities && model.inputModalities.length > 0 + ? { inputModalities: [...model.inputModalities] } + : {}), ...(model.reasoningEfforts && model.reasoningEfforts.length > 0 ? { reasoningEfforts: [...model.reasoningEfforts] } : {}), @@ -397,6 +404,9 @@ export function opencodeCatalogFromProxyRows( contextWindow: row.contextWindow, displayName: row.displayNameSource === "fallback" ? undefined : row.displayName, ...(typeof row.fastRowAvailable === "boolean" ? { fastRowAvailable: row.fastRowAvailable } : {}), + ...(Array.isArray(row.inputModalities) && row.inputModalities.length > 0 + ? { inputModalities: [...row.inputModalities] } + : {}), ...(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 8551f190a9e..f1d96db6bcd 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -54,6 +54,33 @@ import { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } export interface OpencodeModelEntry { name: string; limit?: { context: number; output: number }; + /** opencode `attachment`: the client allows file/image attachments for this model. */ + attachment?: boolean; + /** opencode `modalities`: declared input kinds; output is always text through the proxy. */ + modalities?: { input: string[]; output: string[] }; +} + +/** + * Input modalities opencode's config schema accepts. `attachment` and + * `modalities.input` are read from the provider block; with the `opencodex` provider + * id absent from models.dev the client otherwise defaults both to "no", which blocks + * image attachments before a request reaches the proxy (#4286). + */ +const OPENCODE_INPUT_MODALITIES: ReadonlySet = new Set(["text", "audio", "image", "video", "pdf"]); + +/** Capability fields for a model that declares its input modalities; nothing for one that does not. */ +function opencodeCapabilityFields(model: OpencodeCatalogModel): Pick { + const declared = model.inputModalities ?? []; + if (declared.length === 0) return {}; + const input: string[] = []; + for (const value of declared) { + if (OPENCODE_INPUT_MODALITIES.has(value) && !input.includes(value)) input.push(value); + } + if (input.length === 0) input.push("text"); + return { + attachment: input.some((value) => value !== "text"), + modalities: { input, output: ["text"] }, + }; } /** @@ -659,13 +686,15 @@ export function opencodeProviderBlocks( if (context !== undefined) { entry.limit = { context, output: outputBudgetFor(context) }; } + Object.assign(entry, opencodeCapabilityFields(model)); 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` / `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 dc33732fe23..0f93f3d7ff3 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -38,6 +38,12 @@ export interface OpencodeCatalogModel { id?: string; contextWindow?: number; displayName?: string; + /** + * Declared input modalities, verbatim from the catalog. The opencode serializer turns + * them into `attachment` / `modalities`; a model with nothing declared stays capability- + * free so the client keeps its own default instead of a guessed one. + */ + inputModalities?: readonly string[]; /** Declared effort ladder. Exported as opencode model variants where the client reads them. */ reasoningEfforts?: readonly string[]; /** diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 5524b1c338b..1d2f961cf3f 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -754,9 +754,11 @@ describe("hub-resolved Fast exports", () => { const expanded = opencodeProviderBlocks(BASE_URL, [eligible], cfg({ fastRows: false })); 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" } }, diff --git a/tests/providers/opencode-cli.test.ts b/tests/providers/opencode-cli.test.ts index 4483e5ea998..17dc4e6d023 100644 --- a/tests/providers/opencode-cli.test.ts +++ b/tests/providers/opencode-cli.test.ts @@ -142,6 +142,57 @@ describe("ocx opencode provider block", () => { expect(block.models["kiro/qwen3-coder-next"]?.name).toBe("qwen3-coder-next (kiro)"); }); + test("declared input modalities become opencode attachment and modalities fields (#4286)", () => { + const blocks = buildOpencodeProviderBlocksFromCatalog(10100, [ + { namespaced: "kiro/vision", provider: "kiro", id: "vision", inputModalities: ["text", "image"] }, + { namespaced: "kiro/text-only", provider: "kiro", id: "text-only", inputModalities: ["text"] }, + { namespaced: "kiro/audio-only", provider: "kiro", id: "audio-only", inputModalities: ["audio"] }, + { namespaced: "kiro/odd", provider: "kiro", id: "odd", inputModalities: ["telepathy"] }, + { namespaced: "kiro/undeclared", provider: "kiro", id: "undeclared" }, + ]); + for (const block of [blocks.v1, blocks.v2]) { + // Image-capable rows advertise attachments; opencode otherwise defaults the unknown + // `opencodex` provider to text-only and blocks images before any request is sent. + expect(block.models["kiro/vision"]?.attachment).toBe(true); + expect(block.models["kiro/vision"]?.modalities).toEqual({ input: ["text", "image"], output: ["text"] }); + expect(block.models["kiro/text-only"]?.attachment).toBe(false); + expect(block.models["kiro/text-only"]?.modalities).toEqual({ input: ["text"], output: ["text"] }); + // Modalities opencode's schema knows are carried verbatim, even without text. + expect(block.models["kiro/audio-only"]?.attachment).toBe(true); + expect(block.models["kiro/audio-only"]?.modalities).toEqual({ input: ["audio"], output: ["text"] }); + // Only unknown values declared: text is the honest floor, no attachment. + expect(block.models["kiro/odd"]?.attachment).toBe(false); + expect(block.models["kiro/odd"]?.modalities).toEqual({ input: ["text"], output: ["text"] }); + // Nothing declared: no capability fields, the client keeps its own default. + expect(block.models["kiro/undeclared"]).toEqual({ name: "undeclared (kiro)" }); + } + // The two blocks own separate modality objects. + blocks.v1.models["kiro/vision"]!.modalities!.input.push("mutated"); + expect(blocks.v2.models["kiro/vision"]?.modalities?.input).toEqual(["text", "image"]); + }); + + test("hand-assembled routed models carry inputModalities into the launch block", () => { + const block = buildOpencodeProviderBlock(10100, [], [ + { provider: "kiro", id: "vision", inputModalities: ["text", "image"] }, + { provider: "kiro", id: "plain" }, + ]); + expect(block.models["kiro/vision"]?.attachment).toBe(true); + expect(block.models["kiro/vision"]?.modalities).toEqual({ input: ["text", "image"], output: ["text"] }); + expect(block.models["kiro/plain"]?.attachment).toBeUndefined(); + }); + + test("opencodeCatalogFromProxyRows carries inputModalities from /api/models rows", () => { + const config = cfg(); + const catalog = opencodeCatalogFromProxyRows([ + { namespaced: "kiro/vision", provider: "kiro", id: "vision", inputModalities: ["text", "image"] }, + { namespaced: "kiro/plain", provider: "kiro", id: "plain", inputModalities: [] }, + { namespaced: "kiro/none", provider: "kiro", id: "none" }, + ], config); + expect(catalog.find(m => m.namespaced === "kiro/vision")?.inputModalities).toEqual(["text", "image"]); + expect(catalog.find(m => m.namespaced === "kiro/plain")?.inputModalities).toBeUndefined(); + expect(catalog.find(m => m.namespaced === "kiro/none")?.inputModalities).toBeUndefined(); + }); + test("duplicate keys keep the first entry instead of throwing", () => { const block = buildOpencodeProviderBlock(10100, [], [ { provider: "kiro", id: "dup", displayName: "First" },