mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
commit
8a5cdc88fd
9 changed files with 134 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ??
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
|
|
@ -31,6 +31,7 @@ const compat = {
|
|||
maxTokensField: "max_completion_tokens",
|
||||
requiresToolResultName: false,
|
||||
requiresAssistantAfterToolResult: false,
|
||||
requiresNonNullAssistantContent: false,
|
||||
requiresThinkingAsText: true,
|
||||
requiresReasoningContentOnAssistantMessages: false,
|
||||
thinkingFormat: "openai",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue