Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs-site/src/content/docs/guides/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
30 changes: 7 additions & 23 deletions src/cli/export-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,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.
*
Expand All @@ -78,20 +68,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<string, string[]>();
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,
Expand All @@ -106,8 +89,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;
});
}
Expand Down Expand Up @@ -186,7 +170,7 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep
}
built = { document: exported.config, text: exported.text };
} else {
const rows = await runtimeRequest<ExportProxyModelRow[]>("/api/models", {}, { ...deps, baseUrl: root });
const rows = await runtimeRequest<OpencodeProxyModelRow[]>("/api/models", {}, { ...deps, baseUrl: root });
if (!Array.isArray(rows)) {
throw new RuntimeApiError("Management API returned an unexpected /api/models payload.", 502, rows);
}
Expand Down
5 changes: 5 additions & 0 deletions src/cli/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down Expand Up @@ -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] }
Expand Down
31 changes: 28 additions & 3 deletions src/clients/config-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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[] };
}

/**
Expand Down Expand Up @@ -614,13 +622,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 } : {}),
};
}
Expand Down
7 changes: 7 additions & 0 deletions src/clients/config-export/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
/**
Expand Down
33 changes: 33 additions & 0 deletions src/clients/config-export/model-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = 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: `"<displayName|id> (<native|provider|routed>)"`. The
* provider suffix is what makes two same-named models from different upstreams
Expand Down
97 changes: 97 additions & 0 deletions tests/clients/client-export-modality-enum.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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);
}
}
});
});
Loading
Loading