From 1b44edeb1c88cb3c747b5076853665e9a18b2762 Mon Sep 17 00:00:00 2001 From: jaychou0642-create <283093853+jaychou0642-create@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:05:34 +0800 Subject: [PATCH] fix(provider): treat deepseek-flash as native multimodal Closes #4436. - Remove 'deepseek-flash' from first-party DeepSeek's 'noVisionModels'. - Declare native 'text' and 'image' input in 'modelInputModalities'. - Keep compatibility aliases ('deepseek-chat', 'deepseek-reasoner', 'deepseek-v4-flash') and Zen gateway routes unchanged. - Update docs and structure SSOT files accordingly. - Add focused registry and end-to-end vision sidecar regression tests. --- .../src/content/docs/fr/guides/providers.md | 5 ++ .../src/content/docs/guides/providers.md | 5 ++ docs-site/src/content/docs/guides/sidecars.md | 4 ++ .../src/content/docs/tr/guides/providers.md | 5 ++ .../content/docs/zh-cn/guides/providers.md | 4 ++ .../content/docs/zh-tw/guides/providers.md | 4 ++ src/providers/registry.ts | 25 ++++----- structure/providers/xai-grok.md | 4 ++ structure/runtime.md | 4 ++ structure/subagents.md | 4 ++ structure/transports/inventory.md | 5 ++ .../provider-registry-parity.test.ts | 54 +++++++++++++++++-- tests/vision/vision-sidecar-e2e.test.ts | 46 ++++++++++++++++ 13 files changed, 154 insertions(+), 15 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 5594f6e436..406677cca7 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -364,6 +364,11 @@ Le préréglage DeepSeek intégré route également `deepseek-v4-flash` par son et conserve le streaming SSE en amont. Si ce modèle termine tous les éléments de sortie mais omet l'événement Responses final, opencodex applique une réparation après un délai de grâce de cinq secondes, limitée à ce modèle ; les flux mal formés ou partiels sont fermés comme incomplets, et non déclarés réussis. +Le modèle DeepSeek de première partie `deepseek-flash` déclare nativement les entrées `text` et `image` ; +les requêtes contenant une image sont donc envoyées directement à DeepSeek par défaut sans passer par le +sidecar de vision. Les déclarations explicites `noVisionModels` ou texte seul restent prioritaires. Les modèles +de première partie `deepseek-chat`, `deepseek-reasoner` et `deepseek-v4-flash` restent desservis par le sidecar +par défaut ; les routes Zen sont inchangées et n'ont pas été sondées dans cette mise à jour. > **Trois routes de facturation Volcengine :** `volcengine` correspond à l'API Ark facturée à l'usage, > `volcengine-coding-plan` consomme le quota Coding Plan et `volcengine-agent-plan` le quota Agent Plan. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index b6aada80a6..ddf4494c57 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -525,6 +525,11 @@ The built-in DeepSeek preset also routes `deepseek-v4-flash` over its native Res keeps upstream SSE streaming enabled. If that model finishes every output item but omits the final Responses event, opencodex applies a five-second model-scoped grace repair; malformed or partial streams close as incomplete rather than being reported as successful. +The first-party `deepseek-flash` model advertises native `text` and `image` input, so image requests +are sent directly to DeepSeek by default instead of through the vision sidecar. Explicit +`noVisionModels` or text-only declarations remain authoritative. First-party `deepseek-chat`, +`deepseek-reasoner`, and `deepseek-v4-flash` remain sidecar-backed by default. Zen routes are +unchanged and were not probed in this update. > **Three Volcengine billing routes:** `volcengine` is the pay-as-you-go Ark API, > `volcengine-coding-plan` consumes Coding Plan quota, and `volcengine-agent-plan` consumes Agent diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index cee0da3f07..d87c5b9513 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -135,6 +135,10 @@ allow attachments instead of blocking them before the sidecar runs. When use the `gpt-5.6-luna` fallback. Startup still migrates an explicitly persisted legacy `gpt-5.4-mini` value to `gpt-5.6-luna`; that migration applies to a stored value, not to an absent model field. +The first-party DeepSeek `deepseek-flash` model is native multimodal (`text` and `image`) and does +not use this sidecar by default. Explicit `noVisionModels` or text-only declarations remain +authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and `deepseek-v4-flash` remain +sidecar-backed by default; Zen routes are unchanged and were not probed in this update. - Images can come from user, developer, and tool-result messages, including Codex's `view_image`. - On the OpenAI path (ChatGPT-login passthrough), each image is sent to the configured vision model diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 003d309293..d72ec9b9df 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -410,6 +410,11 @@ yönlendirir ve yukarı akış SSE akışını etkin tutar. Bu model tüm çıkt bitirir ancak son Responses olayını atlarsa opencodex beş saniyelik model kapsamlı bir yetkisiz kullanım onarımı uygular; hatalı biçimlendirilmiş veya kısmi akışlar başarılı olarak bildirilmek yerine tamamlanmamış olarak kapanır. +Birinci taraf `deepseek-flash` modeli yerel olarak `text` ve `image` girdilerini bildirir; bu nedenle +görüntü içeren istekler varsayılan olarak vision sidecar üzerinden geçmeden doğrudan DeepSeek'e gönderilir. +Açık `noVisionModels` veya yalnızca metin bildirimleri önceliğini korur. Birinci taraf `deepseek-chat`, +`deepseek-reasoner` ve `deepseek-v4-flash` varsayılan olarak sidecar üzerinden çalışmaya devam eder; Zen +rotaları değişmedi ve bu güncellemede yoklanmadı. > **Üç Volcengine faturalandırma rotası:** `volcengine` kullandıkça öde Ark API'sidir, `volcengine-coding-plan` Coding Plan kotasını tüketir ve `volcengine-agent-plan` Agent Plan kotasını tüketir. Aynı ürün için verilen anahtarı ve uç noktayı kullanın; sıradan `/api/v3` uç noktası bir Plan aboneliği mevcut olduğunda bile kullandıkça öde ücretlerine neden olabilir. Önayarlar özenle seçilmiş statik model katalogları kullanır çünkü Ark'ın `/models` yanıtı yerleştirme, görsel, video ve 3D kaynaklarını da içerir, Coding ağ geçidi aynı geniş kataloğu döndürür ve Agent Plan ağ geçidinin `/models` kaynağı yoktur. Kullandıkça öde varsayılan olarak `doubao-seed-2-1-pro-260628`'dir; seçilmiş kataloğu güncel DeepSeek ve GLM metin modellerini de içerir. Coding Plan varsayılan olarak `ark-code-latest`, Agent Plan ise varsayılan olarak `deepseek-v4-flash`'dur. diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index b2aafbcd3a..06cd824af6 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -236,6 +236,10 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 内置 DeepSeek preset 同样会让 `deepseek-v4-flash` 使用原生 Responses 端点,并保留上游 SSE 流式输出。如果该模型已经完成全部输出项却缺少最终 Responses 事件,opencodex 会应用模型级 5 秒宽限修复;不完整或格式异常的流会以 incomplete 结束,不会被误报为成功。 +第一方 `deepseek-flash` 模型原生声明支持 `text` 和 `image` 输入,因此图像请求默认会直接发送给 +DeepSeek,不经过 vision sidecar。显式的 `noVisionModels` 或纯文本声明仍然优先。第一方 +`deepseek-chat`、`deepseek-reasoner` 和 `deepseek-v4-flash` 默认仍使用 sidecar;Zen 路由保持不变, +本次更新未进行探测。 > **三条火山方舟计费线路:**`volcengine` 是按量付费方舟 API,`volcengine-coding-plan` > 消耗 Coding Plan 额度,`volcengine-agent-plan` 消耗 Agent Plan 额度。密钥与端点需要属于 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 5bb74c0bb0..7b403bd832 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -316,6 +316,10 @@ provider,例如 **Xiaomi MiMo**,使用 `anthropic` adapter(`x-api-key`) 原生 Responses endpoint,並保持上游 SSE streaming。若該模型完成所有 output item 卻省略最後的 Responses event,opencodex 會套用 5 秒、model-scoped 的 grace repair;malformed 或 partial stream 會以 incomplete 關閉,不會被誤報為成功。 +第一方 `deepseek-flash` 模型原生宣告支援 `text` 與 `image` 輸入,因此圖片請求預設會直接送往 +DeepSeek,不經過 vision sidecar。明確的 `noVisionModels` 或純文字宣告仍然優先。第一方 +`deepseek-chat`、`deepseek-reasoner` 與 `deepseek-v4-flash` 預設仍使用 sidecar;Zen 路由維持不變, +本次更新未進行探測。 > **三條 Volcengine 計費路徑:** `volcengine` 是 pay-as-you-go Ark API, > `volcengine-coding-plan` 消耗 Coding Plan quota,`volcengine-agent-plan` 消耗 Agent Plan quota。請使用 diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 08096f0fb7..d22b1c4e66 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -683,10 +683,9 @@ const DEEPSEEK_V4_LEGACY_MODELS = ["deepseek-v4-flash"]; const DEEPSEEK_NATIVE_THINKING_MODELS = ["deepseek-flash", "deepseek-v4-flash"]; const DEEPSEEK_GATEWAY_THINKING_MODELS = ["deepseek-v4.1-flash", "deepseek-v4-flash"]; /* - * DeepSeek's experimental vision preview (released 2026-08-21, api-docs.deepseek.com): - * text+image input on the V4 Flash base. DeepSeek positions it as a preview id; - * the expectation is that vision merges into `deepseek-v4-flash` proper later, - * at which point this id retires the same way deepseek-chat/reasoner did. + * DeepSeek's legacy vision preview id (released 2026-08-21). First-party probes + * in #4436 resolve it to image-capable `deepseek-flash`; retain the existing + * declarations because gateway support is specific to each served identifier. */ const DEEPSEEK_VISION_PREVIEW_MODEL = "deepseek-v4-flash-vision-exp"; /** @@ -2164,9 +2163,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // the list only as compatibility aliases so existing saved configs and requests // keep validating and routing (they previously mapped to v4-flash; devlog // _fin/260710_provider_hardening/002_research_cn.md). The current offerings are - // the V4 ids — defaultModel and the model-specific wiring above use them. - // deepseek-v4-flash-vision-exp: experimental vision preview (2026-08-21) — - // expected to merge into deepseek-v4-flash later; see DEEPSEEK_VISION_PREVIEW_MODEL. + // V4.1-Flash — defaultModel and the model-specific wiring below use its live id. + // Keep the legacy vision-preview alias; see DEEPSEEK_VISION_PREVIEW_MODEL. models: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_NATIVE_THINKING_MODELS, DEEPSEEK_VISION_PREVIEW_MODEL], // V4.1-Flash is the current first-party offering; `deepseek-v4-flash` now routes there // as a compatibility alias, so a new install should ask for the live id by name. @@ -2174,7 +2172,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Official DeepSeek Codex setup (codex-deepseek-setup.sh) advertises 1,048,576 // for both V4 models; the older 1,000,000 figure was a rounded approximation. modelContextWindows: { "deepseek-flash": 1_048_576, "deepseek-v4-flash": 1_048_576, [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576 }, - modelInputModalities: { [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"] }, + modelInputModalities: { + "deepseek-flash": ["text", "image"], + [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], + }, // DeepSeek documents both V4 models as native Responses API models adapted for Codex // (model table marks Responses API ✓ for flash and pro; the /responses reference lists // both ids as accepted `model` values — verified 2026-08-13 with the V4 Pro GA, @@ -2244,10 +2245,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelReasoningEffortMap: Object.fromEntries(DEEPSEEK_NATIVE_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), modelSupportsReasoningSummaries: Object.fromEntries(DEEPSEEK_NATIVE_THINKING_MODELS.map(id => [id, true])), preserveReasoningContentModels: DEEPSEEK_NATIVE_THINKING_MODELS, - // Issue #88: every DeepSeek API model is text-only input (no image support upstream) — the - // vision sidecar describes attached images for them, and the catalog advertises image input - // on their behalf (same treatment as opencode-go's DeepSeek V4 entries above). - noVisionModels: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_NATIVE_THINKING_MODELS], + // #4436: first-party deepseek-flash accepts native images on Chat and Responses. + // Keep unprobed compatibility aliases on the #88 sidecar path. This must be fixed + // here: router enrichment unions this list with saved config, so config cannot remove it. + noVisionModels: ["deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash"], }, // llama-3.3-70b was deprecated by Cerebras on 2026-02-16. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. { id: "cerebras", label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "gpt-oss-120b" }, diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index ada68474a8..df6acdd1ef 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -94,6 +94,10 @@ Devin CLI credential path composition in `src/oauth/devin-cli.ts` follows the se Provider-scoped catalog hints remain isolated by provider in `src/providers/registry.ts`. The OpenCode Go `deepseek-v4.1-flash` 1,048,576-token context hint does not change xAI model metadata or transport behavior. +The first-party DeepSeek `deepseek-flash` native `text`/`image` declaration is likewise scoped to +the DeepSeek provider and does not alter xAI metadata or transport behavior; explicit capability +overrides remain authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and +`deepseek-v4-flash` remain sidecar-backed by default. Zen routes are unchanged and unprobed here. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. diff --git a/structure/runtime.md b/structure/runtime.md index 35b1e1f31a..1906f3a643 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -189,6 +189,10 @@ Provider-scoped capability hints remain authoritative when discovery returns an capabilities. In particular, `src/providers/registry.ts` assigns OpenCode Go's live `deepseek-v4.1-flash` route the official 1,048,576-token window instead of the conservative 128k routed-model fallback. +The same registry declares the first-party `deepseek-flash` model with `text` and `image` input, +so it bypasses the vision sidecar by default; explicit `noVisionModels` or text-only declarations +remain authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and `deepseek-v4-flash` +remain sidecar-backed by default. Zen routes are unchanged and unprobed in this update. The BigModel Coding Plan Responses preset uses the separately documented `https://open.bigmodel.cn/api/v1` transport and a static catalog. Its provider row diff --git a/structure/subagents.md b/structure/subagents.md index 16ab264e20..7ab146a048 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -117,6 +117,10 @@ featured or picker rank. Canonical `opencode-go` rows retain their configured re and provider-scoped context metadata both when generated and when merged from retained catalog state; `deepseek-v4.1-flash` therefore keeps its 1,048,576-token window, while synthetic max/ultra choices are not added to that provider's declared ladder. +The first-party DeepSeek `deepseek-flash` row declares native `text` and `image` input and therefore +does not require the vision sidecar by default; explicit `noVisionModels` or text-only declarations +remain authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and `deepseek-v4-flash` +remain sidecar-backed by default. Zen routes are unchanged and unprobed in this update. Full derivation with per-line citations: `devlog/_plan/260816_codexrs_multiagent_v2_and_history_perf/013_five_cap_v1_vs_v2.md`. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index b2ba773ba1..2ec7c0e010 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -28,6 +28,11 @@ surface is listed here so a maintainer can find the owner without grepping: | Alibaba regions | `src/providers/alibaba-region-backup.ts`, `src/providers/alibaba-region-migration.ts`, `src/providers/alibaba-region-startup.ts` | Region migration backs up before rewriting and is idempotent across restarts. | | Discovery and quota | `src/providers/model-discovery.ts`, `src/providers/quota.ts`, `src/providers/registry.ts` | Discovery rejects a response over 4 MiB or past 2,000 raw rows before caching it. Provider-scoped hints fill capabilities omitted by live rosters; OpenCode Go's `deepseek-v4.1-flash` keeps its 1,048,576-token context window. Codex quota DTOs suppress retired Spark evidence under the [OpenAI scope contract](../providers/openai-tiers.md#public-provider-contract), retaining ordinary custom windows. | +The registry's first-party `deepseek-flash` row declares native `text` and `image` input, so image +requests bypass the vision sidecar by default; explicit `noVisionModels` or text-only declarations +remain authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and `deepseek-v4-flash` +remain sidecar-backed by default. Zen routes are unchanged and unprobed in this update. + > Decision record: [ADR-0072](../decisions/ADR-0072-transport-inventory.md) Cursor external-model continuations attach data-URL screenshots from the contiguous active diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 8911bc4a1f..46579009ce 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -20,6 +20,7 @@ import { FREE_PROVIDER_DIRECTORY } from "../../src/providers/free-directory"; import { applyProviderConfigHints } from "../../src/codex/catalog"; import { routeModel } from "../../src/router"; import { resolveAdapter } from "../../src/server"; +import { isModelVisionSidecarConsumer } from "../../src/vision/eligibility"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; function nativeTemplate(): Record { @@ -112,7 +113,7 @@ describe("provider registry parity", () => { expect(Object.keys(map ?? {})).toContain("deepseek-flash"); } expect(nativeDeepseek?.preserveReasoningContentModels).toContain("deepseek-flash"); - expect(nativeDeepseek?.noVisionModels).toContain("deepseek-flash"); + expect(nativeDeepseek?.noVisionModels).not.toContain("deepseek-flash"); // The new id keeps the Flash ladder, not the Pro one, through isDeepseekFlashModel. expect(nativeDeepseek?.modelReasoningEfforts?.["deepseek-flash"]) .toEqual(nativeDeepseek?.modelReasoningEfforts?.["deepseek-v4-flash"]); @@ -239,9 +240,9 @@ describe("provider registry parity", () => { expect(KEY_LOGIN_PROVIDERS.deepseek.modelReasoningEffortMap?.["deepseek-v4-flash"]?.max).toBe("max"); expect(KEY_LOGIN_PROVIDERS.deepseek.preserveReasoningContentModels) .toEqual(["deepseek-flash", "deepseek-v4-flash"]); - // Issue #88: every DeepSeek API model is text-only input — the vision sidecar covers them. + // #4436: first-party Flash accepts images; unprobed compatibility aliases keep the sidecar. expect(KEY_LOGIN_PROVIDERS.deepseek.noVisionModels).toEqual([ - "deepseek-chat", "deepseek-reasoner", "deepseek-flash", "deepseek-v4-flash", + "deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash", ]); }); @@ -456,6 +457,53 @@ describe("provider registry parity", () => { expect(neuralwatt?.preserveReasoningContentModels).not.toContain("moonshotai/Kimi-K2.5"); }); + test("first-party DeepSeek Flash advertises native images without widening gateway aliases (#4436)", () => { + const provider = providerConfigSeed(PROVIDER_REGISTRY.find(entry => entry.id === "deepseek")!); + expect(KEY_LOGIN_PROVIDERS.deepseek.modelInputModalities?.["deepseek-flash"]).toEqual(["text", "image"]); + expect(provider.modelInputModalities?.["deepseek-flash"]).toEqual(["text", "image"]); + expect(isModelVisionSidecarConsumer(provider, "deepseek-flash")).toBe(false); + expect(isModelVisionSidecarConsumer(provider, "deepseek-v4-flash-vision-exp")).toBe(false); + for (const model of ["deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash"]) { + expect(isModelVisionSidecarConsumer(provider, model)).toBe(true); + } + for (const id of ["opencode-go", "opencode-zen"]) { + const gateway = providerConfigSeed(PROVIDER_REGISTRY.find(entry => entry.id === id)!); + expect(isModelVisionSidecarConsumer(gateway, "deepseek-v4.1-flash")).toBe(true); + expect(isModelVisionSidecarConsumer(gateway, "deepseek-v4-flash")).toBe(true); + } + const free = providerConfigSeed(PROVIDER_REGISTRY.find(entry => entry.id === "opencode-free")!); + expect(isModelVisionSidecarConsumer(free, "deepseek-v4-flash-free")).toBe(true); + // Saved providers without explicit modality overrides inherit the fix during routing. + const config: OcxConfig = { + port: 0, defaultProvider: "deepseek", + providers: { deepseek: { adapter: "openai-chat", baseUrl: "https://api.deepseek.com", authMode: "key" } }, + }; + const route = routeModel(config, "deepseek/deepseek-flash"); + expect(isModelVisionSidecarConsumer(route.provider, route.modelId)).toBe(false); + const model = applyProviderConfigHints("deepseek", route.provider, { provider: "deepseek", id: route.modelId }); + expect(model.inputModalities).toEqual(["text", "image"]); + const catalog = buildCatalogEntries(nativeTemplate(), [], [model]); + expect(catalog.find(entry => entry.slug === "deepseek/deepseek-flash")?.input_modalities).toEqual(["text", "image"]); + + // Existing saved providers that previously persisted the old seed continue using the sidecar + // until deepseek-flash is removed from their saved noVisionModels list. + const legacyConfig: OcxConfig = { + port: 0, defaultProvider: "deepseek", + providers: { + deepseek: { + adapter: "openai-chat", baseUrl: "https://api.deepseek.com", authMode: "key", + noVisionModels: ["deepseek-chat", "deepseek-reasoner", "deepseek-flash", "deepseek-v4-flash"], + }, + }, + }; + const legacyRoute = routeModel(legacyConfig, "deepseek/deepseek-flash"); + expect(isModelVisionSidecarConsumer(legacyRoute.provider, legacyRoute.modelId)).toBe(true); + // Once deepseek-flash is removed from saved config, native vision is unlocked. + legacyConfig.providers.deepseek.noVisionModels = ["deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash"]; + const upgradedRoute = routeModel(legacyConfig, "deepseek/deepseek-flash"); + expect(isModelVisionSidecarConsumer(upgradedRoute.provider, upgradedRoute.modelId)).toBe(false); + }); + test("Z.AI and Kimi context aliases route with bracket-suffix stripping", () => { const zai = PROVIDER_REGISTRY.find(entry => entry.id === "zai"); const optedInProviders = PROVIDER_REGISTRY diff --git a/tests/vision/vision-sidecar-e2e.test.ts b/tests/vision/vision-sidecar-e2e.test.ts index d88b0070df..29c06c4fe9 100644 --- a/tests/vision/vision-sidecar-e2e.test.ts +++ b/tests/vision/vision-sidecar-e2e.test.ts @@ -413,6 +413,52 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => { } }); + test.each(["openai-chat", "openai-responses"] as const)("DeepSeek Flash preserves native images on the %s wire without a sidecar call (#4436)", async adapter => { + let upstreamBody = ""; + let sidecarHits = 0; + upstream = adapter === "openai-chat" + ? serveUpstream(b => { upstreamBody = b; }) + : serveResponsesUpstream(b => { upstreamBody = b; }); + sidecar = serveResponsesUpstream(() => { sidecarHits += 1; }); + const deepseek = PROVIDER_REGISTRY.find(entry => entry.id === "deepseek")!; + const config: OcxConfig = { + port: 0, hostname: "127.0.0.1", defaultProvider: "deepseeklike", + providers: { + // Carry the real registry classification to a loopback fixture on each wire. + deepseeklike: { + adapter, authMode: "key", baseUrl: upstream.url.toString().replace(/\/$/, ""), + allowPrivateNetwork: true, apiKey: "key-alpha-000111222333", + noVisionModels: deepseek.noVisionModels, + modelInputModalities: deepseek.modelInputModalities, + }, + helper: { + adapter: "openai-responses", authMode: "key", baseUrl: sidecar.url.toString().replace(/\/$/, ""), + allowPrivateNetwork: true, apiKey: "key-alpha-000111222333", + modelInputModalities: { "vision-model": ["text", "image"] }, + }, + }, + visionSidecar: { enabled: true, backend: "routed", model: "helper/vision-model" }, + }; + saveConfig(config); + const server = startServer(0); + try { + const res = await fetch(new URL("/v1/responses", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify(baseRequest("deepseeklike/deepseek-flash")), + }); + expect(res.status).toBe(200); + expect(sidecarHits).toBe(0); + const body = JSON.parse(upstreamBody); + const content = adapter === "openai-chat" ? body.messages[0].content : body.input[0].content; + expect(content).toContainEqual(adapter === "openai-chat" + ? expect.objectContaining({ type: "image_url", image_url: expect.objectContaining({ url: PNG_DATA_URL }) }) + : expect.objectContaining({ type: "input_image", image_url: PNG_DATA_URL })); + expect(upstreamBody).not.toContain("[image omitted"); + } finally { + 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