fix(openai): add string tool result compatibility mode (#5399)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
Qwen Code CI / Integration Tests (CLI, No Sandbox) (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

This commit is contained in:
tt-a1i 2026-06-19 16:56:49 +08:00 committed by GitHub
parent 30964352fa
commit 0430ff7af4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 294 additions and 17 deletions

View file

@ -137,23 +137,23 @@ Settings are organized into categories. Most settings should be placed within th
#### model
| Setting | Type | Description | Default |
| -------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `model.name` | string | The Qwen model to use for conversations. | `undefined` |
| `model.maxSessionTurns` | number | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| `model.maxWallTimeSeconds` | number | Wall-clock budget for headless / unattended runs, in seconds. `-1` means unlimited. Overridable per-invocation via `--max-wall-time`, which requires a positive duration (`90`, `30s`, `5m`, `1h`, `1.5h`); the minimum is 1 second — sub-second values (`500ms`, `0.5`) are rejected as typos. Omit the flag to fall back to this setting. Aborts with exit code 55 when exceeded. | `-1` |
| `model.maxToolCalls` | number | Cumulative tool-call budget for a run (counts every executed tool, success or failure; `structured_output` under `--json-schema` is exempt). `-1` means unlimited; `0` means "no tool calls allowed". Capped at 1,000,000 to catch typos. Overridable via `--max-tool-calls`. Aborts with exit code 55 when exceeded. | `-1` |
| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `enableCacheControl`, `splitToolMedia` (default `true`; splits tool-returned media — including images read by the built-in read_file — into a follow-up user message instead of the spec-violating `role: "tool"` message, so strict OpenAI-compatible servers like doubao / new-api / LM Studio can see it; set `false` to restore the legacy embed-in-tool behavior), `contextWindowSize` (override model's context window size), `modalities` (override auto-detected input modalities), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` |
| `model.chatCompression.contextPercentageThreshold` | number | **REMOVED.** Auto-compaction now uses a three-tier threshold ladder (warn / auto / hard) computed internally from the model's context window via the `computeThresholds()` function — no longer user-configurable. Setting this field in `settings.json` is silently ignored (no startup warning). There is currently no replacement for "disable compression entirely" — reactive overflow recovery remains the safety net at the API layer if compression itself fails. (See PR #4345 / `docs/design/auto-compaction-threshold-redesign.md` for the redesign rationale.) | `N/A` |
| `model.chatCompression.maxRecentFilesToRetain` | number | Number of most-recently-touched files whose current content is restored (embedded if small, otherwise referenced by path) into history after auto-compaction. `0` restores none. Env override: `QWEN_COMPACT_MAX_RECENT_FILES`. | `5` |
| `model.chatCompression.maxRecentImagesToRetain` | number | Number of most-recent images (tool screenshots / user pastes) restored into history after auto-compaction. `0` restores none. Env override: `QWEN_COMPACT_MAX_RECENT_IMAGES`. | `3` |
| `model.chatCompression.enableScreenshotTrigger` | boolean | When `true`, auto-compaction also fires once the number of tool-returned images accumulated in history reaches `screenshotTriggerThreshold`, independent of token usage — aimed at computer-use sessions where frequent screenshots dilute model attention. Counts only images returned inside tool results, not user-pasted images. Env override: `QWEN_COMPACT_SCREENSHOT_TRIGGER` (`1`/`true`/`0`/`false`). | `true` |
| `model.chatCompression.screenshotTriggerThreshold` | number | Tool-returned image count at or above which the screenshot trigger fires (only when `enableScreenshotTrigger`). Compaction resets the count — surviving images are re-embedded as top-level parts, which the trigger doesn't count — so it won't immediately re-fire. Env override: `QWEN_COMPACT_SCREENSHOT_THRESHOLD`. | `50` |
| `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `true` |
| `model.skipLoopDetection` | boolean | Disables streaming loop detection checks. Defaults to `true` (loop detection is skipped) to avoid false positives interrupting legitimate workflows. Set to `false` to re-enable streaming loop detection — useful as a guardrail in headless / non-interactive runs where stuck repetition can otherwise waste budget. | `true` |
| `model.skipStartupContext` | boolean | Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup. | `false` |
| `model.enableOpenAILogging` | boolean | Enables logging of OpenAI API calls for debugging and analysis. When enabled, API requests and responses are logged to JSON files. | `false` |
| `model.openAILoggingDir` | string | Custom directory path for OpenAI API logs. If not specified, defaults to `logs/openai` in the current working directory. Supports absolute paths, relative paths (resolved from current working directory), and `~` expansion (home directory). | `undefined` |
| Setting | Type | Description | Default |
| -------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `model.name` | string | The Qwen model to use for conversations. | `undefined` |
| `model.maxSessionTurns` | number | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| `model.maxWallTimeSeconds` | number | Wall-clock budget for headless / unattended runs, in seconds. `-1` means unlimited. Overridable per-invocation via `--max-wall-time`, which requires a positive duration (`90`, `30s`, `5m`, `1h`, `1.5h`); the minimum is 1 second — sub-second values (`500ms`, `0.5`) are rejected as typos. Omit the flag to fall back to this setting. Aborts with exit code 55 when exceeded. | `-1` |
| `model.maxToolCalls` | number | Cumulative tool-call budget for a run (counts every executed tool, success or failure; `structured_output` under `--json-schema` is exempt). `-1` means unlimited; `0` means "no tool calls allowed". Capped at 1,000,000 to catch typos. Overridable via `--max-tool-calls`. Aborts with exit code 55 when exceeded. | `-1` |
| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `enableCacheControl`, `splitToolMedia` (default `true`; splits tool-returned media — including images read by the built-in read_file — into a follow-up user message instead of the spec-violating `role: "tool"` message, so strict OpenAI-compatible servers like doubao / new-api / LM Studio can see it; set `false` to restore the legacy embed-in-tool behavior), `toolResultContentFormat` (default `"parts"`; set `"string"` only for legacy OpenAI-compatible runtimes whose tool templates ignore text content parts), `contextWindowSize` (override model's context window size), `modalities` (override auto-detected input modalities), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` |
| `model.chatCompression.contextPercentageThreshold` | number | **REMOVED.** Auto-compaction now uses a three-tier threshold ladder (warn / auto / hard) computed internally from the model's context window via the `computeThresholds()` function — no longer user-configurable. Setting this field in `settings.json` is silently ignored (no startup warning). There is currently no replacement for "disable compression entirely" — reactive overflow recovery remains the safety net at the API layer if compression itself fails. (See PR #4345 / `docs/design/auto-compaction-threshold-redesign.md` for the redesign rationale.) | `N/A` |
| `model.chatCompression.maxRecentFilesToRetain` | number | Number of most-recently-touched files whose current content is restored (embedded if small, otherwise referenced by path) into history after auto-compaction. `0` restores none. Env override: `QWEN_COMPACT_MAX_RECENT_FILES`. | `5` |
| `model.chatCompression.maxRecentImagesToRetain` | number | Number of most-recent images (tool screenshots / user pastes) restored into history after auto-compaction. `0` restores none. Env override: `QWEN_COMPACT_MAX_RECENT_IMAGES`. | `3` |
| `model.chatCompression.enableScreenshotTrigger` | boolean | When `true`, auto-compaction also fires once the number of tool-returned images accumulated in history reaches `screenshotTriggerThreshold`, independent of token usage — aimed at computer-use sessions where frequent screenshots dilute model attention. Counts only images returned inside tool results, not user-pasted images. Env override: `QWEN_COMPACT_SCREENSHOT_TRIGGER` (`1`/`true`/`0`/`false`). | `true` |
| `model.chatCompression.screenshotTriggerThreshold` | number | Tool-returned image count at or above which the screenshot trigger fires (only when `enableScreenshotTrigger`). Compaction resets the count — surviving images are re-embedded as top-level parts, which the trigger doesn't count — so it won't immediately re-fire. Env override: `QWEN_COMPACT_SCREENSHOT_THRESHOLD`. | `50` |
| `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `true` |
| `model.skipLoopDetection` | boolean | Disables streaming loop detection checks. Defaults to `true` (loop detection is skipped) to avoid false positives interrupting legitimate workflows. Set to `false` to re-enable streaming loop detection — useful as a guardrail in headless / non-interactive runs where stuck repetition can otherwise waste budget. | `true` |
| `model.skipStartupContext` | boolean | Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup. | `false` |
| `model.enableOpenAILogging` | boolean | Enables logging of OpenAI API calls for debugging and analysis. When enabled, API requests and responses are logged to JSON files. | `false` |
| `model.openAILoggingDir` | string | Custom directory path for OpenAI API logs. If not specified, defaults to `logs/openai` in the current working directory. Supports absolute paths, relative paths (resolved from current working directory), and `~` expansion (home directory). | `undefined` |
**Example model.generationConfig:**
@ -167,6 +167,7 @@ Settings are organized into categories. Most settings should be placed within th
"image": true
},
"enableCacheControl": true,
"toolResultContentFormat": "parts",
"customHeaders": {
"X-Client-Request-ID": "req-123"
},
@ -195,6 +196,10 @@ This is transparent to users — you may briefly see a retry indicator if escala
To override this behavior, either set `samplingParams.max_tokens` in your settings or use the `QWEN_CODE_MAX_OUTPUT_TOKENS` environment variable.
**toolResultContentFormat:**
Controls how text-only tool results are serialized in OpenAI-compatible requests. The default `"parts"` keeps the standard content-part array shape. Set `"string"` only for legacy OpenAI-compatible runtimes whose tool templates ignore text content parts, such as older GLM-5.1 vLLM/SGLang templates. Tool-returned media is still controlled by `splitToolMedia`.
**contextWindowSize:**
Overrides the default context window size for the selected model. Qwen Code determines the context window using built-in defaults based on model name matching, with a constant fallback value. Use this setting when a provider's effective context limit differs from Qwen Code's default. This value defines the model's assumed maximum context capacity, not a per-request token limit.

View file

@ -1287,6 +1287,21 @@ const SETTINGS_SCHEMA = {
parentKey: 'generationConfig',
showInDialog: false,
},
toolResultContentFormat: {
type: 'enum',
label: 'Tool Result Content Format',
category: 'Generation Configuration',
requiresRestart: false,
default: 'parts',
description:
'Controls how text-only tool results are serialized in OpenAI-compatible requests. Use "parts" for the default content-part array shape. Use "string" only for legacy OpenAI-compatible runtimes whose tool templates ignore text content parts (for example older GLM-5.1 vLLM/SGLang templates; QwenLM/qwen-code#3361). Tool-returned media is still handled by splitToolMedia.',
parentKey: 'generationConfig',
showInDialog: false,
options: [
{ value: 'parts', label: 'Content Parts (Default)' },
{ value: 'string', label: 'String' },
],
},
schemaCompliance: {
type: 'enum',
label: 'Tool Schema Compliance',

View file

@ -4091,6 +4091,7 @@ describe('Model Switching and Config Updates', () => {
['contextWindowSize']: 128_000,
['samplingParams']: { temperature: 0.8 },
['enableCacheControl']: false,
['toolResultContentFormat']: 'string',
};
vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({
@ -4100,6 +4101,7 @@ describe('Model Switching and Config Updates', () => {
contextWindowSize: { kind: 'computed', detail: 'auto' },
samplingParams: { kind: 'settings' },
enableCacheControl: { kind: 'settings' },
toolResultContentFormat: { kind: 'settings' },
},
});
@ -4119,6 +4121,7 @@ describe('Model Switching and Config Updates', () => {
expect(updatedConfig['contextWindowSize']).toBe(128_000);
expect(updatedConfig['samplingParams']?.temperature).toBe(0.8);
expect(updatedConfig['enableCacheControl']).toBe(false);
expect(updatedConfig['toolResultContentFormat']).toBe('string');
// Verify sources are also updated
const sources = config.getContentGeneratorConfigSources();
@ -4128,6 +4131,7 @@ describe('Model Switching and Config Updates', () => {
expect(sources['contextWindowSize']?.detail).toBe('auto');
expect(sources['samplingParams']?.kind).toBe('settings');
expect(sources['enableCacheControl']?.kind).toBe('settings');
expect(sources['toolResultContentFormat']?.kind).toBe('settings');
});
it('should trigger full refresh when switching to non-qwen-oauth provider', async () => {

View file

@ -2660,6 +2660,8 @@ export class Config {
this.contentGeneratorConfig.enableCacheControl =
config.enableCacheControl;
this.contentGeneratorConfig.splitToolMedia = config.splitToolMedia;
this.contentGeneratorConfig.toolResultContentFormat =
config.toolResultContentFormat;
if ('model' in sources) {
this.contentGeneratorConfigSources['model'] = sources['model'];
@ -2680,6 +2682,10 @@ export class Config {
this.contentGeneratorConfigSources['splitToolMedia'] =
sources['splitToolMedia'];
}
if ('toolResultContentFormat' in sources) {
this.contentGeneratorConfigSources['toolResultContentFormat'] =
sources['toolResultContentFormat'];
}
return;
}

View file

@ -135,6 +135,12 @@ export type ContentGeneratorConfig = {
// and safe for permissive providers); set false to restore the legacy
// embed-in-tool-message behavior. See QwenLM/qwen-code#4876, #3616.
splitToolMedia?: boolean;
// OpenAI Chat Completions accepts tool result content as either a plain
// string or an array of text content parts. Some older OpenAI-compatible
// tool templates only read the string form, so this opt-in serializes
// text-only tool results as strings while leaving the default spec-compliant
// content-part shape unchanged.
toolResultContentFormat?: 'parts' | 'string';
};
// Keep the public ContentGeneratorConfigSources API, but reuse the generic

View file

@ -1576,6 +1576,7 @@ describe('LoggingContentGenerator', () => {
authType: AuthType.USE_OPENAI,
enableOpenAILogging: true,
modalities: { image: true },
toolResultContentFormat: 'string' as const,
};
const generator = new LoggingContentGenerator(
wrapped,
@ -1609,6 +1610,7 @@ describe('LoggingContentGenerator', () => {
expect.objectContaining({
model: 'test-model',
modalities: { image: true },
toolResultContentFormat: 'string',
}),
{ cleanOrphanToolCalls: false },
);
@ -1633,6 +1635,61 @@ describe('LoggingContentGenerator', () => {
]);
});
it('uses string tool result content in reconstructed OpenAI logs when configured', async () => {
convertGeminiRequestToOpenAISpy.mockImplementationOnce(
(request, requestContext, options) =>
realConvertGeminiRequestToOpenAI(request, requestContext, options),
);
const wrapped = createWrappedGenerator(
vi
.fn()
.mockResolvedValue(
createResponse('resp-tool-log', 'test-model', [{ text: 'ok' }]),
),
vi.fn(),
);
const generator = new LoggingContentGenerator(wrapped, createConfig(), {
model: 'test-model',
authType: AuthType.USE_OPENAI,
enableOpenAILogging: true,
toolResultContentFormat: 'string',
});
const request = {
model: 'test-model',
contents: [
{
role: 'model',
parts: [{ functionCall: { id: 'call_1', name: 'shell', args: {} } }],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'call_1',
name: 'shell',
response: { output: 'hello world' },
},
},
],
},
],
} as unknown as GenerateContentParameters;
await generator.generateContent(request, 'prompt-tool-log');
const openaiLoggerInstance = vi.mocked(OpenAILogger).mock.results[0]
?.value as { logInteraction: ReturnType<typeof vi.fn> };
const [openaiRequest] = openaiLoggerInstance.logInteraction.mock
.calls[0] as [OpenAI.Chat.ChatCompletionCreateParams];
const toolMessage = openaiRequest.messages.find(
(message) => message.role === 'tool',
);
expect(toolMessage?.content).toBe('hello world');
});
it('logs the captured wire request including provider-injected fields (generateContent)', async () => {
const wireRequest: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'deepseek-v4-pro',

View file

@ -106,6 +106,7 @@ export class LoggingContentGenerator implements ContentGenerator {
private schemaCompliance?: 'auto' | 'openapi_30';
private modalities?: InputModalities;
private splitToolMedia?: boolean;
private toolResultContentFormat?: ContentGeneratorConfig['toolResultContentFormat'];
private readonly generatorAuthType: ContentGeneratorConfig['authType'];
constructor(
@ -115,6 +116,7 @@ export class LoggingContentGenerator implements ContentGenerator {
) {
this.modalities = generatorConfig.modalities;
this.splitToolMedia = generatorConfig.splitToolMedia;
this.toolResultContentFormat = generatorConfig.toolResultContentFormat;
this.generatorAuthType = generatorConfig.authType;
// Extract fields needed for initialization from passed config
@ -769,6 +771,7 @@ export class LoggingContentGenerator implements ContentGenerator {
// --openai-logging fallback reconstruction reflects the same split as the
// request actually sent. Opt out via generationConfig.splitToolMedia = false.
splitToolMedia: this.splitToolMedia ?? true,
toolResultContentFormat: this.toolResultContentFormat ?? 'parts',
startTime: 0,
};
}

View file

@ -908,6 +908,53 @@ describe('OpenAIContentConverter', () => {
expect(img?.image_url?.url).toBe('data:image/png;base64,aaa');
});
it('should keep embedded media as content parts when string tool content is requested but splitToolMedia is false', () => {
const request: GenerateContentParameters = {
model: 'models/test',
contents: [
{
role: 'model',
parts: [{ functionCall: { id: 'c1', name: 'shot', args: {} } }],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'c1',
name: 'shot',
response: { output: 'screenshot' },
parts: [
{ inlineData: { mimeType: 'image/png', data: 'aaa' } },
],
},
},
],
},
],
};
const messages = converter.convertGeminiRequestToOpenAI(request, {
...requestContext,
splitToolMedia: false,
toolResultContentFormat: 'string',
});
const toolMessage = messages.find((m) => m.role === 'tool');
expect(Array.isArray(toolMessage?.content)).toBe(true);
const toolContent = toolMessage?.content as Array<{
type: string;
text?: string;
image_url?: { url: string };
}>;
expect(toolContent.find((p) => p.type === 'text')?.text).toBe(
'screenshot',
);
expect(
toolContent.find((p) => p.type === 'image_url')?.image_url?.url,
).toBe('data:image/png;base64,aaa');
});
it('should convert function responses with fileData to tool message with embedded image_url', () => {
const request: GenerateContentParameters = {
model: 'models/test',
@ -1511,6 +1558,23 @@ describe('OpenAIContentConverter', () => {
expect(userMessage).toBeUndefined();
});
it('should serialize text-only tool content as a string when requested', () => {
const request = createRequestWithFunctionResponse({
output: 'Plain text output',
});
const messages = converter.convertGeminiRequestToOpenAI(request, {
...requestContext,
toolResultContentFormat: 'string',
});
const toolMessage = messages.find((message) => message.role === 'tool');
expect(toolMessage).toBeDefined();
expect(toolMessage?.content).toBe('Plain text output');
const userMessage = messages.find((message) => message.role === 'user');
expect(userMessage).toBeUndefined();
});
it('should create tool message with empty content for empty function responses', () => {
const request: GenerateContentParameters = {
model: 'models/test',

View file

@ -662,6 +662,20 @@ function processContent(
accumulatedSplitMedia.push(...mediaParts);
}
}
if (
requestContext.toolResultContentFormat === 'string' &&
Array.isArray(toolMessage.content)
) {
const toolContent = toolMessage.content as OpenAIContentPart[];
if (
toolContent.every(
(cp): cp is OpenAI.Chat.ChatCompletionContentPartText =>
cp?.type === 'text',
)
) {
toolMessage.content = toolContent.map((cp) => cp.text).join('\n');
}
}
messages.push(toolMessage);
}
}

View file

@ -346,6 +346,90 @@ describe('ContentGenerationPipeline', () => {
);
});
it('should pass configured tool result content format to the converter', 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();
mockProvider.getRequestContextOverrides = vi.fn().mockReturnValue({});
mockContentGeneratorConfig.toolResultContentFormat = 'string';
(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({
toolResultContentFormat: 'string',
}),
);
});
it('should let provider tool result content format overrides take precedence', 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();
mockContentGeneratorConfig.toolResultContentFormat = 'parts';
mockProvider.getRequestContextOverrides = vi.fn().mockReturnValue({
toolResultContentFormat: 'string',
});
(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({
toolResultContentFormat: 'string',
}),
);
});
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 = {

View file

@ -630,6 +630,10 @@ export class ContentGenerationPipeline {
// message is spec-compliant and safe for permissive providers too.
// Opt out via generationConfig.splitToolMedia = false.
true,
toolResultContentFormat:
providerOverrides.toolResultContentFormat ??
this.contentGeneratorConfig.toolResultContentFormat ??
'parts',
...(toolCallParser ? { toolCallParser } : {}),
...(responseParsingOptions ? { responseParsingOptions } : {}),
...(taggedThinkingParser ? { taggedThinkingParser } : {}),

View file

@ -1,9 +1,11 @@
import type { GenerateContentConfig } from '@google/genai';
import type OpenAI from 'openai';
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
import type { OpenAIResponseParsingOptions } from '../responseParsingOptions.js';
export type OpenAIRequestContextOverrides = {
splitToolMedia?: boolean;
toolResultContentFormat?: ContentGeneratorConfig['toolResultContentFormat'];
};
// Extended types to support cache_control for DashScope

View file

@ -48,6 +48,9 @@ export interface RequestContext {
// user message for strict OpenAI-compat servers. See ContentGeneratorConfig
// for details.
splitToolMedia?: boolean;
// Default keeps tool result text as content parts; "string" is an opt-in
// compatibility mode for older OpenAI-compatible tool templates.
toolResultContentFormat?: ContentGeneratorConfig['toolResultContentFormat'];
/**
* Per-stream mutable state for cumulative-delta normalization on the visible
* content channel. Initialised lazily on first use. Must NOT be shared or

View file

@ -31,6 +31,7 @@ export const MODEL_GENERATION_CONFIG_FIELDS = [
'extra_body',
'modalities',
'splitToolMedia',
'toolResultContentFormat',
] as const satisfies ReadonlyArray<keyof ContentGeneratorConfig>;
/**

View file

@ -39,6 +39,7 @@ export type ModelGenerationConfig = Pick<
| 'contextWindowSize'
| 'modalities'
| 'splitToolMedia'
| 'toolResultContentFormat'
>;
/**

View file

@ -553,6 +553,14 @@
"type": "boolean",
"default": true
},
"toolResultContentFormat": {
"description": "Controls how text-only tool results are serialized in OpenAI-compatible requests. Use \"parts\" for the default content-part array shape. Use \"string\" only for legacy OpenAI-compatible runtimes whose tool templates ignore text content parts (for example older GLM-5.1 vLLM/SGLang templates; QwenLM/qwen-code#3361). Tool-returned media is still handled by splitToolMedia. Options: parts, string",
"enum": [
"parts",
"string"
],
"default": "parts"
},
"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",
"enum": [