feat: add strict tool message ordering setting for OpenAI Compatible providers

Adds a new setting `openAiStrictToolMessageOrdering` to OpenAI Compatible
providers that enables merging text content after tool results into the
last tool message. This fixes "Unexpected role user after role tool"
errors with NVIDIA NIM, OpenRouter, and other strict OpenAI-compatible APIs.

When enabled, environment_details and other text content that follows
tool_result messages are merged into the last tool message instead of
being sent as a separate user message.

Fixes #10540
This commit is contained in:
Roo Code 2026-01-10 05:40:55 +00:00
parent e8ed344b0f
commit db061661cc
4 changed files with 34 additions and 5 deletions

View file

@ -250,6 +250,7 @@ const openAiSchema = baseProviderSettingsSchema.extend({
openAiStreamingEnabled: z.boolean().optional(),
openAiHostHeader: z.string().optional(), // Keep temporarily for backward compatibility during migration.
openAiHeaders: z.record(z.string(), z.string()).optional(),
openAiStrictToolMessageOrdering: z.boolean().optional(), // Merge text after tool results to avoid "user after tool" errors.
})
const ollamaSchema = baseProviderSettingsSchema.extend({

View file

@ -122,7 +122,11 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
}
}
convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)]
// When openAiStrictToolMessageOrdering is enabled, merge text content after tool results
// into the last tool message. This fixes "user after tool" errors with NVIDIA NIM,
// OpenRouter, and other strict OpenAI-compatible APIs.
const mergeToolResultText = this.options.openAiStrictToolMessageOrdering ?? false
convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages, { mergeToolResultText })]
if (modelInfo.supportsPromptCache) {
// Note: the following logic is copied from openrouter:
@ -224,11 +228,15 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
yield this.processUsageMetrics(lastUsage, modelInfo)
}
} else {
// When openAiStrictToolMessageOrdering is enabled, merge text content after tool results
// into the last tool message. This fixes "user after tool" errors with NVIDIA NIM,
// OpenRouter, and other strict OpenAI-compatible APIs.
const mergeToolResultText = this.options.openAiStrictToolMessageOrdering ?? false
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
model: modelId,
messages: deepseekReasoner
? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
: [systemMessage, ...convertToOpenAiMessages(messages)],
: [systemMessage, ...convertToOpenAiMessages(messages, { mergeToolResultText })],
...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }),
...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }),
...(metadata?.toolProtocol === "native" && {
@ -342,6 +350,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
if (this.options.openAiStreamingEnabled ?? true) {
const isGrokXAI = this._isGrokXAI(this.options.openAiBaseUrl)
// When openAiStrictToolMessageOrdering is enabled, merge text content after tool results
// into the last tool message. This fixes "user after tool" errors with NVIDIA NIM,
// OpenRouter, and other strict OpenAI-compatible APIs.
const mergeToolResultText = this.options.openAiStrictToolMessageOrdering ?? false
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: modelId,
messages: [
@ -349,7 +361,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
role: "developer",
content: `Formatting re-enabled\n${systemPrompt}`,
},
...convertToOpenAiMessages(messages),
...convertToOpenAiMessages(messages, { mergeToolResultText }),
],
stream: true,
...(isGrokXAI ? {} : { stream_options: { include_usage: true } }),
@ -379,6 +391,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
yield* this.handleStreamResponse(stream)
} else {
// When openAiStrictToolMessageOrdering is enabled, merge text content after tool results
// into the last tool message. This fixes "user after tool" errors with NVIDIA NIM,
// OpenRouter, and other strict OpenAI-compatible APIs.
const mergeToolResultText = this.options.openAiStrictToolMessageOrdering ?? false
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
model: modelId,
messages: [
@ -386,7 +402,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
role: "developer",
content: `Formatting re-enabled\n${systemPrompt}`,
},
...convertToOpenAiMessages(messages),
...convertToOpenAiMessages(messages, { mergeToolResultText }),
],
reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined,
temperature: undefined,

View file

@ -158,6 +158,16 @@ export const OpenAICompatible = ({
onChange={handleInputChange("openAiStreamingEnabled", noTransform)}>
{t("settings:modelInfo.enableStreaming")}
</Checkbox>
<div>
<Checkbox
checked={apiConfiguration?.openAiStrictToolMessageOrdering ?? false}
onChange={handleInputChange("openAiStrictToolMessageOrdering", noTransform)}>
{t("settings:providers.openAiStrictToolMessageOrdering")}
</Checkbox>
<div className="text-sm text-vscode-descriptionForeground ml-6">
{t("settings:providers.openAiStrictToolMessageOrderingDescription")}
</div>
</div>
<div>
<Checkbox
checked={apiConfiguration?.includeMaxTokens ?? true}

View file

@ -531,7 +531,9 @@
"placeholder": "Default: claude",
"maxTokensLabel": "Max Output Tokens",
"maxTokensDescription": "Maximum number of output tokens for Claude Code responses. Default is 8000."
}
},
"openAiStrictToolMessageOrdering": "Strict tool message ordering",
"openAiStrictToolMessageOrderingDescription": "Enable this if your API returns errors like \"Unexpected role 'user' after role 'tool'\". This merges text content into tool messages to fix compatibility with NVIDIA NIM, OpenRouter, and other strict OpenAI-compatible APIs."
},
"browser": {
"enable": {