Merge branch 'pr-8118' into HEAD

# Conflicts:
#	packages/ai/scripts/generate-models.ts
#	packages/ai/src/types.ts
#	packages/ai/test/deferred-tools.test.ts
#	packages/coding-agent/docs/custom-provider.md
#	packages/coding-agent/docs/models.md
#	packages/coding-agent/src/core/model-config.ts
This commit is contained in:
Mario Zechner 2026-11-04 10:28:47 +00:00
commit 8a5cdc88fd
9 changed files with 134 additions and 1 deletions

View file

@ -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)

View file

@ -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 ??

View file

@ -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:

View file

@ -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<Required<OpenAICompletionsCompat>, "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.");
});
});

View file

@ -31,6 +31,7 @@ const compat = {
maxTokensField: "max_completion_tokens",
requiresToolResultName: false,
requiresAssistantAfterToolResult: false,
requiresNonNullAssistantContent: false,
requiresThinkingAsText: true,
requiresReasoningContentOnAssistantMessages: false,
thinkingFormat: "openai",

View file

@ -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",

View file

@ -23,6 +23,7 @@ const compat: Omit<Required<OpenAICompletionsCompat>, "thinkingTokenBudgetField"
maxTokensField: "max_completion_tokens",
requiresToolResultName: false,
requiresAssistantAfterToolResult: false,
requiresNonNullAssistantContent: false,
requiresThinkingAsText: false,
requiresReasoningContentOnAssistantMessages: false,
thinkingFormat: "openai",

View file

@ -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 &lt;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. |

View file

@ -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 <thinking> delimiters. Default: auto-detected from URL."