From 9c8ae0907dd740d33ca8ceb9d6f100d7d0a49dc6 Mon Sep 17 00:00:00 2001 From: gaoyukun Date: Fri, 14 Aug 2026 19:12:44 +0800 Subject: [PATCH] feat(ai): add requiresNonNullAssistantContent compat flag Some OpenAI-compatible gateways reject replayed assistant messages whose content is null (e.g. tool-call-only assistant messages) and require "" instead. The only existing way to force "" was requiresAssistantAfterToolResult, which also inserts an assistant message between tool results and the following user message. Add an independent opt-in flag so the non-null content fix can be enabled without the assistant-insertion side effect. Default false, so no existing provider behavior changes. closes #8063 --- packages/ai/README.md | 1 + packages/ai/scripts/generate-models.ts | 2 + packages/ai/src/api/openai-completions.ts | 5 +- packages/ai/src/types.ts | 2 + packages/ai/test/deferred-tools.test.ts | 1 + ...letions-non-null-assistant-content.test.ts | 110 ++++++++++++++++++ ...penai-completions-thinking-as-text.test.ts | 1 + .../openai-completions-tool-choice.test.ts | 1 + ...nai-completions-tool-result-images.test.ts | 1 + packages/coding-agent/docs/custom-provider.md | 1 + packages/coding-agent/docs/models.md | 1 + .../coding-agent/src/core/model-config.ts | 1 + 12 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 packages/ai/test/openai-completions-non-null-assistant-content.test.ts diff --git a/packages/ai/README.md b/packages/ai/README.md index ed4e4a545..1eb0ccd71 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1165,6 +1165,7 @@ interface OpenAICompletionsCompat { maxTokensField?: 'max_completion_tokens' | 'max_tokens'; // Which field name to use (default: max_completion_tokens) requiresToolResultName?: boolean; // Whether tool results require the `name` field (default: false) requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false) + requiresNonNullAssistantContent?: boolean; // Whether replayed assistant messages with no text must send "" instead of null content (default: false) requiresThinkingAsText?: boolean; // Whether thinking blocks must be converted to text (default: false) requiresReasoningContentOnAssistantMessages?: boolean; // Whether all replayed assistant messages must include empty reasoning_content when reasoning is enabled (default: auto-detected for DeepSeek) thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'chat-template' | 'qwen-chat-template' | 'string-thinking' | 'ant-ling'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses thinking: { type }, 'qwen' uses enable_thinking, 'chat-template' uses configurable chat_template_kwargs, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking and preserve_thinking, 'string-thinking' uses top-level thinking, 'ant-ling' uses reasoning: { effort } only for mapped efforts (default: openai) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 2e0194027..6426c4644 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -555,6 +555,7 @@ const OPENAI_COMPLETIONS_DEFAULT_COMPAT = { maxTokensField: "max_completion_tokens", requiresToolResultName: false, requiresAssistantAfterToolResult: false, + requiresNonNullAssistantContent: false, requiresThinkingAsText: false, requiresReasoningContentOnAssistantMessages: false, thinkingFormat: "openai", @@ -635,6 +636,7 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", requiresToolResultName: false, requiresAssistantAfterToolResult: false, + requiresNonNullAssistantContent: false, requiresThinkingAsText: false, requiresReasoningContentOnAssistantMessages: isDeepSeek, thinkingFormat: isDeepSeek diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 569457d31..6bb017d3a 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -1082,7 +1082,7 @@ export function convertMessages( // Some providers don't accept null content, use empty string instead const assistantMsg: ChatCompletionAssistantMessageParam = { role: "assistant", - content: compat.requiresAssistantAfterToolResult ? "" : null, + content: compat.requiresAssistantAfterToolResult || compat.requiresNonNullAssistantContent ? "" : null, }; const assistantTextParts = msg.content @@ -1452,6 +1452,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", requiresToolResultName: false, requiresAssistantAfterToolResult: false, + requiresNonNullAssistantContent: false, requiresThinkingAsText: false, requiresReasoningContentOnAssistantMessages: isDeepSeek, thinkingFormat: isDeepSeek @@ -1503,6 +1504,8 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion requiresToolResultName: model.compat.requiresToolResultName ?? detected.requiresToolResultName, requiresAssistantAfterToolResult: model.compat.requiresAssistantAfterToolResult ?? detected.requiresAssistantAfterToolResult, + requiresNonNullAssistantContent: + model.compat.requiresNonNullAssistantContent ?? detected.requiresNonNullAssistantContent, requiresThinkingAsText: model.compat.requiresThinkingAsText ?? detected.requiresThinkingAsText, requiresReasoningContentOnAssistantMessages: model.compat.requiresReasoningContentOnAssistantMessages ?? diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index b9ba470a8..339c5ab21 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -533,6 +533,8 @@ export interface OpenAICompletionsCompat { requiresToolResultName?: boolean; /** Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL. */ requiresAssistantAfterToolResult?: boolean; + /** Whether replayed assistant messages with no text (e.g. tool-call-only messages) must send `""` instead of `null` content. Default: false. */ + requiresNonNullAssistantContent?: boolean; /** Whether thinking blocks must be converted to text blocks with delimiters. Default: auto-detected from URL. */ requiresThinkingAsText?: boolean; /** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */ diff --git a/packages/ai/test/deferred-tools.test.ts b/packages/ai/test/deferred-tools.test.ts index 614203a5b..b40a6dee5 100644 --- a/packages/ai/test/deferred-tools.test.ts +++ b/packages/ai/test/deferred-tools.test.ts @@ -362,6 +362,7 @@ describe("deferred tools", () => { maxTokensField: "max_tokens", requiresToolResultName: false, requiresAssistantAfterToolResult: false, + requiresNonNullAssistantContent: false, requiresThinkingAsText: false, requiresReasoningContentOnAssistantMessages: false, thinkingFormat: "openai", diff --git a/packages/ai/test/openai-completions-non-null-assistant-content.test.ts b/packages/ai/test/openai-completions-non-null-assistant-content.test.ts new file mode 100644 index 000000000..dc743ee72 --- /dev/null +++ b/packages/ai/test/openai-completions-non-null-assistant-content.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { convertMessages } from "../src/api/openai-completions.ts"; +import { getModel } from "../src/compat.ts"; +import type { AssistantMessage, Context, Model, OpenAICompletionsCompat, Usage } from "../src/types.ts"; + +const emptyUsage: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +const baseCompat: Omit, "deferredToolsMode"> & { + deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"]; +} = { + supportsStore: true, + supportsDeveloperRole: true, + supportsReasoningEffort: true, + supportsUsageInStreaming: true, + supportsFinishReason: true, + maxTokensField: "max_completion_tokens", + requiresToolResultName: false, + requiresAssistantAfterToolResult: false, + requiresNonNullAssistantContent: false, + requiresThinkingAsText: false, + requiresReasoningContentOnAssistantMessages: false, + thinkingFormat: "openai", + openRouterRouting: {}, + vercelGatewayRouting: {}, + chatTemplateKwargs: {}, + zaiToolStream: false, + supportsStrictMode: true, + supportsOpenAIGrammarTools: false, + cacheControlFormat: "anthropic", + sendSessionAffinityHeaders: false, + sessionAffinityFormat: "openai", + supportsLongCacheRetention: true, +}; + +function buildModel(): Model<"openai-completions"> { + const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini"); + return { ...baseModel, api: "openai-completions", input: ["text", "image"] }; +} + +function buildContext(model: Model<"openai-completions">): Context { + const now = Date.now(); + const assistantMessage: AssistantMessage = { + role: "assistant", + content: [{ type: "toolCall", id: "tool-1", name: "bash", arguments: { command: "true" } }], + api: model.api, + provider: model.provider, + model: model.id, + usage: emptyUsage, + stopReason: "toolUse", + timestamp: now, + }; + return { + messages: [ + { role: "user", content: "Run the command", timestamp: now - 1 }, + assistantMessage, + { + role: "toolResult", + toolCallId: "tool-1", + toolName: "bash", + content: [{ type: "text", text: "ok" }], + isError: false, + timestamp: now + 1, + }, + { role: "user", content: "Thanks", timestamp: now + 2 }, + ], + }; +} + +describe("requiresNonNullAssistantContent", () => { + it("sends null content for tool-call-only assistant messages by default", () => { + const model = buildModel(); + const messages = convertMessages(model, buildContext(model), baseCompat); + const assistantMessages = messages.filter((message) => message.role === "assistant"); + expect(assistantMessages.length).toBe(1); + expect(assistantMessages[0].content).toBe(null); + }); + + it("sends empty string content without inserting an assistant message after tool results", () => { + const model = buildModel(); + const messages = convertMessages(model, buildContext(model), { + ...baseCompat, + requiresNonNullAssistantContent: true, + }); + const roles = messages.map((message) => message.role); + expect(roles).toEqual(["user", "assistant", "tool", "user"]); + const assistantMessages = messages.filter((message) => message.role === "assistant"); + expect(assistantMessages.length).toBe(1); + expect(assistantMessages[0].content).toBe(""); + }); + + it("still inserts an assistant message when requiresAssistantAfterToolResult is enabled", () => { + const model = buildModel(); + const messages = convertMessages(model, buildContext(model), { + ...baseCompat, + requiresAssistantAfterToolResult: true, + }); + const roles = messages.map((message) => message.role); + expect(roles).toEqual(["user", "assistant", "tool", "assistant", "user"]); + const assistantMessages = messages.filter((message) => message.role === "assistant"); + expect(assistantMessages[0].content).toBe(""); + expect(assistantMessages[1].content).toBe("I have processed the tool results."); + }); +}); diff --git a/packages/ai/test/openai-completions-thinking-as-text.test.ts b/packages/ai/test/openai-completions-thinking-as-text.test.ts index 1f3539fc1..9be8df2db 100644 --- a/packages/ai/test/openai-completions-thinking-as-text.test.ts +++ b/packages/ai/test/openai-completions-thinking-as-text.test.ts @@ -30,6 +30,7 @@ const compat = { maxTokensField: "max_completion_tokens", requiresToolResultName: false, requiresAssistantAfterToolResult: false, + requiresNonNullAssistantContent: false, requiresThinkingAsText: true, requiresReasoningContentOnAssistantMessages: false, thinkingFormat: "openai", diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 0e1ce0e36..a0706dcd7 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1289,6 +1289,7 @@ describe("openai-completions tool_choice", () => { maxTokensField: "max_completion_tokens", requiresToolResultName: false, requiresAssistantAfterToolResult: false, + requiresNonNullAssistantContent: false, requiresThinkingAsText: false, requiresReasoningContentOnAssistantMessages: false, thinkingFormat: "openai", diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index f9e5cda73..b339438ac 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -30,6 +30,7 @@ const compat: Omit, "deferredToolsMode"> & { maxTokensField: "max_completion_tokens", requiresToolResultName: false, requiresAssistantAfterToolResult: false, + requiresNonNullAssistantContent: false, requiresThinkingAsText: false, requiresReasoningContentOnAssistantMessages: false, thinkingFormat: "openai", diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 87fc02f23..c3d7f947a 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -749,6 +749,7 @@ interface ProviderModelConfig { maxTokensField?: "max_completion_tokens" | "max_tokens"; requiresToolResultName?: boolean; requiresAssistantAfterToolResult?: boolean; + requiresNonNullAssistantContent?: boolean; requiresThinkingAsText?: boolean; requiresReasoningContentOnAssistantMessages?: boolean; thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "chat-template" | "qwen-chat-template" | "string-thinking" | "ant-ling"; diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index fc81cc8a8..27dfa9245 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -445,6 +445,7 @@ For providers with partial OpenAI compatibility, use the `compat` field. | `maxTokensField` | Use `max_completion_tokens` or `max_tokens` | | `requiresToolResultName` | Include `name` on tool result messages | | `requiresAssistantAfterToolResult` | Insert an assistant message before a user message after tool results | +| `requiresNonNullAssistantContent` | Send `""` instead of `null` content on replayed assistant messages with no text (e.g. tool-call-only messages) for gateways that reject `null` content | | `requiresThinkingAsText` | Convert thinking blocks to plain text | | `requiresReasoningContentOnAssistantMessages` | Include empty `reasoning_content` on all replayed assistant messages when reasoning is enabled | | `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `zai`, `qwen`, `chat-template`, or `qwen-chat-template` thinking parameters | diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index 9ee6f4368..d033bbdc1 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -77,6 +77,7 @@ const OpenAICompletionsCompatSchema = Type.Object({ maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])), requiresToolResultName: Type.Optional(Type.Boolean()), requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()), + requiresNonNullAssistantContent: Type.Optional(Type.Boolean()), requiresThinkingAsText: Type.Optional(Type.Boolean()), requiresReasoningContentOnAssistantMessages: Type.Optional(Type.Boolean()), thinkingFormat: Type.Optional(