Compare commits

...

1 Commits

Author SHA1 Message Date
diegosouzapw
a65cbbcee0 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
2026-08-04 05:20:07 -03:00
3 changed files with 111 additions and 5 deletions

View File

@@ -0,0 +1 @@
- fix(opencode): propagate vision capability from live catalog into opencode.json (#8960)

View File

@@ -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<ExistingModelEntry, "attachment" | "reasoning" | "temperature" | "tool_call"> {
const result: Pick<ExistingModelEntry, "attachment" | "reasoning" | "temperature" | "tool_call"> = {};
// 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.

View File

@@ -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