feat(ai): add chat-template thinking compat

closes #5673
This commit is contained in:
Armin Ronacher
2026-06-19 23:34:17 +02:00
parent 128330e36f
commit 8b97e75c6b
12 changed files with 248 additions and 8 deletions

View File

@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added configurable `chat-template` thinking support for OpenAI-compatible providers that use `chat_template_kwargs`, such as DeepSeek models behind vLLM ([#5673](https://github.com/earendil-works/pi/issues/5673)).
### Fixed
- Fixed GitHub Copilot OAuth model availability to use the authenticated account's model picker catalog ([#5897](https://github.com/earendil-works/pi/issues/5897)).

View File

@@ -967,7 +967,8 @@ interface OpenAICompletionsCompat {
requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (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' | '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 enable_thinking, 'qwen' uses enable_thinking, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking, 'string-thinking' uses top-level thinking, 'ant-ling' uses reasoning: { effort } only for mapped efforts (default: openai)
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)
chatTemplateKwargs?: Record<string, string | number | boolean | null | { '$var': 'thinking.enabled' | 'thinking.effort'; omitWhenOff?: boolean }>; // chat_template_kwargs values; use $var for pi-controlled thinking values
cacheControlFormat?: 'anthropic'; // Anthropic-style cache_control on system prompt, last tool, and last user/assistant text content
openRouterRouting?: OpenRouterRouting; // OpenRouter routing preferences (default: {})
vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway routing preferences (default: {})

View File

@@ -15,6 +15,7 @@ import { calculateCost, clampThinkingLevel } from "../models.ts";
import type {
AssistantMessage,
CacheRetention,
ChatTemplateKwargValue,
Context,
ImageContent,
Message,
@@ -91,6 +92,8 @@ type ResolvedOpenAICompletionsCompat = Omit<Required<OpenAICompletionsCompat>, "
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
};
type ResolvedChatTemplateKwargValue = string | number | boolean | null;
type ChatCompletionInstructionMessageParam = ChatCompletionDeveloperMessageParam | ChatCompletionSystemMessageParam;
type ChatCompletionTextPartWithCacheControl = ChatCompletionContentPartText & {
@@ -585,6 +588,11 @@ function buildParams(
enable_thinking: !!options?.reasoningEffort,
preserve_thinking: true,
};
} else if (compat.thinkingFormat === "chat-template" && model.reasoning) {
const chatTemplateKwargs = buildChatTemplateKwargs(model, options, compat);
if (chatTemplateKwargs) {
(params as any).chat_template_kwargs = chatTemplateKwargs;
}
} else if (compat.thinkingFormat === "deepseek" && model.reasoning) {
if (options?.reasoningEffort) {
(params as any).thinking = { type: "enabled" };
@@ -655,6 +663,44 @@ function buildParams(
return params;
}
function buildChatTemplateKwargs(
model: Model<"openai-completions">,
options: OpenAICompletionsOptions | undefined,
compat: ResolvedOpenAICompletionsCompat,
): Record<string, ResolvedChatTemplateKwargValue> | undefined {
const kwargs: Record<string, ResolvedChatTemplateKwargValue> = {};
for (const [key, value] of Object.entries(compat.chatTemplateKwargs)) {
const resolved = resolveChatTemplateKwargValue(model, options, value);
if (resolved !== undefined) {
kwargs[key] = resolved;
}
}
return Object.keys(kwargs).length > 0 ? kwargs : undefined;
}
function resolveChatTemplateKwargValue(
model: Model<"openai-completions">,
options: OpenAICompletionsOptions | undefined,
value: ChatTemplateKwargValue,
): ResolvedChatTemplateKwargValue | undefined {
if (typeof value !== "object" || value === null) {
return value;
}
const reasoningEffort = options?.reasoningEffort;
if (!reasoningEffort && value.omitWhenOff) {
return undefined;
}
if (value.$var === "thinking.enabled") {
return !!reasoningEffort;
}
const mappedValue = reasoningEffort ? model.thinkingLevelMap?.[reasoningEffort] : model.thinkingLevelMap?.off;
return mappedValue === undefined ? reasoningEffort : typeof mappedValue === "string" ? mappedValue : undefined;
}
function getCompatCacheControl(
compat: ResolvedOpenAICompletionsCompat,
cacheRetention: CacheRetention,
@@ -1167,6 +1213,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
: "openai",
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
zaiToolStream: false,
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia,
cacheControlFormat,
@@ -1205,6 +1252,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion
thinkingFormat: model.compat.thinkingFormat ?? detected.thinkingFormat,
openRouterRouting: model.compat.openRouterRouting ?? {},
vercelGatewayRouting: model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting,
chatTemplateKwargs: model.compat.chatTemplateKwargs ?? detected.chatTemplateKwargs,
zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream,
supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode,
cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat,

View File

@@ -65,6 +65,15 @@ export type ImagesProvider = KnownImagesProvider | string;
export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh";
export type ModelThinkingLevel = "off" | ThinkingLevel;
export type ThinkingLevelMap = Partial<Record<ModelThinkingLevel, string | null>>;
export type ChatTemplateKwargValue =
| string
| number
| boolean
| null
| {
$var: "thinking.enabled" | "thinking.effort";
omitWhenOff?: boolean;
};
/** Token budgets for each thinking level (token-based providers only) */
export interface ThinkingBudgets {
@@ -403,7 +412,7 @@ export interface OpenAICompletionsCompat {
requiresThinkingAsText?: boolean;
/** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */
requiresReasoningContentOnAssistantMessages?: boolean;
/** Format for reasoning/thinking parameter. "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 top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking, "string-thinking" uses top-level thinking: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */
/** Format for reasoning/thinking parameter. "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 top-level enable_thinking: boolean, "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: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */
thinkingFormat?:
| "openai"
| "openrouter"
@@ -411,9 +420,12 @@ export interface OpenAICompletionsCompat {
| "together"
| "zai"
| "qwen"
| "chat-template"
| "qwen-chat-template"
| "string-thinking"
| "ant-ling";
/** Kwargs to send as `chat_template_kwargs` when `thinkingFormat` is `chat-template`. Use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values. */
chatTemplateKwargs?: Record<string, ChatTemplateKwargValue>;
/** OpenRouter-compatible routing preferences sent as the `provider` request field. */
openRouterRouting?: OpenRouterRouting;
/** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */

View File

@@ -34,6 +34,7 @@ const compat = {
thinkingFormat: "openai",
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
zaiToolStream: false,
supportsStrictMode: true,
cacheControlFormat: undefined,

View File

@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { stream, streamSimple } from "../src/index.ts";
import { getModel } from "../src/models.ts";
import { convertMessages } from "../src/providers/openai-completions.ts";
import type { AssistantMessage, Model, Tool, ToolResultMessage } from "../src/types.ts";
import type { AssistantMessage, Model, SimpleStreamOptions, Tool, ToolResultMessage } from "../src/types.ts";
const mockState = vi.hoisted(() => ({
lastParams: undefined as unknown,
@@ -64,6 +64,46 @@ vi.mock("openai", () => {
return { default: FakeOpenAI };
});
const localOpenAICompletionsModel = {
api: "openai-completions",
provider: "local-vllm",
baseUrl: "http://localhost:8000/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
} satisfies Omit<Model<"openai-completions">, "id" | "name" | "compat">;
type CapturedParams = {
chat_template_kwargs?: Record<string, unknown>;
thinking?: unknown;
reasoning_effort?: string;
};
async function captureSimpleParams(
model: Model<"openai-completions">,
reasoning?: SimpleStreamOptions["reasoning"],
): Promise<CapturedParams> {
let payload: unknown;
await streamSimple(
model,
{
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
},
{
apiKey: "test",
reasoning,
onPayload: (params: unknown) => {
payload = params;
},
},
).result();
return (payload ?? mockState.lastParams) as CapturedParams;
}
describe("openai-completions tool_choice", () => {
beforeEach(() => {
mockState.lastParams = undefined;
@@ -1142,6 +1182,7 @@ describe("openai-completions tool_choice", () => {
thinkingFormat: "openai",
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
zaiToolStream: false,
supportsStrictMode: true,
sendSessionAffinityHeaders: false,
@@ -1449,6 +1490,77 @@ describe("openai-completions tool_choice", () => {
expect(params.reasoning_effort).toBeUndefined();
});
it("uses configurable chat template boolean thinking kwargs", async () => {
const model = {
...localOpenAICompletionsModel,
id: "deepseek-ai/DeepSeek-V3.1",
name: "DeepSeek V3.1 via vLLM",
compat: {
thinkingFormat: "chat-template",
supportsReasoningEffort: false,
chatTemplateKwargs: { thinking: { $var: "thinking.enabled" } },
},
} satisfies Model<"openai-completions">;
for (const testCase of [
{ reasoning: "high" as const, expected: true },
{ reasoning: undefined, expected: false },
]) {
const params = await captureSimpleParams(model, testCase.reasoning);
expect(params.chat_template_kwargs).toEqual({ thinking: testCase.expected });
expect(params.thinking).toBeUndefined();
expect(params.reasoning_effort).toBeUndefined();
}
});
it("uses qwen chat template thinking kwargs", async () => {
const model = {
...localOpenAICompletionsModel,
id: "Qwen/Qwen3-Coder",
name: "Qwen3 Coder via vLLM",
compat: {
thinkingFormat: "qwen-chat-template",
supportsReasoningEffort: false,
},
} satisfies Model<"openai-completions">;
for (const testCase of [
{ reasoning: "high" as const, expected: true },
{ reasoning: undefined, expected: false },
]) {
const params = await captureSimpleParams(model, testCase.reasoning);
expect(params.chat_template_kwargs).toEqual({
enable_thinking: testCase.expected,
preserve_thinking: true,
});
expect(params.reasoning_effort).toBeUndefined();
}
});
it("uses configurable chat template effort kwargs with static kwargs", async () => {
const model = {
...localOpenAICompletionsModel,
id: "unsloth/gpt-oss-120b-GGUF",
name: "GPT OSS via vLLM",
thinkingLevelMap: { xhigh: "max" },
compat: {
thinkingFormat: "chat-template",
supportsReasoningEffort: false,
chatTemplateKwargs: {
preserve_thinking: true,
reasoning_effort: { $var: "thinking.effort", omitWhenOff: true },
},
},
} satisfies Model<"openai-completions">;
const params = await captureSimpleParams(model, "xhigh");
expect(params.chat_template_kwargs).toEqual({ preserve_thinking: true, reasoning_effort: "max" });
expect(params.reasoning_effort).toBeUndefined();
});
it("uses Ant Ling compatibility metadata", async () => {
const model = getModel("ant-ling", "Ring-2.6-1T")!;
let payload: unknown;

View File

@@ -32,6 +32,7 @@ const compat: Required<OpenAICompletionsCompat> = {
thinkingFormat: "openai",
openRouterRouting: {},
vercelGatewayRouting: {},
chatTemplateKwargs: {},
zaiToolStream: false,
supportsStrictMode: true,
cacheControlFormat: "anthropic",