Skip to content
Open
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
44 changes: 42 additions & 2 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,34 @@ const THINKING_BUDGET_MODELS = [
const OPENCODE_GO_THINKING_BUDGET_MODELS = ["qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus"];
const DEEPSEEK_THINKING_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"];
const OPENCODE_FREE_DEEPSEEK_MODELS = ["deepseek-v4-flash-free"];
/*
* Zen free models that reject `image_url` upstream (#1043, and the reproducible
* half of #1024).
*
* Zen publishes NO modality metadata — its `/v1/models` returns only id, object,
* created, owned_by — so this list is measured, not derived. Each id was probed
* once against https://opencode.ai/zen/v1 on 2026-08-05 with a text control first
* and then a 1x1 PNG; the six below failed the image request, four of them with
* `[404] No endpoints found that support image input` and `big-pickle` with the
* exact deserialize error quoted in #1043.
*
* `mimo-v2.5-free` and `longcat-2.0-free` ACCEPT images and are deliberately
* absent. Adding them would silently replace a working image with a caption,
* which is worse than the loud 400 this list exists to prevent — see the negative
* assertion in tests/provider-registry-parity.test.ts.
*
* Zen's roster is discovered live while this list is static, so it is a dated
* exception list, not a capability model. Re-probe before extending it.
* Evidence: devlog/_plan/260805_bug_fix_stack/002_zen_modality_probe.md
*/
const OPENCODE_ZEN_TEXT_ONLY_MODELS = [
"big-pickle",
"nemotron-3-ultra-free",
"ling-3.0-flash-free",
"north-mini-code-free",
"laguna-s-2.1-free",
"deepseek-v4-flash-free",
];
/*
* DeepSeek's Codex ladder is low/high/max, and the two V4 models resolve it
* DIFFERENTLY. From the official thinking-mode table (api-docs.deepseek.com,
Expand Down Expand Up @@ -1694,7 +1722,17 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
autoToolChoiceOnlyModels: KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS,
preserveReasoningContentModels: KIMI_THINKING_MODELS,
},
{ id: "opencode-zen", label: "opencode zen", baseUrl: "https://opencode.ai/zen/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://opencode.ai/auth" },
{
id: "opencode-zen",
label: "opencode zen",
baseUrl: "https://opencode.ai/zen/v1",
adapter: "openai-chat",
authKind: "key",
dashboardUrl: "https://opencode.ai/auth",
// #1043: without this the proxy forwards image parts to text-only Zen models and
// the upstream rejects the whole request with a 400.
noVisionModels: OPENCODE_ZEN_TEXT_ONLY_MODELS,
},
{ id: "vercel-ai-gateway", label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://vercel.com/dashboard" },
{
id: "opencode-free",
Expand All @@ -1713,7 +1751,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
modelReasoningEfforts: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])),
modelReasoningEffortMap: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, deepseekReasoningMapFor(id)])),
preserveReasoningContentModels: OPENCODE_FREE_DEEPSEEK_MODELS,
noVisionModels: OPENCODE_FREE_DEEPSEEK_MODELS,
// Same Zen roster behind the same base URL, so it carries the same measured
// text-only list rather than only its DeepSeek member (#1043).
noVisionModels: OPENCODE_ZEN_TEXT_ONLY_MODELS,
},
{ id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.5-pro" },
{ id: "kilo", label: "Kilo", baseUrl: "https://api.kilo.ai/api/gateway", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://kilo.ai" },
Expand Down
41 changes: 41 additions & 0 deletions tests/provider-registry-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,47 @@ describe("provider registry parity", () => {
// so the request still reaches Command Code as `deepseek/deepseek-v4-flash`.
expect(entries.find(e => e.slug === "commandcode/deepseek-deepseek-v4-flash")).toBeTruthy();
});
/*
* #1043. Zen publishes no modality metadata, so the classification below is an
* empirical list measured against the live endpoint on 2026-08-05, not something
* derived from provider data.
*
* The negative half is the part that matters. `mimo-v2.5-free` and
* `longcat-2.0-free` accept images; listing them would silently swap a working
* image for a caption, which is a worse failure than the loud 400 this fixes
* because nothing surfaces it. This test exists so a future "classify all the
* free models" patch fails here instead of shipping.
*/
test("Zen text-only classification covers the measured models and excludes the vision ones", () => {
const measuredTextOnly = [
"big-pickle",
"nemotron-3-ultra-free",
"ling-3.0-flash-free",
"north-mini-code-free",
"laguna-s-2.1-free",
"deepseek-v4-flash-free",
];
// Measured as ACCEPTING images. Never add these to a noVisionModels list.
const measuredVisionCapable = ["mimo-v2.5-free", "longcat-2.0-free"];

for (const providerId of ["opencode-zen", "opencode-free"]) {
const entry = PROVIDER_REGISTRY.find(p => p.id === providerId);
expect(entry, `registry entry ${providerId} is missing`).toBeTruthy();
expect(entry?.baseUrl, `${providerId} should serve the Zen roster`)
.toBe("https://opencode.ai/zen/v1");

const listed = entry?.noVisionModels ?? [];
for (const model of measuredTextOnly) {
expect(listed, `${providerId} must strip images for text-only ${model}`)
.toContain(model);
}
for (const model of measuredVisionCapable) {
expect(listed, `${providerId} must NOT strip images for vision-capable ${model}`)
.not.toContain(model);
}
}
});

});

describe("free-provider directory isolation", () => {
Expand Down
89 changes: 89 additions & 0 deletions tests/vision-sidecar-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { saveConfig } from "../src/config";
import { startServer } from "../src/server";
import { PROVIDER_REGISTRY } from "../src/providers/registry";
import type { OcxConfig } from "../src/types";
import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home";
import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt";
Expand Down Expand Up @@ -195,4 +196,92 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => {
await server.stop(true);
}
});

/*
* #1043 activation evidence. The registry classification is only useful if the
* strip actually fires for a Zen model, so this drives the real path with the
* built-in `opencode-zen` list rather than a fixture list, and asserts the
* observable effect: the image bytes are gone from the upstream body and the
* omission marker is there instead.
*
* `big-pickle` is the id that reproduced the reported 400 verbatim against the
* live endpoint (devlog/_plan/260805_bug_fix_stack/002_zen_modality_probe.md).
*/
test("a text-only Zen model has its image stripped before the upstream request (#1043)", async () => {
let upstreamBody = "";
upstream = serveUpstream(b => { upstreamBody = b; });

const zen = PROVIDER_REGISTRY.find(p => p.id === "opencode-zen");
expect(zen?.noVisionModels).toContain("big-pickle");

const config: OcxConfig = {
port: 0, hostname: "127.0.0.1", defaultProvider: "zenlike", openaiProviderTierVersion: 2,
providers: {
// A custom provider carrying the REGISTRY's list verbatim. The built-in
// opencode-zen entry pins its own baseUrl, so it cannot be aimed at a local
// upstream; what is under test is the classification, which is read from the
// registry above rather than written out here.
zenlike: {
adapter: "openai-chat",
baseUrl: `http://127.0.0.1:${upstream.port}/v1`,
allowPrivateNetwork: true,
apiKey: "key-alpha-000111222333",
noVisionModels: zen?.noVisionModels,
},
openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" },
},
} as OcxConfig;
saveConfig(config);
const server = startServer(0);
try {
const res = await fetch(new URL("/v1/responses", server.url), {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer forward-oauth-token" },
body: JSON.stringify(baseRequest("zenlike/big-pickle")),
});
expect(res.status).toBe(200);
// The effect, not merely a 200: no image bytes on the wire, marker present.
expect(upstreamBody).not.toContain("aGVsbG8taW1hZ2UtYnl0ZXM=");
expect(upstreamBody).toContain("[image omitted");
} finally {
await server.stop(true);
}
});

test("a vision-capable Zen model keeps its image (#1043 negative case)", async () => {
let upstreamBody = "";
upstream = serveUpstream(b => { upstreamBody = b; });

const zen = PROVIDER_REGISTRY.find(p => p.id === "opencode-zen");
// Measured as accepting images; classifying it would silently degrade it.
expect(zen?.noVisionModels).not.toContain("mimo-v2.5-free");

const config: OcxConfig = {
port: 0, hostname: "127.0.0.1", defaultProvider: "zenlike", openaiProviderTierVersion: 2,
providers: {
zenlike: {
adapter: "openai-chat",
baseUrl: `http://127.0.0.1:${upstream.port}/v1`,
allowPrivateNetwork: true,
apiKey: "key-alpha-000111222333",
noVisionModels: zen?.noVisionModels,
},
openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" },
},
} as OcxConfig;
saveConfig(config);
const server = startServer(0);
try {
const res = await fetch(new URL("/v1/responses", server.url), {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer forward-oauth-token" },
body: JSON.stringify(baseRequest("zenlike/mimo-v2.5-free")),
});
expect(res.status).toBe(200);
expect(upstreamBody).toContain("aGVsbG8taW1hZ2UtYnl0ZXM=");
expect(upstreamBody).not.toContain("[image omitted");
} finally {
await server.stop(true);
}
});
});
Loading