diff --git a/src/providers/registry.ts b/src/providers/registry.ts index ae4907b80..317c6ffa7 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -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, @@ -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", @@ -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" }, diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 14b4fb1ac..f336a3b1b 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -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", () => { diff --git a/tests/vision-sidecar-e2e.test.ts b/tests/vision-sidecar-e2e.test.ts index 2e44ec3fb..1069fd22d 100644 --- a/tests/vision-sidecar-e2e.test.ts +++ b/tests/vision-sidecar-e2e.test.ts @@ -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"; @@ -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); + } + }); });