Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1183,8 +1183,10 @@ interface OpenAICompletionsCompat {
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)
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
chatTemplateArgs?: Record<string, string | number | boolean | null | { '$var': 'thinking.enabled' | 'thinking.effort'; omitWhenOff?: boolean }>; // chat_template_args values for thinkingFormat: 'baseten'; use $var for pi-controlled thinking values
chatTemplateKwargs?: Record<string, string | number | boolean | null | { '$var': 'thinking.enabled' | 'thinking.effort' | 'thinking.budget'; omitWhenOff?: boolean }>; // chat_template_kwargs values; use $var for pi-controlled thinking values
chatTemplateArgs?: Record<string, string | number | boolean | null | { '$var': 'thinking.enabled' | 'thinking.effort' | 'thinking.budget'; omitWhenOff?: boolean }>; // chat_template_args values for thinkingFormat: 'baseten'; use $var for pi-controlled thinking values
thinkingTokenBudgetField?: 'thinking_token_budget' | 'thinking_budget' | 'thinking_budget_tokens'; // Top-level field that caps reasoning tokens from thinkingBudgets (vLLM / Qwen / llama.cpp). Off by default.
supportsThinkingTokenBudget?: boolean; // Alias for thinkingTokenBudgetField: 'thinking_token_budget' (vLLM). Prefer thinkingTokenBudgetField. Default: false.
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: {})
Expand Down
7 changes: 6 additions & 1 deletion packages/ai/scripts/generate-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,12 @@ const OPENAI_COMPLETIONS_DEFAULT_COMPAT = {
supportsOpenAIGrammarTools: false,
sendSessionAffinityHeaders: false,
supportsLongCacheRetention: true,
} satisfies Required<Omit<OpenAICompletionsCompat, "cacheControlFormat" | "deferredToolsMode">> & {
} satisfies Required<
Omit<
OpenAICompletionsCompat,
"cacheControlFormat" | "deferredToolsMode" | "supportsThinkingTokenBudget" | "thinkingTokenBudgetField"
>
> & {
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"];
};
Expand Down
71 changes: 46 additions & 25 deletions packages/ai/src/api/openai-completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
TextContent,
ThinkingBudgets,
ThinkingContent,
ThinkingTokenBudgetField,
Tool,
ToolCall,
ToolResultMessage,
Expand All @@ -54,7 +55,7 @@ import {
} from "./constrained-sampling.ts";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
import { buildBaseOptions, clampReasoning, MIN_ANSWER_TOKENS } from "./simple-options.ts";
import { buildBaseOptions, clampThinkingBudgetToAnswerRoom, thinkingBudgetForLevel } from "./simple-options.ts";
import { transformMessages } from "./transform-messages.ts";

/**
Expand Down Expand Up @@ -144,7 +145,7 @@ function isEncryptedReasoningDetail(detail: unknown): detail is OpenAIEncryptedR
export interface OpenAICompletionsOptions extends StreamOptions {
toolChoice?: OpenAI.Chat.Completions.ChatCompletionToolChoiceOption;
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
/** Token budgets per thinking level. Only used when `compat.supportsThinkingTokenBudget` is set. */
/** Token budgets per thinking level. Used when `compat.thinkingTokenBudgetField` or `compat.supportsThinkingTokenBudget` is set, or by `{ "$var": "thinking.budget" }`. */
thinkingBudgets?: ThinkingBudgets;
}

Expand All @@ -159,11 +160,12 @@ interface OpenAICompatCacheControl {

type ResolvedOpenAICompletionsCompat = Omit<
Required<OpenAICompletionsCompat>,
"cacheControlFormat" | "deferredToolsMode" | "supportsThinkingTokenBudget"
"cacheControlFormat" | "deferredToolsMode" | "supportsThinkingTokenBudget" | "thinkingTokenBudgetField"
> & {
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"];
supportsThinkingTokenBudget?: OpenAICompletionsCompat["supportsThinkingTokenBudget"];
thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"];
};

type ResolvedChatTemplateKwargValue = string | number | boolean | null;
Expand Down Expand Up @@ -752,6 +754,9 @@ function buildParams(
params.tool_choice = options.toolChoice;
}

const thinkingTokenBudgetField = resolveThinkingTokenBudgetField(compat);
const thinkingBudget = resolveClampedThinkingBudget(model, options, params);

if (compat.thinkingFormat === "zai" && model.reasoning) {
const zaiParams = params as Omit<typeof params, "reasoning_effort"> & {
thinking?: { type: "enabled" | "disabled"; clear_thinking?: boolean };
Expand Down Expand Up @@ -779,7 +784,7 @@ function buildParams(
preserve_thinking: true,
};
} else if (compat.thinkingFormat === "chat-template" && model.reasoning) {
const chatTemplateKwargs = buildChatTemplateValues(model, options, compat.chatTemplateKwargs);
const chatTemplateKwargs = buildChatTemplateValues(model, options, compat.chatTemplateKwargs, thinkingBudget);
if (chatTemplateKwargs) {
(params as any).chat_template_kwargs = chatTemplateKwargs;
}
Expand All @@ -788,7 +793,7 @@ function buildParams(
chat_template_args?: Record<string, ResolvedChatTemplateKwargValue>;
reasoning_effort?: string;
};
const chatTemplateArgs = buildChatTemplateValues(model, options, compat.chatTemplateArgs);
const chatTemplateArgs = buildChatTemplateValues(model, options, compat.chatTemplateArgs, thinkingBudget);
if (chatTemplateArgs) {
basetenParams.chat_template_args = chatTemplateArgs;
}
Expand Down Expand Up @@ -851,25 +856,12 @@ function buildParams(
}
}

// vLLM caps reasoning with a top-level thinking_token_budget. Independent of
// thinkingFormat: the same server can serve zai, qwen or chat-template models.
// Reasoning and the answer share max_tokens here, so an uncapped reasoning
// phase can consume the whole response and leave no answer and no tool call.
if (compat.supportsThinkingTokenBudget && options?.reasoningEffort && model.reasoning) {
const level = clampReasoning(options.reasoningEffort)!;
const budgets: ThinkingBudgets = {
minimal: 1024,
low: 2048,
medium: 8192,
high: 16384,
...options.thinkingBudgets,
};
const ceiling = (params as { max_tokens?: number }).max_tokens ?? params.max_completion_tokens ?? model.maxTokens;
// Always leave room for the answer, otherwise the budget recreates the bug it prevents.
const budget = Math.min(budgets[level]!, Math.max(0, ceiling - MIN_ANSWER_TOKENS));
if (budget > 0) {
(params as { thinking_token_budget?: number }).thinking_token_budget = budget;
}
// Cap reasoning with a top-level budget field. Independent of thinkingFormat: the
// same server can serve zai, qwen or chat-template models. Reasoning and the answer
// share max_tokens here, so an uncapped reasoning phase can consume the whole
// response and leave no answer and no tool call.
if (thinkingTokenBudgetField && thinkingBudget !== undefined) {
Object.assign(params, { [thinkingTokenBudgetField]: thinkingBudget });
}

// OpenRouter provider routing preferences
Expand All @@ -896,15 +888,38 @@ function buildParams(
return params;
}

function resolveThinkingTokenBudgetField(
compat: Pick<OpenAICompletionsCompat, "thinkingTokenBudgetField" | "supportsThinkingTokenBudget">,
): ThinkingTokenBudgetField | undefined {
if (compat.thinkingTokenBudgetField) return compat.thinkingTokenBudgetField;
if (compat.supportsThinkingTokenBudget) return "thinking_token_budget";
return undefined;
}

function resolveClampedThinkingBudget(
model: Model<"openai-completions">,
options: OpenAICompletionsOptions | undefined,
params: { max_tokens?: number | null; max_completion_tokens?: number | null },
): number | undefined {
if (!options?.reasoningEffort || !model.reasoning) return undefined;
const ceiling = params.max_tokens ?? params.max_completion_tokens ?? model.maxTokens;
const budget = clampThinkingBudgetToAnswerRoom(
thinkingBudgetForLevel(options.reasoningEffort, options.thinkingBudgets),
ceiling,
);
return budget > 0 ? budget : undefined;
}

function buildChatTemplateValues(
model: Model<"openai-completions">,
options: OpenAICompletionsOptions | undefined,
values: Record<string, ChatTemplateKwargValue>,
thinkingBudget?: number,
): Record<string, ResolvedChatTemplateKwargValue> | undefined {
const resolvedValues: Record<string, ResolvedChatTemplateKwargValue> = {};

for (const [key, value] of Object.entries(values)) {
const resolved = resolveChatTemplateKwargValue(model, options, value);
const resolved = resolveChatTemplateKwargValue(model, options, value, thinkingBudget);
if (resolved !== undefined) {
resolvedValues[key] = resolved;
}
Expand All @@ -917,6 +932,7 @@ function resolveChatTemplateKwargValue(
model: Model<"openai-completions">,
options: OpenAICompletionsOptions | undefined,
value: ChatTemplateKwargValue,
thinkingBudget?: number,
): ResolvedChatTemplateKwargValue | undefined {
if (typeof value !== "object" || value === null) {
return value;
Expand All @@ -929,6 +945,9 @@ function resolveChatTemplateKwargValue(
if (value.$var === "thinking.enabled") {
return !!reasoningEffort;
}
if (value.$var === "thinking.budget") {
return thinkingBudget;
}

const mappedValue = reasoningEffort ? model.thinkingLevelMap?.[reasoningEffort] : model.thinkingLevelMap?.off;
return mappedValue === undefined ? reasoningEffort : typeof mappedValue === "string" ? mappedValue : undefined;
Expand Down Expand Up @@ -1532,6 +1551,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
chatTemplateArgs: {},
zaiToolStream: false,
supportsThinkingTokenBudget: false,
thinkingTokenBudgetField: undefined,
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia,
supportsOpenAIGrammarTools: false,
cacheControlFormat,
Expand Down Expand Up @@ -1577,6 +1597,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion
chatTemplateArgs: model.compat.chatTemplateArgs ?? detected.chatTemplateArgs,
zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream,
supportsThinkingTokenBudget: model.compat.supportsThinkingTokenBudget ?? detected.supportsThinkingTokenBudget,
thinkingTokenBudgetField: model.compat.thinkingTokenBudgetField ?? detected.thinkingTokenBudgetField,
supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode,
supportsOpenAIGrammarTools: model.compat.supportsOpenAIGrammarTools ?? detected.supportsOpenAIGrammarTools,
cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat,
Expand Down
31 changes: 20 additions & 11 deletions packages/ai/src/api/simple-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,32 +54,41 @@ export function buildBaseOptions(
/** Tokens always left for the answer when a thinking budget shares the response ceiling. */
export const MIN_ANSWER_TOKENS = 1024;

export const DEFAULT_THINKING_BUDGETS: ThinkingBudgets = {
minimal: 1024,
low: 2048,
medium: 8192,
high: 16384,
};

export function clampReasoning(effort: ThinkingLevel | undefined): Exclude<ThinkingLevel, "xhigh" | "max"> | undefined {
return effort === "xhigh" || effort === "max" ? "high" : effort;
}

export function thinkingBudgetForLevel(reasoningLevel: ThinkingLevel, customBudgets?: ThinkingBudgets): number {
const budgets = { ...DEFAULT_THINKING_BUDGETS, ...customBudgets };
const level = clampReasoning(reasoningLevel)!;
return budgets[level]!;
}

/** Cap a thinking budget so at least MIN_ANSWER_TOKENS remain under a shared response ceiling. */
export function clampThinkingBudgetToAnswerRoom(thinkingBudget: number, ceiling: number): number {
return Math.min(thinkingBudget, Math.max(0, ceiling - MIN_ANSWER_TOKENS));
}

export function adjustMaxTokensForThinking(
// Undefined means no explicit caller cap. Use the model cap and fit thinking inside it.
baseMaxTokens: number | undefined,
modelMaxTokens: number,
reasoningLevel: ThinkingLevel,
customBudgets?: ThinkingBudgets,
): { maxTokens: number; thinkingBudget: number } {
const defaultBudgets: ThinkingBudgets = {
minimal: 1024,
low: 2048,
medium: 8192,
high: 16384,
};
const budgets = { ...defaultBudgets, ...customBudgets };

const level = clampReasoning(reasoningLevel)!;
let thinkingBudget = budgets[level]!;
let thinkingBudget = thinkingBudgetForLevel(reasoningLevel, customBudgets);
const maxTokens =
baseMaxTokens === undefined ? modelMaxTokens : Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens);

if (maxTokens <= thinkingBudget) {
thinkingBudget = Math.max(0, maxTokens - MIN_ANSWER_TOKENS);
thinkingBudget = clampThinkingBudgetToAnswerRoom(thinkingBudget, maxTokens);
}

return { maxTokens, thinkingBudget };
Expand Down
19 changes: 15 additions & 4 deletions packages/ai/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,13 @@ export type ChatTemplateKwargValue =
| boolean
| null
| {
$var: "thinking.enabled" | "thinking.effort";
$var: "thinking.enabled" | "thinking.effort" | "thinking.budget";
omitWhenOff?: boolean;
};

/** Top-level request field used to cap reasoning tokens on OpenAI-compatible servers. */
export type ThinkingTokenBudgetField = "thinking_token_budget" | "thinking_budget" | "thinking_budget_tokens";

/** Token budgets for each thinking level (token-based providers only) */
export interface ThinkingBudgets {
minimal?: number;
Expand Down Expand Up @@ -583,17 +586,25 @@ export interface OpenAICompletionsCompat {
| "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. */
/** Kwargs to send as `chat_template_kwargs` when `thinkingFormat` is `chat-template`. Use `{ "$var": "thinking.enabled" }`, `{ "$var": "thinking.effort" }`, or `{ "$var": "thinking.budget" }` for pi-controlled thinking values. */
chatTemplateKwargs?: Record<string, ChatTemplateKwargValue>;
/** Arguments to send as `chat_template_args` when `thinkingFormat` is `baseten`. Use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values. */
/** Arguments to send as `chat_template_args` when `thinkingFormat` is `baseten`. Use `{ "$var": "thinking.enabled" }`, `{ "$var": "thinking.effort" }`, or `{ "$var": "thinking.budget" }` for pi-controlled thinking values. */
chatTemplateArgs?: 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. */
vercelGatewayRouting?: VercelGatewayRouting;
/** Whether z.ai supports top-level `tool_stream: true` for streaming tool call deltas. Default: false. */
zaiToolStream?: boolean;
/** Whether the provider supports top-level `thinking_token_budget` to cap reasoning tokens (vLLM). Reasoning and the answer share `max_tokens` on these endpoints, so without a budget a reasoning-heavy turn can consume the whole response and emit no answer. Default: false. */
/**
* Top-level request field used to cap reasoning tokens from `thinkingBudgets`.
* Reasoning and the answer share `max_tokens` on these endpoints, so without a budget a
* reasoning-heavy turn can consume the whole response and emit no answer.
* `"thinking_token_budget"` is vLLM, `"thinking_budget"` is Qwen/DashScope/SGLang,
* `"thinking_budget_tokens"` is llama.cpp. Off by default; not set on the generated catalog.
*/
thinkingTokenBudgetField?: ThinkingTokenBudgetField;
/** Alias for `thinkingTokenBudgetField: "thinking_token_budget"` (vLLM). Prefer `thinkingTokenBudgetField`. Default: false. */
supportsThinkingTokenBudget?: boolean;
/** Whether the provider supports OpenAI custom tools with Lark/regex grammar formats. When false, grammar-constrained tools fall back to normal function tools. Default: false; the generated model catalog enables it for capable models. */
supportsOpenAIGrammarTools?: boolean;
Expand Down
7 changes: 6 additions & 1 deletion packages/ai/test/openai-completions-thinking-as-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,20 @@ const compat = {
chatTemplateArgs: {},
zaiToolStream: false,
supportsThinkingTokenBudget: false,
thinkingTokenBudgetField: undefined,
supportsStrictMode: true,
supportsOpenAIGrammarTools: false,
cacheControlFormat: undefined,
sendSessionAffinityHeaders: false,
sessionAffinityFormat: "openai",
supportsLongCacheRetention: true,
} satisfies Omit<Required<OpenAICompletionsCompat>, "cacheControlFormat" | "deferredToolsMode"> & {
} satisfies Omit<
Required<OpenAICompletionsCompat>,
"cacheControlFormat" | "deferredToolsMode" | "thinkingTokenBudgetField"
> & {
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"];
thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"];
};

function buildModel(baseUrl = "http://127.0.0.1:1"): Model<"openai-completions"> {
Expand Down
Loading
Loading