diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 3b3717ad45..8cad064732 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -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({ diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 43a4fe7ae3..0906e4fd8a 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -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, diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx index 4eea6f09f1..25fefd2639 100644 --- a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx @@ -158,6 +158,16 @@ export const OpenAICompatible = ({ onChange={handleInputChange("openAiStreamingEnabled", noTransform)}> {t("settings:modelInfo.enableStreaming")} +
+ + {t("settings:providers.openAiStrictToolMessageOrdering")} + +
+ {t("settings:providers.openAiStrictToolMessageOrderingDescription")} +
+