diff --git a/src/shared/load-opencode-plugins.ts b/src/shared/load-opencode-plugins.ts index a6beffdcf..1d8664aa7 100644 --- a/src/shared/load-opencode-plugins.ts +++ b/src/shared/load-opencode-plugins.ts @@ -51,12 +51,12 @@ export function loadOpencodePlugins(directory: string): string[] { const result = parseJsoncSafe(content) const plugins = result.data?.plugin ?? [] - for (const rawPlugin of plugins) { - const plugin = typeof rawPlugin === "string" ? rawPlugin : Array.isArray(rawPlugin) ? rawPlugin[0] : null - if (typeof plugin !== "string") continue - if (seenPluginEntries.has(plugin)) continue - seenPluginEntries.add(plugin) - pluginEntries.push(plugin) + for (const plugin of plugins) { + const entry = typeof plugin === "string" ? plugin : Array.isArray(plugin) ? plugin[0] : null + if (typeof entry !== "string") continue + if (seenPluginEntries.has(entry)) continue + seenPluginEntries.add(entry) + pluginEntries.push(entry) } } catch { continue diff --git a/src/shared/model-capabilities/runtime-model-readers.test.ts b/src/shared/model-capabilities/runtime-model-readers.test.ts new file mode 100644 index 000000000..2433d9a45 --- /dev/null +++ b/src/shared/model-capabilities/runtime-model-readers.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "bun:test" +import { readRuntimeModelModalities } from "./runtime-model-readers" + +describe("readRuntimeModelModalities", () => { + describe("object-shaped modalities (OpenCode schema)", () => { + it("#given modalities with input/output string arrays #when reading runtime model #then returns normalized modalities", () => { + const runtimeModel = { + id: "test-model", + modalities: { + input: ["text", "image", "pdf"], + output: ["text"], + }, + } + + const result = readRuntimeModelModalities(runtimeModel as Record) + + expect(result).toBeDefined() + expect(result?.input).toEqual(["text", "image", "pdf"]) + expect(result?.output).toEqual(["text"]) + }) + + it("#given modalities with mixed-case strings #when reading runtime model #then lowercases all entries", () => { + const runtimeModel = { + id: "test-model", + modalities: { + input: ["Text", "IMAGE", "Pdf"], + output: ["TEXT"], + }, + } + + const result = readRuntimeModelModalities(runtimeModel as Record) + + expect(result?.input).toEqual(["text", "image", "pdf"]) + expect(result?.output).toEqual(["text"]) + }) + + it("#given modalities nested in capabilities #when reading runtime model #then finds and normalizes them", () => { + const runtimeModel = { + id: "test-model", + capabilities: { + modalities: { + input: ["text", "audio"], + output: ["text"], + }, + }, + } + + const result = readRuntimeModelModalities(runtimeModel as Record) + + expect(result).toBeDefined() + expect(result?.input).toEqual(["text", "audio"]) + expect(result?.output).toEqual(["text"]) + }) + }) + + describe("flat string array modalities", () => { + it("#given modalities as boolean map at top level #when reading runtime model #then returns undefined (not supported at this level)", () => { + const runtimeModel = { + id: "test-model", + modalities: { + text: true, + image: true, + audio: false, + }, + } + + const result = readRuntimeModelModalities(runtimeModel as Record) + + // Boolean maps are only recognized inside input/output keys, not at the modalities root + expect(result).toBeUndefined() + }) + }) + + describe("no crash on unexpected shapes", () => { + it("#given modalities is undefined #when reading runtime model #then returns undefined", () => { + const runtimeModel = { id: "test-model" } + + const result = readRuntimeModelModalities(runtimeModel as Record) + + expect(result).toBeUndefined() + }) + + it("#given modalities contains nested arrays #when reading runtime model #then does not throw", () => { + const runtimeModel = { + id: "test-model", + modalities: { + input: [["text", "image"], ["pdf"]], + output: [["text"]], + }, + } + + expect(() => { + readRuntimeModelModalities(runtimeModel as Record) + }).not.toThrow() + }) + + it("#given modalities values are non-string non-array #when reading runtime model #then does not throw", () => { + const runtimeModel = { + id: "test-model", + modalities: { + input: 42, + output: null, + }, + } + + expect(() => { + readRuntimeModelModalities(runtimeModel as Record) + }).not.toThrow() + }) + }) +}) diff --git a/src/shared/model-capabilities/runtime-model-readers.ts b/src/shared/model-capabilities/runtime-model-readers.ts index a7b740f32..55b54e077 100644 --- a/src/shared/model-capabilities/runtime-model-readers.ts +++ b/src/shared/model-capabilities/runtime-model-readers.ts @@ -22,7 +22,7 @@ function readStringArray(value: unknown): string[] | undefined { function normalizeVariantKeys(value: unknown): string[] | undefined { const arrayVariants = readStringArray(value) if (arrayVariants) { - return arrayVariants.map((variant) => variant.toLowerCase()) + return arrayVariants.filter((v): v is string => typeof v === "string").map((variant) => variant.toLowerCase()) } if (!isRecord(value)) { @@ -36,13 +36,24 @@ function normalizeVariantKeys(value: unknown): string[] | undefined { function readModalityKeys(value: unknown): string[] | undefined { const stringArray = readStringArray(value) if (stringArray) { - return stringArray.map((entry) => entry.toLowerCase()) + return stringArray.filter((entry): entry is string => typeof entry === "string").map((entry) => entry.toLowerCase()) } if (!isRecord(value)) { return undefined } + // Handle OpenCode's object-shaped modalities: { input: string[], output: string[] } + // When the full modalities object reaches here (e.g. via the normalizeModalities + // fallback path), flatten nested string arrays before applying toLowerCase. + const fromNested = Object.values(value) + .filter((v): v is string[] => Array.isArray(v)) + .flat() + .filter((item): item is string => typeof item === "string") + if (fromNested.length > 0) { + return fromNested.map((entry) => entry.toLowerCase()) + } + const enabled = Object.entries(value) .filter(([, supported]) => supported === true) .map(([modality]) => modality.toLowerCase())