Skip to content
Merged
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
34 changes: 17 additions & 17 deletions docs/users/configuration/settings.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1259,9 +1259,9 @@ const SETTINGS_SCHEMA = {
label: 'Split Tool Result Media',
category: 'Generation Configuration',
requiresRestart: false,
default: false,
default: true,
description:
'When true, media (images / audio / video / files) returned by MCP tool calls is split into a follow-up user message instead of being embedded in the tool message. Required for strict OpenAI-compatible servers (e.g., LM Studio) that reject non-text content on `role: "tool"` messages with HTTP 400 "Invalid \'messages\' in payload". Default false preserves the prior behavior for permissive providers. See QwenLM/qwen-code#3616.',
'When true, media (images / audio / video / files) returned by tool calls — including the built-in read_file and MCP tools — is split into a follow-up user message instead of being embedded in the `role: "tool"` message. The OpenAI Chat Completions spec only permits text on tool messages, so strict OpenAI-compatible servers (e.g., doubao / new-api / LM Studio) silently drop or reject embedded media and the model never sees an image read via read_file (QwenLM/qwen-code#4876, #3616). Default true is spec-compliant and safe for permissive providers; set false only to restore the legacy embed-in-tool-message behavior.',
parentKey: 'generationConfig',
showInDialog: false,
},
Expand Down
17 changes: 9 additions & 8 deletions packages/core/src/core/contentGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,14 +125,15 @@ export type ContentGeneratorConfig = {
// Supported input modalities. Unsupported media types are replaced with text
// placeholders. Leave undefined to use automatic detection from model name.
modalities?: InputModalities;
// When true, media parts in MCP tool responses are split into a follow-up
// `role: "user"` message instead of being embedded inside the `role: "tool"`
// message. The OpenAI Chat Completions spec only permits string / text-part
// content on tool messages; strict OpenAI-compatible servers (notably
// LM Studio) reject anything else with HTTP 400 "Invalid 'messages' in
// payload". Enable this for any provider that strictly validates tool
// message content. Default: false (preserves prior behavior for permissive
// providers). See QwenLM/qwen-code#3616.
// When true, media parts in tool responses (including the built-in read_file
// and MCP tools) are split into a follow-up `role: "user"` message instead of
// being embedded inside the `role: "tool"` message. The OpenAI Chat
// Completions spec only permits string / text-part content on tool messages;
// strict OpenAI-compatible servers (e.g. doubao / new-api / LM Studio) drop or
// reject anything else (HTTP 400 "Invalid 'messages' in payload"), so an image
// read via read_file never reaches the model. Default: true (spec-compliant
// and safe for permissive providers); set false to restore the legacy
// embed-in-tool-message behavior. See QwenLM/qwen-code#4876, #3616.
splitToolMedia?: boolean;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export class LoggingContentGenerator implements ContentGenerator {
private openaiLogger?: OpenAILogger;
private schemaCompliance?: 'auto' | 'openapi_30';
private modalities?: InputModalities;
private splitToolMedia?: boolean;
private readonly generatorAuthType: ContentGeneratorConfig['authType'];

constructor(
Expand All @@ -113,6 +114,7 @@ export class LoggingContentGenerator implements ContentGenerator {
generatorConfig: ContentGeneratorConfig,
) {
this.modalities = generatorConfig.modalities;
this.splitToolMedia = generatorConfig.splitToolMedia;
this.generatorAuthType = generatorConfig.authType;

// Extract fields needed for initialization from passed config
Expand Down Expand Up @@ -736,6 +738,10 @@ export class LoggingContentGenerator implements ContentGenerator {
return {
model,
modalities: this.modalities ?? {},
// Mirror the pipeline default (see pipeline.ts createRequestContext) so the
// --openai-logging fallback reconstruction reflects the same split as the
// request actually sent. Opt out via generationConfig.splitToolMedia = false.
splitToolMedia: this.splitToolMedia ?? true,
startTime: 0,
};
}
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/core/modalityDefaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,43 @@ describe('defaultModalities', () => {
});
});

describe('ByteDance Doubao', () => {
it('returns image for doubao-seed-2.0-pro (issue #4876)', () => {
const m = defaultModalities('doubao-seed-2.0-pro');
expect(m.image).toBe(true);
expect(m.video).toBeUndefined();
expect(m.audio).toBeUndefined();
});

it('returns image for doubao-seed-1.6', () => {
expect(defaultModalities('doubao-seed-1.6').image).toBe(true);
});

it('returns image for doubao-1.5-vision-pro', () => {
expect(defaultModalities('doubao-1.5-vision-pro').image).toBe(true);
});

it('returns image for doubao-vision', () => {
expect(defaultModalities('doubao-vision').image).toBe(true);
});

it('returns text-only for doubao-seedance (text→video generation model)', () => {
expect(defaultModalities('doubao-seedance-1.0-pro')).toEqual({});
});

it('returns text-only for doubao-seedream (text→image generation model)', () => {
expect(defaultModalities('doubao-seedream-3.0')).toEqual({});
});

it('returns text-only for doubao-pro-32k', () => {
expect(defaultModalities('doubao-pro-32k')).toEqual({});
});

it('returns text-only for doubao-lite-4k', () => {
expect(defaultModalities('doubao-lite-4k')).toEqual({});
});
});

describe('unknown models', () => {
it('returns text-only for unrecognized models', () => {
expect(defaultModalities('some-random-model-xyz')).toEqual({});
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/core/modalityDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ const MODALITY_PATTERNS: Array<[RegExp, InputModalities]> = [
// -------------------
[/^kimi-k2\.5/, { image: true, video: true }],
[/^kimi-/, {}],

// -------------------
// ByteDance Doubao — Seed-series and *-vision / *-vl models accept image
// input; other Doubao models (pro / lite / text) are text-only.
// (QwenLM/qwen-code#4876)
// -------------------
// seedance (text→video) and seedream (text→image) are generation models with
// text-only input — exclude them before the multimodal Seed chat series.
[/^doubao-seed(ance|ream)/, {}],
[/^doubao-seed/, { image: true }],
[/^doubao-.*(vision|vl)/, { image: true }],
[/^doubao/, {}],
];

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -787,10 +787,11 @@ describe('OpenAIContentConverter', () => {
expect(img?.image_url?.url).toBe('data:image/png;base64,xxx');
});

it('should preserve prior embedded-media behavior when splitToolMedia is false (default) on parallel tool calls (issue #3616)', () => {
it('should preserve embedded-media behavior when splitToolMedia is explicitly false (opt-out) on parallel tool calls (issue #3616, #4876)', () => {
// Same input as the parallel-tool-calls split test, but with the flag
// off. Asserts that the opt-in is actually opt-in: media stays embedded
// in the tool message and no follow-up user message is synthesised.
// explicitly off. Since #4876 the default is true (spec-compliant), so
// this asserts the opt-out path: media stays embedded in the tool
// message and no follow-up user message is synthesised.
const request: GenerateContentParameters = {
model: 'models/test',
contents: [
Expand Down Expand Up @@ -826,11 +827,10 @@ describe('OpenAIContentConverter', () => {
],
};

// requestContext default has splitToolMedia undefined / false
const messages = converter.convertGeminiRequestToOpenAI(
request,
requestContext,
);
const messages = converter.convertGeminiRequestToOpenAI(request, {
...requestContext,
splitToolMedia: false,
});

const toolMessages = messages.filter((m) => m.role === 'tool');
const userMessages = messages.filter((m) => m.role === 'user');
Expand Down
25 changes: 13 additions & 12 deletions packages/core/src/core/openaiContentGenerator/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,18 +594,19 @@ function processContent(
requestContext,
);
if (toolMessage) {
// Opt-in only (ContentGeneratorConfig.splitToolMedia). OpenAI spec
// only permits string / text-part content on `role: "tool"` messages.
// Strict OpenAI-compatible servers (e.g. LM Studio) reject tool
// messages containing image_url / input_audio / video_url / file
// parts with HTTP 400 "Invalid 'messages' in payload". When the flag
// is set, strip non-text media from this tool message and accumulate
// it; the combined media is emitted as a single follow-up user
// message after the parts loop completes — preserving the
// "all tool responses contiguous" requirement for parallel tool
// calls. Default (flag false) preserves prior behavior: media is
// embedded in the tool message and permissive providers continue
// to receive it that way. See #3616.
// Controlled by ContentGeneratorConfig.splitToolMedia (default true;
// resolved in pipeline.ts). OpenAI spec only permits string / text-part
// content on `role: "tool"` messages. Strict OpenAI-compatible servers
// (e.g. doubao / new-api / LM Studio) silently drop or reject tool
// messages containing image_url / input_audio / video_url / file parts
// (HTTP 400 "Invalid 'messages' in payload"), so an image read via
// read_file never reaches the model. When the flag is set, strip
// non-text media from this tool message and accumulate it; the combined
// media is emitted as a single follow-up user message after the parts
// loop completes — preserving the "all tool responses contiguous"
// requirement for parallel tool calls. Opt out (flag false) to restore
// the legacy behavior: media embedded in the tool message, which only
// permissive providers accept. See #4876, #3616.
if (
requestContext.splitToolMedia &&
Array.isArray(toolMessage.content)
Expand Down
44 changes: 44 additions & 0 deletions packages/core/src/core/openaiContentGenerator/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,50 @@ describe('ContentGenerationPipeline', () => {
);
});

it('should default splitToolMedia to true when neither provider override nor content generator config sets it (issue #4876)', async () => {
const request: GenerateContentParameters = {
model: 'test-model',
contents: [{ parts: [{ text: 'Hello' }], role: 'user' }],
};
const userPromptId = 'test-prompt-id';
const mockMessages = [
{ role: 'user', content: 'Hello' },
] as OpenAI.Chat.ChatCompletionMessageParam[];
const mockOpenAIResponse = {
id: 'response-id',
choices: [
{ message: { content: 'Hello response' }, finish_reason: 'stop' },
],
created: Date.now(),
model: 'test-model',
} as OpenAI.Chat.ChatCompletion;
const mockGeminiResponse = new GenerateContentResponse();

// Neither the provider nor the content generator config sets
// splitToolMedia — it must default to true so tool-returned images are
// moved out of the spec-violating `role: "tool"` message (#4876).
mockProvider.getRequestContextOverrides = vi.fn().mockReturnValue({});
mockContentGeneratorConfig.splitToolMedia = undefined;
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue(
mockMessages,
);
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
mockGeminiResponse,
);
(mockClient.chat.completions.create as Mock).mockResolvedValue(
mockOpenAIResponse,
);

await pipeline.execute(request, userPromptId);

expect(mockConverter.convertGeminiRequestToOpenAI).toHaveBeenCalledWith(
request,
expect.objectContaining({
splitToolMedia: true,
}),
);
});

it('should fall back to configured model when request.model is empty', async () => {
// Arrange — empty model string is falsy, should fall back to contentGeneratorConfig.model
const request: GenerateContentParameters = {
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/core/openaiContentGenerator/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,14 @@ export class ContentGenerationPipeline {
splitToolMedia:
providerOverrides.splitToolMedia ??
this.contentGeneratorConfig.splitToolMedia ??
false,
// Default true: the OpenAI Chat Completions spec only permits text on
// `role: "tool"` messages, so tool-returned media (e.g. an image read
// by read_file) embedded there is silently dropped or rejected by
// strict providers (doubao / new-api / LM Studio) and the model never
// sees it (QwenLM/qwen-code#4876). Splitting it into a follow-up user
// message is spec-compliant and safe for permissive providers too.
// Opt out via generationConfig.splitToolMedia = false.
true,
...(toolCallParser ? { toolCallParser } : {}),
...(responseParsingOptions ? { responseParsingOptions } : {}),
...(taggedThinkingParser ? { taggedThinkingParser } : {}),
Expand Down
4 changes: 2 additions & 2 deletions packages/vscode-ide-companion/schemas/settings.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -540,9 +540,9 @@
"default": true
},
"splitToolMedia": {
"description": "When true, media (images / audio / video / files) returned by MCP tool calls is split into a follow-up user message instead of being embedded in the tool message. Required for strict OpenAI-compatible servers (e.g., LM Studio) that reject non-text content on `role: \"tool\"` messages with HTTP 400 \"Invalid 'messages' in payload\". Default false preserves the prior behavior for permissive providers. See QwenLM/qwen-code#3616.",
"description": "When true, media (images / audio / video / files) returned by tool calls — including the built-in read_file and MCP tools — is split into a follow-up user message instead of being embedded in the `role: \"tool\"` message. The OpenAI Chat Completions spec only permits text on tool messages, so strict OpenAI-compatible servers (e.g., doubao / new-api / LM Studio) silently drop or reject embedded media and the model never sees an image read via read_file (QwenLM/qwen-code#4876, #3616). Default true is spec-compliant and safe for permissive providers; set false only to restore the legacy embed-in-tool-message behavior.",
"type": "boolean",
"default": false
"default": true
},
"schemaCompliance": {
"description": "The compliance mode for tool schemas sent to the model. Use \"openapi_30\" for strict OpenAPI 3.0 compatibility (e.g., for Gemini). Options: auto, openapi_30",
Expand Down
Loading