diff --git a/packages/ai/README.md b/packages/ai/README.md index d7a7a21e8..23896ba97 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1459,6 +1459,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' | 'baseten' | '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, 'baseten' uses configurable chat_template_args 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/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 0e5b606e6..155e9e5e5 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -1281,7 +1281,7 @@ export function convertMessages( // Some providers don't accept null content, use empty string instead const assistantMsg: ChatCompletionAssistantMessageParamWithReasoning = { role: "assistant", - content: compat.requiresAssistantAfterToolResult ? "" : null, + content: compat.requiresAssistantAfterToolResult || compat.requiresNonNullAssistantContent ? "" : null, }; const assistantTextParts = msg.content @@ -1645,6 +1645,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 @@ -1701,6 +1702,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/providers/compat-schema.ts b/packages/ai/src/providers/compat-schema.ts index 88defded8..aaa34eebd 100644 --- a/packages/ai/src/providers/compat-schema.ts +++ b/packages/ai/src/providers/compat-schema.ts @@ -232,6 +232,12 @@ const OpenAICompletionsCompatProperties = { "Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL.", }), ), + requiresNonNullAssistantContent: opt( + bool({ + description: + 'Whether replayed assistant messages with no text (e.g. tool-call-only messages) must send "" instead of null content. Default: false.', + }), + ), requiresThinkingAsText: opt( bool({ description: 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..8cc592a32 --- /dev/null +++ b/packages/ai/test/openai-completions-non-null-assistant-content.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { convertMessages } from "../src/api/openai-completions.ts"; +import { getModel, normalizeContext } from "../src/compat.ts"; +import type { AssistantMessage, 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, "thinkingTokenBudgetField" | "vllmPriority"> & { + thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"]; +} = { + 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: {}, + chatTemplateArgs: {}, + zaiToolStream: false, + supportsThinkingTokenBudget: false, + thinkingTokenBudgetField: undefined, + supportsStrictMode: true, + supportsOpenAIGrammarTools: false, + supportsMidConvoSystemMessages: false, + supportsMidConvoToolAdditions: 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">) { + 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 normalizeContext({ + 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 e609b1453..a98c61612 100644 --- a/packages/ai/test/openai-completions-thinking-as-text.test.ts +++ b/packages/ai/test/openai-completions-thinking-as-text.test.ts @@ -31,6 +31,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 cf6aeebb5..58c21098c 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1405,6 +1405,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 7acc7221c..6819ed045 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -23,6 +23,7 @@ const compat: Omit, "thinkingTokenBudgetField" maxTokensField: "max_completion_tokens", requiresToolResultName: false, requiresAssistantAfterToolResult: false, + requiresNonNullAssistantContent: false, requiresThinkingAsText: false, requiresReasoningContentOnAssistantMessages: false, thinkingFormat: "openai", diff --git a/packages/coding-agent/docs/models-configuration.md b/packages/coding-agent/docs/models-configuration.md index 830f9bfdc..2181fbc75 100644 --- a/packages/coding-agent/docs/models-configuration.md +++ b/packages/coding-agent/docs/models-configuration.md @@ -118,6 +118,7 @@ See [Describe model input and caching](models.md#describe-model-input-and-cachin | `maxTokensField` | `"max_completion_tokens" \| "max_tokens"` | | Which field to use for max tokens. Default: auto-detected from URL. | | `requiresToolResultName` | boolean | | Whether tool results require the name field. Default: auto-detected from URL. | | `requiresAssistantAfterToolResult` | boolean | | Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL. | +| `requiresNonNullAssistantContent` | boolean | | Whether replayed assistant messages with no text (e.g. tool-call-only messages) must send "" instead of null content. Default: false. | | `requiresThinkingAsText` | boolean | | Whether thinking blocks must be converted to text blocks with <thinking> delimiters. Default: auto-detected from URL. | | `requiresReasoningContentOnAssistantMessages` | boolean | | Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. | | `thinkingFormat` | `"openai" \| "openrouter" \| "deepseek" \| "together" \| "baseten" \| "zai" \| "qwen" \| "chat-template" \| "qwen-chat-template" \| "string-thinking" \| "ant-ling"` | | Format for reasoning or thinking parameters. When omitted, Pi auto-detects the format from the provider URL. "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, "baseten" uses configurable chat_template_args plus reasoning_effort when supported, "zai" uses thinking.type, "qwen" uses top-level enable_thinking, "qwen-chat-template" uses chat_template_kwargs.enable_thinking and preserve_thinking, "chat-template" uses configurable chat_template_kwargs, "string-thinking" uses top-level thinking, and "ant-ling" uses reasoning.effort only when the mapped effort is non-null. | diff --git a/packages/coding-agent/schemas/models.schema.json b/packages/coding-agent/schemas/models.schema.json index 8861f8542..aea82ce5f 100644 --- a/packages/coding-agent/schemas/models.schema.json +++ b/packages/coding-agent/schemas/models.schema.json @@ -552,6 +552,10 @@ "type": "boolean", "description": "Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL." }, + "requiresNonNullAssistantContent": { + "type": "boolean", + "description": "Whether replayed assistant messages with no text (e.g. tool-call-only messages) must send \"\" instead of null content. Default: false." + }, "requiresThinkingAsText": { "type": "boolean", "description": "Whether thinking blocks must be converted to text blocks with delimiters. Default: auto-detected from URL."