Skip to content

fix(core): preserve images for multimodal DeepSeek - #9854

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
shenyankm:fix/deepseek-vision-modalities-9832
Aug 26, 2026
Merged

fix(core): preserve images for multimodal DeepSeek#9854
wenshao merged 1 commit into
QwenLM:mainfrom
shenyankm:fix/deepseek-vision-modalities-9832

Conversation

@shenyankm

Copy link
Copy Markdown
Contributor

What this PR does

This change preserves multipart image input for DeepSeek-compatible routes when the selected model explicitly declares image support. Text-only DeepSeek routes retain their existing request normalization, and regression coverage exercises both the official endpoint and a custom OpenAI-compatible gateway.

Why it's needed

DeepSeek provider selection also matches model names on custom gateways. That path previously flattened every multipart message after capability resolution, replacing an image with an unsupported-content placeholder even when the user explicitly configured image input. Vision-capable DeepSeek models therefore could not receive images through Qwen Code.

Reviewer Test Plan

How to verify

Configure a DeepSeek-named OpenAI-compatible model with image input enabled, send an image, and confirm the backend receives multipart content and describes the image. Then use a text-only DeepSeek configuration and confirm its multipart content still follows the existing string-flattening behavior.

Evidence (Before & After)

Before: the outgoing image part became [Unsupported content type: image_url].

After: the same local CLI path sent a real PNG to deepseek-v4-flash-vision-exp; the model correctly identified a purple-violet interlocking angular mark and a blue circle. The focused provider test passes all 29 cases.

Tested on

OS Status
🍏 macOS ⚠️ not tested
🪟 Windows ⚠️ not tested
🐧 Linux ✅ tested

Environment (optional)

Local source CLI without sandbox; Node.js 24.18.0. Focused provider tests and the core package typecheck passed.

Risk & Scope

  • Main risk or tradeoff: a text-only endpoint incorrectly configured with image support will now receive multipart input and may reject it; the explicit capability declaration is treated as authoritative.
  • Not validated / out of scope: other media modalities and live testing on macOS or Windows.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #9832

中文说明

本 PR 的改动

当选中的模型明确声明支持图片输入时,此改动为 DeepSeek 兼容路由保留多段图片内容。纯文本 DeepSeek 路由继续使用现有请求整形,并通过官方端点和自定义 OpenAI 兼容网关的回归用例覆盖该行为。

为什么需要此改动

DeepSeek 提供商选择也会在自定义网关上按模型名匹配。此前该路径会在能力解析后无条件扁平化所有多段消息,即使用户明确配置了图片输入,图片仍会被替换成不支持内容类型的占位符。因此,具备视觉能力的 DeepSeek 模型无法通过 Qwen Code 接收图片。

审查者测试计划

验证方式

配置一个启用图片输入的 DeepSeek 命名 OpenAI 兼容模型,发送图片,并确认后端收到多段内容且能够描述图片。随后使用纯文本 DeepSeek 配置,确认其多段内容仍采用现有字符串扁平化行为。

证据(修复前后)

修复前:发出的图片部分会变成 [Unsupported content type: image_url]

修复后:同一本地 CLI 路径向 deepseek-v4-flash-vision-exp 发送了真实 PNG;模型正确识别出紫罗兰色交织棱角图形和蓝色圆形。提供商专项测试的 29 个用例全部通过。

测试平台

操作系统 状态
macOS ⚠️ 未测试
Windows ⚠️ 未测试
Linux ✅ 已测试

环境(可选)

本地源码 CLI,未启用沙箱;Node.js 24.18.0。提供商专项测试和核心包类型检查均已通过。

风险与范围

  • 主要风险或取舍:如果纯文本端点被错误配置为支持图片,它现在会收到多段输入并可能拒绝请求;此改动将显式能力声明视为权威配置。
  • 未验证或不在范围内:其他媒体模态,以及 macOS 或 Windows 上的真实测试。
  • 破坏性变更或迁移说明:无。

关联问题

Fixes #9832

@shenyankm

Copy link
Copy Markdown
Contributor Author

E2E test report

  • Surface: local source CLI on Linux, no sandbox
  • Route: OpenAI-compatible provider, https://api.deepseek.com, model deepseek-v4-flash-vision-exp
  • Capability: explicit generationConfig.modalities.image: true
  • Credential: existing project DEEPSEEK_API_KEY; the value was never printed or copied
  • Input: a real PNG containing a purple-violet interlocking angular mark and a blue circle
  • Observed: the outgoing request retained its image_url content part and the live model response correctly described the image as a vivid purple-violet interlocking angular knot with a lighter blue circle at the top right
  • Result: PASS

Supporting checks after rebasing onto current upstream main:

  • npx vitest run src/core/openaiContentGenerator/provider/deepseek.test.ts — 29/29 passed
  • npm run typecheck in packages/core — passed

The isolated runtime configuration was removed after the test.

@shenyankm
shenyankm marked this pull request as ready for review August 24, 2026 08:20
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template: complete ✓ — all required sections present, with the bilingual summary.

Problem: observed bug, not theory. #9832 reports that an image sent to a DeepSeek-named vision model (deepseek-v4-flash-vision-exp) comes back as [Unsupported content type: image_url] even though the user declared modalities.image: true. I traced the code path and the mechanism checks out: the converter honors the declared modalities and emits a real image_url part, then DeepSeekOpenAICompatibleProvider.buildRequest unconditionally flattens every message afterwards, clobbering the declaration. The bug is real.

Direction: aligned. qwen-code already carries a full input-modalities contract (explicit declaration, auto-detection fallback, converter placeholders for unsupported types). This PR makes the DeepSeek route respect that contract instead of overriding it — it fixes an inconsistency rather than adding a new surface. No direct CHANGELOG reference, but the area is clearly established in the codebase.

Size: core path (packages/core/src/core/openaiContentGenerator/provider/): 61 production lines (+33/−28 in deepseek.ts), 37 test lines (+37 in deepseek.test.ts), 0 generated/schema. fix type, well below any Stage 0 threshold — no maintainer escalation on size.

Approach: the scope feels right. One condition gating the flatten on the declared capability reuses the existing contract instead of inventing a new knob; the doc-comment updates track the new behavior; nothing unrelated in the diff. The declared risk (a text-only endpoint misconfigured with image: true now receives multipart and may reject it) is inherent to treating the explicit declaration as authoritative — same semantics as every other OpenAI-compatible route.

Risk: openaiContentGenerator is on the revert-correlated high-risk path list, so this gets full-depth review with CI evidence required before any approval — flagged here so reviewers know where to focus.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板:完整 ✓ —— 所有必填段落齐全,含中文对照。

问题:已观测到的 bug,不是理论性问题。#9832 报告:向 DeepSeek 命名的视觉模型(deepseek-v4-flash-vision-exp)发送图片时,即使用户声明了 modalities.image: true,图片仍被替换成 [Unsupported content type: image_url]。我追踪了代码路径,机制属实:converter 会遵循声明的 modalities 生成真正的 image_url 片段,但 DeepSeekOpenAICompatibleProvider.buildRequest 随后无条件扁平化所有消息,把声明覆盖掉了。bug 真实存在。

方向:对齐。qwen-code 已有完整的输入模态契约(显式声明、自动检测回退、对不支持类型的占位符处理)。本 PR 让 DeepSeek 路由遵守该契约而不是覆盖它——修复的是不一致,而非新增能力面。CHANGELOG 无直接引用,但该领域在代码库中已明确确立。

规模:核心路径(packages/core/src/core/openaiContentGenerator/provider/):61 行生产代码deepseek.ts +33/−28),37 行测试deepseek.test.ts +37),0 行生成/schema。fix 类型,远低于 Stage 0 各阈值——无需因规模转交维护者。

方案:范围合理。用一个条件按声明的能力门控扁平化,复用现有契约而非新增开关;文档注释与新行为同步更新;diff 无夹带改动。已声明的风险(纯文本端点被误配 image: true 时会收到多段内容并可能拒绝)是"以显式声明为权威"的固有语义——与其他 OpenAI 兼容路由一致。

风险openaiContentGenerator 在回滚相关的高风险路径清单中,因此本 PR 将获得全深度审查,且批准前必须有 CI 证据——在此标出,方便审阅者聚焦。

进入代码审查 🔍

Qwen Code · qwen3.8-max

Reviewed at bfa97bb247c8212c5610287724155dc96213708f · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review. Before reading the diff, my own proposal for this bug was exactly what landed here: gate the DeepSeek flatten on the declared input modalities, because the converter already replaces media parts for undeclared modalities — the provider-level flatten was a second, unconditional layer clobbering that decision. The implementation is one condition in buildRequest, no new machinery.

No correctness blockers found:

  • Text-only routes are byte-identical to before. modalities unset or image falsy → the same flattenContentParts path runs, and the existing flatten tests still pin that behavior. Auto-detection resolves every deepseek* model to text-only {}, so nothing changes for users who didn't declare anything.
  • The thinking-mode replay fix is untouched. ensureReasoningContentOnAssistantMessage runs on both branches, so reasoning_content still lands on assistant turns regardless of the flatten gate.
  • No new wire format is invented. With image declared, the DeepSeek route now sends exactly what any other OpenAI-compatible provider sends for a declared-image model — it stops being a special case.
  • The new tests pin the fix through real provider selection (determineProvider), covering both api.deepseek.com and a custom gateway matched by model name, and also check the original request isn't mutated.

One non-blocking nit: the gate is modalities?.image only — a DeepSeek route declaring pdf/audio/video would still flatten. That matches the reported problem and the PR's stated scope; a follow-up only makes sense if such a model actually appears.

The two-layer interaction that caused the bug, and where the gate sits:

sequenceDiagram
    participant P1 as User image message
    participant P2 as Pipeline request context
    participant P3 as Converter
    participant P4 as DeepSeek buildRequest
    participant P5 as Backend wire
    P1->>P2: model config declares image input
    P2->>P3: context carries declared modalities
    P3->>P4: keeps image_url part, placeholders for undeclared types
    P4->>P4: gate on declared image capability
    P4->>P5: multipart preserved, or flattened to text as before
Loading

Test evidence — this PR's own CI on the reviewed commit (unattended run: the gate never executes PR code; this is read from the PR's own CI via the API):

CI on bfa97bb — full unit suite green, no failures:

Check Conclusion
Test (ubuntu-latest, Node 22.x) — full unit suite ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
Security Checks — Secret scan (TruffleHog) ✅ success
Security Checks — Dependency CVE audit ✅ success
Security Checks — precheck ✅ success
Test (macos-latest, Node 22.x) ⏭️ skipped
Test (windows-latest, Node 22.x) ⏭️ skipped
Integration Tests (CLI, No Sandbox) ⏭️ skipped

The three skips are repo policy — those jobs carry if: github.event_name == 'merge_group' in ci.yml and only run in the merge queue — not PR failures. No red check on this commit. The unit suite that passed on CI includes the deepseek provider test file this PR extends.

Sandboxed verification would settle the one remaining gap: @qwen-code /verify — that a DeepSeek-compatible backend actually accepts the preserved multipart request and describes the image is the author's claim (an E2E report on Linux is linked in this thread), not independently re-run here; the unit tests pin request construction, not wire acceptance. The author lacks write access, so this is a sponsored run: a maintainer's /verify comment approves the head it was written against, and the run carries a pre-execution risk screen plus a full workspace wipe — read the resulting report with the same skepticism as the fork's own CI logs.

中文说明

代码审查:读 diff 之前我自己对这个 bug 的方案与 PR 一致——按声明的输入模态门控 DeepSeek 的扁平化,因为 converter 已经会为未声明的模态生成占位符,provider 层的扁平化是第二个无条件层,覆盖了前者的决定。实现就是 buildRequest 里的一个条件,没有新增机制。

未发现正确性阻塞项:

  • 纯文本路由与改动前完全一致modalities 未设置或 image 为假时走原有 flattenContentParts,现有扁平化测试仍锁定该行为;自动检测把所有 deepseek* 模型解析为纯文本 {},未声明的用户不受任何影响。
  • 思考模式回放修复不受影响:两个分支都会执行 ensureReasoningContentOnAssistantMessage
  • 没有发明新的线上格式:声明图片后,DeepSeek 路由发出的请求与其他任何声明了图片的 OpenAI 兼容 provider 完全相同——它不再是一个特例。
  • 新测试经由真实 provider 选择(determineProvider)锁定修复,覆盖官方端点与按模型名匹配的自定义网关,并验证原始请求未被修改。

一个非阻塞小点:门控只看 modalities?.image——DeepSeek 路由声明 pdf/audio/video 时仍会被扁平化。这与报告的问题和 PR 声明的范围一致;等此类模型真正出现时再跟进即可。

上方时序图展示了导致 bug 的两层交互以及门控的位置。

测试证据(无人值守运行:门禁从不执行 PR 代码,以下通过 API 读取本 PR 自身的 CI):所审提交 bfa97bb 上完整单元测试套件通过,无失败——Ubuntu 单测、Desktop Shell(ubuntu/windows)、web-shell E2E Smoke、安全检查(TruffleHog 密钥扫描、依赖 CVE 审计、precheck)均为 success;macOS/Windows 单测与 CLI 集成测试为 skipped,原因是仓库策略(这些 job 在 ci.yml 中限定 merge_group 事件,只在合并队列运行),并非 PR 导致的失败。CI 通过的单测套件包含本 PR 扩展的 deepseek provider 测试文件。

沙箱验证可补齐剩余缺口:@qwen-code /verify —— DeepSeek 兼容后端是否真的接受保留下来的多段请求并描述图片,目前是作者的自述(线程中有 Linux 上的 E2E 报告),未在此独立复跑;单测锁定的是请求构造,不是线上接受度。作者没有写权限,因此这是一次受助运行:维护者评论 /verify 即对其所指向的 head 放行,运行前有预执行风险筛查与完整工作区清理——请对报告的审慎程度与对待 fork 自身 CI 日志相同。

Qwen Code · qwen3.8-max

Reviewed at bfa97bb247c8212c5610287724155dc96213708f · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean, minimal fix that makes the DeepSeek route respect the existing modalities contract; the only non-blocking nit is the image-only gate, and end-to-end wire acceptance rests on the author's E2E report (the sponsored /verify lane named above can settle it).

Stepping back over the whole thing:

  • The problem was verified real before any code reading — I traced the unconditional flatten clobbering the declared modalities.image against 请给deepseekv4flash-vision-exp模型图片能力,这是qwen code后端代码的问题,请修正 #9832's report. Not a hypothetical.
  • The approach is the one I would have picked: a single condition reusing the declared-modality contract already implemented by the converter and pipeline. No new abstractions, no config surface, and the doc comments were updated to match instead of being left to rot.
  • Every line in the diff serves the stated goal — no drive-by edits.
  • The tests pin the new behavior on both the official endpoint and a custom gateway through real provider selection, while the pre-existing tests still pin the old flattening for undeclared routes. CI ran the full unit suite on this commit: green.
  • Six months from now this reads as one honest condition with a comment explaining why the two decisions are kept separate — I'd thank the author, not curse them.

The one thing to hold in mind after merge: the explicit declaration is now authoritative on DeepSeek routes, same as everywhere else. If a user declares image support on a text-only endpoint, the server's rejection is the feedback loop — that's the declared tradeoff, and it's consistent with every other OpenAI-compatible route.

Approving, pinned to the reviewed commit. main requires two approvals, so a maintainer's review is still needed — the high-risk-path flag in the Stage 1 comment is aimed at you.

中文说明

信心:4/5 —— 干净、最小化的修复,让 DeepSeek 路由遵守既有的 modalities 契约;唯一的非阻塞小点是门控只看 image,端到端线上接受度目前依据作者的 E2E 报告(上文指名的受助 /verify 通道可以补齐验证)。

整体回顾:

  • 在读代码之前就已确认问题真实存在——对照 请给deepseekv4flash-vision-exp模型图片能力,这是qwen code后端代码的问题,请修正 #9832 的报告追踪到无条件扁平化覆盖了声明的 modalities.image,不是假想问题。
  • 方案与我的独立选择一致:用单个条件复用 converter 和 pipeline 已实现的声明模态契约。没有新抽象、没有新配置面,文档注释同步更新而非放任失准。
  • diff 中每一行都服务于既定目标,无夹带改动。
  • 测试经由真实 provider 选择,在官方端点与自定义网关两种路由上锁定新行为;原有测试仍锁定未声明路由的旧扁平化行为。CI 在该提交上运行了完整单测套件:通过。
  • 六个月后回看,这就是一个诚实的条件加一段解释"为何两个决定要分开"的注释——值得感谢而非吐槽。

合并后需要记住一点:显式声明在 DeepSeek 路由上现在是权威的,与其他路由一致。若用户在纯文本端点上声明图片支持,服务端拒绝就是反馈机制——这是已声明的取舍,与其他所有 OpenAI 兼容路由一致。

已批准,锚定到所审提交。main 需要两个批准,因此仍需维护者审查——Stage 1 评论中的高风险路径标记就是给维护者的提示。

Qwen Code · qwen3.8-max

Reviewed at bfa97bb247c8212c5610287724155dc96213708f · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline.

Not explored to full depth (tool budget reached): "agent 1a": could not execute deepseek.test.ts — the review worktree has no node_modules and no built dist/ (core tests resolve the package entry through dist), and i….

中文说明

已审查。 建议见行内评论。

未探索到全部深度(达到工具调用预算):"agent 1a"could not execute deepseek.test.ts — the review worktree has no node_modules and no built dist/ (core tests resolve the package entry through dist), and i…

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +129 to +131
const messages = reshaped.messages.map((message) =>
ensureReasoningContentOnAssistantMessage(
this.contentGeneratorConfig.modalities?.image

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The new image-preservation branch is not pinned by any test for the reasoning_content injection (the issue #3695 guard). Both branches currently go through ensureReasoningContentOnAssistantMessage, which is correct — but a one-line refactor that applies the helper only to the flattening branch would pass the entire suite: the existing reasoning_content tests all run on the text-only config (flatten branch), and the new vision test sends only user messages, which the helper returns unchanged. I confirmed this by running the mutant: image ? message : ensureReasoningContentOnAssistantMessage(flattenContentParts(message)) passes all 29 tests, and a probe with a vision-enabled config plus an assistant turn missing reasoning_content fails on the mutant (expected undefined to be '') while it passes on this code. If such a mutant ever shipped, DeepSeek thinking mode would reject every subsequent request mid-conversation on exactly the routes this PR enables. Suggested fix — extend the vision test with an assistant turn lacking reasoning_content and assert it is injected as '' while multipart user content is still preserved:

const result = visionProvider.buildRequest(
  {
    model: 'deepseek-v4-flash-vision-exp',
    messages: [
      structuredClone(expectedMessage),
      { role: 'assistant', content: 'A cat.' },
    ],
  },
  userPromptId,
);
expect(result.messages?.[1]).toEqual({
  role: 'assistant',
  content: 'A cat.',
  reasoning_content: '',
});
中文说明

新的图片保留分支没有任何测试来固定 reasoning_content 注入(issue #3695 的防护)。当前两个分支都会经过 ensureReasoningContentOnAssistantMessage,这是正确的——但如果未来有一行重构把该辅助函数只应用到扁平化分支上,整个测试套件仍会全部通过:现有的 reasoning_content 测试都运行在纯文本配置(扁平化分支)下,而新增的视觉测试只发送 user 消息(辅助函数对其原样返回)。实际运行变体后确认:image ? message : ensureReasoningContentOnAssistantMessage(flattenContentParts(message)) 通过全部 29 个测试;而一个使用视觉配置、且 assistant 回合缺少 reasoning_content 的探针测试在该变体上失败(expected undefined to be ''),在本 PR 代码上通过。若该变体将来被引入,DeepSeek 思考模式会在本 PR 启用的路由上于对话中途拒绝后续所有请求。建议修复——在视觉测试中补充一个缺少 reasoning_content 的 assistant 回合,断言其被注入为 '',同时多段 user 内容仍被保留(见上方代码示例)。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +131 to +133
this.contentGeneratorConfig.modalities?.image
? message
: flattenContentParts(message),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The gate keys on modalities.image alone but skips flattenContentParts message-wide, so any additionally declared modality on a DeepSeek route bypasses the placeholder safety net that previously guaranteed the request went through. I confirmed this with an A/B run: with modalities: { image: true, pdf: true } (a declaration the model registry preserves verbatim), the converter emits an OpenAI type: 'file' part for the PDF; on this branch the raw file part ships as-is, while the pre-change code replaced it with [Unsupported content type: file] and the request always went through. If the endpoint's wire format rejects that part, a configuration that previously degraded gracefully now fails mid-conversation. The updated doc comment ("Models that explicitly declare image input retain multipart content") also states a narrower guarantee than the code implements. Suggested fix: make the skip part-type-aware — when image input is declared, keep image_url parts and still flatten/placeholder every other non-text part type.

中文说明

该判断只检查 modalities.image,却对整个消息跳过 flattenContentParts,因此 DeepSeek 路由上额外声明的其他模态会绕过此前保证请求始终可用的占位符安全网。A/B 运行验证:配置 modalities: { image: true, pdf: true }(模型注册表会原样保留该声明)时,转换器会为 PDF 生成 OpenAI type: 'file' 部分;本分支会把原始 file 部分原样发出,而改动前的代码会用 [Unsupported content type: file] 占位符替换、保证请求始终可用。如果端点的 wire 格式拒绝该部分,原本可以优雅降级的配置现在会在对话中途直接失败。此外,更新后的文档注释("显式声明图片输入的模型保留多段内容")所承诺的范围也小于代码的实际实现。建议修复:让跳过逻辑感知内容部分类型——声明图片输入时保留 image_url 部分,对其余非文本类型继续扁平化/占位。

— qwen3.8-max via Qwen Code /review (v0.22.0)

@jarvislee90s-dot

Copy link
Copy Markdown

Third-party validation — this also breaks image passthrough through custom gateways, not just DeepSeek's own vision models

I'm building a local gateway that relays qwen-code traffic to OpenAI-compatible endpoints and transcribes images into text for text-only models (so the upstream doesn't even need vision support). Reproduced this on v0.22.0 with a deepseek-named model served from a non-DeepSeek host:

  • modelProviders.openai[] entry: baseUrl: https://ollama.com/v1, model deepseek-v4-flash:0731, modalities.image: true
  • isDeepSeekProvider() matches on the model name regardless of host → flattenContentParts replaces every image part with [Unsupported content type: image_url] before the request ever leaves the client
  • Gateway-side logs show zero image bytes on the wire; the model literally quotes the placeholder text back to the user. The exact same setup with a non-deepseek-named model (GLM) works end-to-end, which isolates the cause to the name heuristic rather than the gateway.

Gating on declared modalities.image covers this case exactly, and it's a broader win than the official vision models: it also enables image input for deepseek-named text models behind transcription gateways. The custom-gateway regression test (opencode.ai/zen/go/v1) is precisely the shape we hit — much appreciated.

While reading this class we noticed two more behaviors that follow the model-name fallback onto custom gateways, in the same "collateral damage" family as the flattening:

  1. ensureReasoningContentOnAssistantMessage — injects reasoning_content: "" into every assistant turn that lacks it. Reasonable for api.deepseek.com (Deepseek V4 API Error: 400 The reasoning_content in the thinking mode must be passed back to the API #3695), but it pushes a DeepSeek-specific field at strict OpenAI-compat backends that never asked for it.
  2. getDefaultGenerationConfig() forces temperature: 0.

Meanwhile translateReasoningEffort / the thinking-disabled emission are correctly hostname-gated (per the "keep the two decisions separated" note). Would it make sense to hostname-gate those two as well — or at least the reasoning_content injection?

@wenshao

wenshao commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — real local environment, A/B against the PR base

Verdict: works as advertised, recommend merge. The bug reproduces on the base commit, the fix resolves it end-to-end through the real CLI, and the text-only DeepSeek route is byte-for-byte unchanged. Three non-blocking notes below — one of them (N1) is worth a one-line follow-up either here or as a separate PR.

How this was verified

PR head bfa97bb247
Base 4d3f9ff571 (merge-base with main)
A/B method one-file revert of deepseek.ts, both arms bundled from source with the same esbuild.config.js
A/B proof modalities?.image ? message present in the PR bundle chunk and absent from the base chunk; const flattened = flattenContentParts(message) present only in the base chunk
Surface real interactive CLI (node dist/cli.js --approval-mode yolo) in tmux, real read_file, real settings.json
Backend a local OpenAI-compatible gateway that logs every request body and derives its reply from the bytes it actually receives (PNG/JPEG decode + dominant-colour histogram). No canned answer — if the model "describes the image", the image really arrived.
Config the reporter's setup from #9832: id: deepseek-v4-flash-vision-exp, custom baseUrl, generationConfig.modalities.image = true

1. The bug reproduces on base, and the fix resolves it

Identical config, identical prompt, identical file — only the code under test differs. In both arms read_file succeeds and reports [image: jpeg], so the difference is created strictly downstream, in request shaping:

TUI A/B

The image used (fixture) is a 320×320 PNG containing a purple-violet interlocking angular mark and a blue circle. The colours the gateway reports back — rgb(123,58,236) and rgb(95,125,238) — are measured from the received bytes, so that reply is only producible if the pixels made it over the wire.

2. What actually left the process

Wire A/B

Both arms were run under equal-length filesystem paths so the byte counts are directly comparable:

  • turn 1: 134,923149,905 bytes (+14,982), and [Unsupported content type: image_url] is replaced by a real image_url part (image/jpeg, 11,128 bytes, sha256 875774c6…).
  • Incidentally: the CLI re-encodes the PNG to JPEG before sending. Orthogonal to this PR, just noting it so the image/jpeg in the logs isn't confusing.

3. No regression on the text-only DeepSeek route

Same scenario with deepseek-chat and no declared modalities, PR bundle vs base bundle, equal-length run paths:

turn 0: bodyBytes 134353 vs 134353   shapes identical
turn 1: bodyBytes 134929 vs 134929   shapes identical
TEXT-ONLY ROUTE UNCHANGED BY PR: True

Worth recording why: on a text-only config the converter already replaces the image with its own text placeholder before the provider runs, so the DeepSeek flatten never sees an image part on that route at all. The flatten's real remaining job there is joining text parts into a string, and that still happens.

4. Test evidence

  • deepseek.test.ts: 29/29 pass on the PR.
  • Non-vacuity confirmed — reverting only deepseek.ts to base makes exactly the 2 new cases fail (expected { role: 'user', … } to deeply equal { role: 'user', … }) while the other 27 stay green.
  • packages/core/src/core/openaiContentGenerator/: 861/861 pass across 18 files.
  • packages/core tsc --noEmit: clean.
  • The Anthropic-protocol DeepSeek path (anthropicContentGenerator.ts) does no content flattening, only effort mapping, so there is no sibling fix needed there.

5. Capability matrix

Capability matrix


Notes (none blocking)

N1 — the gate turns flattening off for every message, not just image-bearing ones

This is the one I'd like a follow-up on. modalities.image gates the whole flattenContentParts call, so once image input is declared, a plain text-only turn also stops being flattened: the matrix row image:true + TEXT-ONLY parts yields parts[text,text], and in the live run turn 0 — which contains no image at all — already changes shape (stringparts[text,text,text,text], +91 bytes).

On a permissive gateway that is harmless. On a route that genuinely requires string content — the constraint the flatten was written for, and which the PR explicitly allows to be paired with modalities.image — the failure lands on the very first request, before an image is ever sent:

Blast radius

The PR body does disclose this risk ("a text-only endpoint incorrectly configured with image support … may reject it"), but the disclosure reads as "image turns may be rejected", whereas what actually happens is "the session dies on hello". A misconfigured user gets an error that points nowhere near the image they were trying to send.

Gating on the message instead of the config keeps the fix and shrinks the radius to the turn that actually carries an image:

const keepMultipart = this.contentGeneratorConfig.modalities?.image === true;
const messages = reshaped.messages.map((message) =>
  ensureReasoningContentOnAssistantMessage(
    keepMultipart && hasImagePart(message)
      ? message
      : flattenContentParts(message),
  ),
);

function hasImagePart(message: OpenAI.Chat.ChatCompletionMessageParam): boolean {
  const content = (message as { content?: unknown }).content;
  return (
    Array.isArray(content) &&
    content.some(
      (part) =>
        typeof part === 'object' &&
        part !== null &&
        (part as { type?: string }).type === 'image_url',
    )
  );
}

I built and ran this variant: the image fix still works end-to-end (identical gateway description), text turns keep the legacy string shape, and on the strict endpoint only the image turn is rejected (lower pane of the screenshot above).

Note that this variant also passes all 29 tests unchanged — so the current suite cannot tell the two gates apart. If you keep the broad gate, a test pinning image:true + text-only-multipart → parts[...] would at least make that choice deliberate.

N2 — "explicitly declares" is slightly stronger than what the code checks

The PR body says the multipart content is preserved "when the selected model explicitly declares image support", but the gate reads contentGeneratorConfig.modalities, which resolveModelConfig always fills — explicitly configured, else auto-detected from the model id via defaultModalities.

For the case this PR targets it makes no difference: MODALITY_PATTERNS maps /^deepseek/ to {}, so a deepseek-* id never auto-detects image and the user's explicit config really is what flips the gate. But a model that reaches this provider by the name fallback while auto-detecting image from a different pattern does get the new behaviour with no user declaration at all — e.g. gpt-4o-deepseek-gatewayparts[text,image_url] (last-but-one matrix row). Arguably the right outcome; just a wording nit in the PR description.

N3 — informational: audio and PDF parts also stop being flattened

With image: true, [text, input_audio] and [text, file] also survive as multipart (matrix rows 6–7), even though neither modality was declared. Not reachable today: converter.ts gates each modality independently and replaces an undeclared audio/PDF part with a text placeholder before the provider ever sees it. Recording it so it isn't a surprise if that ordering ever changes. N1's hasImagePart guard happens to close this too.

中文版

维护者验证 —— 真实本地环境,与 PR 基线做 A/B 对照

结论:功能符合描述,建议合并。 缺陷在基线提交上可复现,修复在真实 CLI 上端到端生效,纯文本 DeepSeek 路由逐字节未变。下面三条备注均不阻断合并,其中 N1 建议在本 PR 或后续 PR 中补一行改动。

验证方式

PR head bfa97bb247
基线 4d3f9ff571(与 main 的 merge-base)
A/B 方法 仅回退 deepseek.ts 一个文件,两臂用同一份 esbuild.config.js 从源码打包
A/B 佐证 modalities?.image ? message 只出现在 PR 产物 chunk 中,基线产物中不存在;const flattened = flattenContentParts(message) 只出现在基线产物中
测试面 tmux 中运行真实交互式 CLI(node dist/cli.js --approval-mode yolo),真实 read_file,真实 settings.json
后端 本地 OpenAI 兼容网关,记录每个请求体,并且回复内容由实际收到的字节推导得出(PNG/JPEG 解码 + 主色直方图)。没有预设答案——模型若能"描述图片",说明图片确实到达了
配置 复刻 #9832 报告者的配置:id: deepseek-v4-flash-vision-exp、自定义 baseUrlgenerationConfig.modalities.image = true

1. 基线可复现缺陷,PR 修复生效

配置、提示词、文件完全一致,仅被测代码不同。两臂中 read_file 均成功并显示 [image: jpeg],因此差异严格产生于下游的请求整形阶段:

TUI A/B

所用图片(fixture)是 320×320 PNG,包含紫罗兰色交织棱角图形和蓝色圆形。网关回报的颜色 rgb(123,58,236)rgb(95,125,238) 是从收到的字节测量得出的,因此只有像素真正上线才可能产生该回复。

2. 实际发出的请求内容

Wire A/B

两臂运行在等长文件系统路径下,字节数可直接比较:

  • 第 1 回合:134,923149,905 字节(+14,982),[Unsupported content type: image_url] 被真实的 image_url 部件取代(image/jpeg,11,128 字节,sha256 875774c6…)。
  • 附带发现:CLI 在发送前会把 PNG 重编码为 JPEG。与本 PR 无关,仅说明日志中的 image/jpeg 从何而来。

3. 纯文本 DeepSeek 路由无回归

同一场景改用 deepseek-chat、不声明 modalities,PR 产物与基线产物对比(等长运行路径):

turn 0: bodyBytes 134353 vs 134353   形态一致
turn 1: bodyBytes 134929 vs 134929   形态一致
TEXT-ONLY ROUTE UNCHANGED BY PR: True

值得记录原因:纯文本配置下,converter 已经在 provider 运行之前把图片替换成了自己的文本占位符,因此该路由上 DeepSeek 的扁平化逻辑根本见不到图片部件。扁平化在此处真正剩下的职责是把文本部件拼成字符串,这一行为依然保留。

4. 测试证据

  • deepseek.test.ts:PR 上 29/29 通过
  • 非空洞性已确认 —— 仅把 deepseek.ts 回退到基线,恰好只有 2 个新增用例失败(expected { role: 'user', … } to deeply equal { role: 'user', … }),其余 27 个仍然通过。
  • packages/core/src/core/openaiContentGenerator/:18 个文件 861/861 通过
  • packages/coretsc --noEmit:无错误。
  • Anthropic 协议下的 DeepSeek 路径(anthropicContentGenerator.ts)不做内容扁平化,只做 effort 映射,因此那边不需要同类修复。

5. 能力矩阵

Capability matrix


备注(均不阻断合并)

N1 —— 该门控关闭的是所有消息的扁平化,而不仅是携带图片的消息

这一条希望能有后续跟进。modalities.image 门控的是整个 flattenContentParts 调用,因此一旦声明支持图片输入,纯文本回合也不再被扁平化:矩阵中 image:true + TEXT-ONLY parts 一行输出 parts[text,text];在真实运行中,完全不含图片的第 0 回合形态就已改变stringparts[text,text,text,text],+91 字节)。

在宽松网关上这无害。但在真正要求字符串内容的路由上——也正是扁平化逻辑当初针对的约束,而本 PR 明确允许它与 modalities.image 共存——失败会落在第一个请求上,早于任何图片发送之前:

Blast radius

PR 描述确实披露了该风险("纯文本端点被错误配置为支持图片……可能拒绝请求"),但这段表述读起来像是"图片回合可能被拒绝",而实际情况是"会话在打招呼时就挂了"。配置有误的用户拿到的报错,与他试图发送的那张图片毫无指向关系。

改为按消息而非按配置门控,可以保留修复效果,同时把影响面收窄到真正携带图片的那一回合:

const keepMultipart = this.contentGeneratorConfig.modalities?.image === true;
const messages = reshaped.messages.map((message) =>
  ensureReasoningContentOnAssistantMessage(
    keepMultipart && hasImagePart(message)
      ? message
      : flattenContentParts(message),
  ),
);

function hasImagePart(message: OpenAI.Chat.ChatCompletionMessageParam): boolean {
  const content = (message as { content?: unknown }).content;
  return (
    Array.isArray(content) &&
    content.some(
      (part) =>
        typeof part === 'object' &&
        part !== null &&
        (part as { type?: string }).type === 'image_url',
    )
  );
}

我构建并运行了该变体:图片修复端到端依然生效(网关给出完全相同的描述),文本回合保持旧有的字符串形态,在严格端点上只有图片回合被拒绝(见上图下半部分)。

需要注意的是,该变体同样能原封不动地通过全部 29 个测试——也就是说现有测试集无法区分这两种门控。如果决定保留宽门控,建议至少补一个用例,把 image:true + 纯文本多段 → parts[...] 的行为钉住,让这个选择成为有意为之。

N2 —— "显式声明"的说法比代码实际检查的条件更强

PR 描述称"当选中的模型明确声明支持图片输入时"保留多段内容,但门控读取的是 contentGeneratorConfig.modalities,而 resolveModelConfig 总会填充该字段——用户显式配置优先,否则由 defaultModalities 按模型 id 自动推断。

对本 PR 针对的场景没有影响:MODALITY_PATTERNS/^deepseek/ 映射为 {},所以 deepseek-* 的 id 永远不会自动推断出 image,真正翻转门控的确实是用户的显式配置。但如果某个模型是通过模型名回退命中该 provider,同时又按其他规则自动推断出 image,那么在用户完全没有声明的情况下也会获得新行为——例如 gpt-4o-deepseek-gatewayparts[text,image_url](矩阵倒数第二行)。这个结果本身或许是合理的,只是 PR 描述的措辞需要微调。

N3 —— 信息性:audio 与 PDF 部件同样不再被扁平化

image: true 时,[text, input_audio][text, file] 也会以多段形式保留(矩阵第 6、7 行),尽管这两种模态都没有被声明。目前不可达converter.ts 对每种模态独立门控,会在 provider 见到之前把未声明的 audio/PDF 部件替换成文本占位符。此处记录一笔,以免将来该顺序变动时造成意外。N1 中的 hasImagePart 守卫恰好也能一并封住这个口子。

@wenshao
wenshao added this pull request to the merge queue Aug 26, 2026
Merged via the queue into QwenLM:main with commit e386656 Aug 26, 2026
156 checks passed
@shenyankm
shenyankm deleted the fix/deepseek-vision-modalities-9832 branch August 26, 2026 08:57
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.22.2.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

请给deepseekv4flash-vision-exp模型图片能力,这是qwen code后端代码的问题,请修正

4 participants