import { getAgentProvider, type AgentProviderId, } from "./agent-providers"; import { nearestReasoningLevel, reasoningRequestFields, reasoningTokenBudget, type ReasoningLevel, } from "./agent-reasoning"; import { anthropicModelReasoning, geminiModelReasoning, isAdaptiveClaude, isAlwaysThinkingClaude, isClaude46, isManualThinkingClaude, openAiModelReasoning, } from "./model-reasoning-rules"; import { SITE_URL } from "./site"; /** * Shared chat contract for Numo, dictation, feedback, and integrations. * Callers use provider-independent fields, translated below. Add common options * here; reserve `extensions` for intentionally provider-specific capabilities, * such as the OpenRouter web plugin. */ export interface AiChatRequest { model: string; messages: unknown[]; stream?: boolean; maxOutputTokens?: number; reasoning?: { /** Explicitly disable optional reasoning on OpenRouter; omission keeps model defaults. */ enabled?: false; effort?: ReasoningLevel; /** Fixed budget when the provider knows how to express it. */ maxTokens?: number; }; tools?: unknown[]; toolChoice?: unknown; temperature?: number; stop?: string | string[]; responseFormat?: unknown; parallelToolCalls?: boolean; /** Options deliberately specific to the chosen endpoint. Never for a common field. */ extensions?: Record; } function positiveInt(value: unknown): number | undefined { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined; return Math.trunc(value); } /** * Claude 4.7 and later return a 400 for any `temperature` below 1 * ("`temperature` is deprecated for this model"), so the sampling field is * dropped on those families. */ function rejectsSamplingTemperature(model: string): boolean { return isAdaptiveClaude(model) && !isClaude46(model); } /** * Thinking takes its share of the output ceiling. A ceiling sized for the * visible answer would let a high effort consume it and end the round on * `max_tokens`; only generated tokens are billed, so the floor is free on * short answers. */ const ADAPTIVE_CLAUDE_MIN_OUTPUT_TOKENS = 16_000; /** A level folded to what the family accepts (`minimal` → `low`, `xhigh` → `high` on 4.6). */ function anthropicEffort(model: string, level: ReasoningLevel): ReasoningLevel { const allowed = anthropicModelReasoning(model)?.efforts ?? []; return nearestReasoningLevel(level === "off" ? "low" : level, allowed); } /** * Families whose Chat Completions function calling only works with * `reasoning_effort: "none"`: GPT-5.6, and GPT-6 Sol and GPT-6 Luna (OpenAI * guide "Using GPT-6"). GPT-6 Astra, GPT-6.1 Sol and GPT-6.1 Terra are left * out on purpose: they reject `none` and need Responses for tool calling, so * no effort value makes them usable on this transport. */ function needsReasoningOffWithFunctionTools(model: string): boolean { return ( /^gpt-5\.6(?:-|$)/i.test(model) || /^gpt-6-(?:sol|luna)(?:-|$)/i.test(model) ); } /** * Families that reject forced tool choice with a 400 on every request: * Claude Opus 5.5, Sonnet 5.5, Fable 5.1, and Mythos 5.1. Downgrade to `auto` * at the compatibility boundary to keep these requests usable. */ function rejectsForcedToolChoice(model: string): boolean { return ( /^claude-(?:opus|sonnet)-5-5(?:-|$)/i.test(model) || /^claude-(?:fable|mythos)-5-1(?:-|$)/i.test(model) ); } function isForcedOpenAiToolChoice(value: unknown): boolean { if (value === "required") return true; if (typeof value !== "object" || value === null) return false; const choice = value as { type?: unknown; allowed_tools?: { mode?: unknown } }; return choice.type === "function" || (choice.type === "allowed_tools" && choice.allowed_tools?.mode === "required"); } function normalizeProviderToolChoice( body: Record, provider: AgentProviderId, ): void { if ( provider === "anthropic" && typeof body.model === "string" && rejectsForcedToolChoice(body.model) && isForcedOpenAiToolChoice(body.tool_choice) ) { body.tool_choice = "auto"; } } /** * Final Anthropic adjustments once the body is assembled: drops the sampling * temperature recent families reject, drops a manual budget when the tool * choice is forced ("Thinking may not be enabled when tool_choice forces tool * use", 400), and lifts the ceiling when adaptive thinking runs. */ function normalizeAnthropicBody( body: Record, provider: AgentProviderId, ): void { if (provider !== "anthropic" || typeof body.model !== "string") return; if (rejectsSamplingTemperature(body.model)) delete body.temperature; const thinking = (body.thinking as { type?: unknown } | undefined)?.type; if (thinking === "enabled" && isForcedOpenAiToolChoice(body.tool_choice)) { delete body.thinking; } if (thinking === "adaptive") { const ceiling = body.max_tokens; if (typeof ceiling !== "number" || ceiling < ADAPTIVE_CLAUDE_MIN_OUTPUT_TOKENS) { body.max_tokens = ADAPTIVE_CLAUDE_MIN_OUTPUT_TOKENS; } } } /** * Native Messages API reasoning (`lib/server/anthropic-messages.ts` carries * these fields as they are). Adaptive families get `thinking: adaptive` with a * readable summary for the reasoning feed, and the level as * `output_config.effort`; 4.5 families get a manual budget. */ function anthropicReasoningFields(params: { model: string; effort?: ReasoningLevel; fixedTokens?: number; maxOutputTokens?: number; }): Record { const { model } = params; if (params.effort === "off") { // Fable 5 / Mythos 5 / Mythos Preview and the Claude 5.5 families refuse // `thinking: {type: "disabled"}` (400): the cheapest effort is the // closest request they accept. if (isAlwaysThinkingClaude(model)) { return { output_config: { effort: anthropicEffort(model, "off") } }; } return isAdaptiveClaude(model) || isManualThinkingClaude(model) ? { thinking: { type: "disabled" } } : {}; } // A fixed budget keeps manual mode where the family still accepts one. const manual = isManualThinkingClaude(model) && (params.fixedTokens !== undefined || !isAdaptiveClaude(model)); if (!manual) { if (!isAdaptiveClaude(model) || !params.effort) return {}; return { thinking: { type: "adaptive", display: "summarized" }, output_config: { effort: anthropicEffort(model, params.effort) }, }; } const requested = params.fixedTokens ?? (params.effort ? reasoningTokenBudget(params.effort) : undefined); if (requested === undefined) return {}; const maxOutput = params.maxOutputTokens; if (maxOutput !== undefined && maxOutput <= 1024) return {}; const budget = Math.max( 1024, maxOutput !== undefined ? Math.min(requested, maxOutput - 1) : requested, ); return { thinking: { type: "enabled", budget_tokens: budget } }; } /** `reasoning_effort` for OpenAI and Gemini, folded to what the model takes. */ function compatEffortFields( provider: "openai" | "google", model: string, level: ReasoningLevel, ): Record { const reasoning = provider === "openai" ? openAiModelReasoning(model) : geminiModelReasoning(model); if (!reasoning) return {}; if (level === "off") { // A mandatory model has no "off": the server already lowers it, and the // model's own default is the safe fallback for any other caller. return reasoning.mandatory ? {} : { reasoning_effort: "none" }; } return { reasoning_effort: nearestReasoningLevel(level, reasoning.efforts) }; } function providerReasoningFields( provider: AgentProviderId, model: string, reasoning: AiChatRequest["reasoning"] | undefined, maxOutputTokens: number | undefined, hasFunctionTools: boolean, ): Record { // GPT-5.6 and GPT-6 Sol/Luna call functions via Chat Completions only // without an active reasoning effort. Responses is the full path; as long as // this transport remains Chat Completions, `none` is the documented contract. if (provider === "openai" && hasFunctionTools && needsReasoningOffWithFunctionTools(model)) { return { reasoning_effort: "none" }; } if (provider === "openrouter" && reasoning?.enabled === false) { return { reasoning: { enabled: false } }; } if ( reasoning === undefined || (reasoning.effort === undefined && reasoning.maxTokens === undefined) ) { return {}; } const fixedTokens = positiveInt(reasoning?.maxTokens); if ((provider === "openai" || provider === "google") && reasoning.effort) { return compatEffortFields(provider, model, reasoning.effort); } // "Off" really disables optional reasoning: omitting the field ran the // model at its default effort (MIN-826). A mandatory model answers 400, // which `repairRejectedAiChatBody` replays without the field. if (provider === "openrouter" && reasoning.effort === "off") { return { reasoning: { enabled: false } }; } if (provider === "anthropic") { return anthropicReasoningFields({ model, effort: reasoning.effort, fixedTokens, maxOutputTokens, }); } if (fixedTokens !== undefined && provider === "openrouter") { return { reasoning: { max_tokens: fixedTokens, exclude: false } }; } return reasoning.effort ? reasoningRequestFields(reasoning.effort, provider) : {}; } /** * Gemini 3 returns a `thought_signature` in each tool call's `extra_content`, * and requires it back on the next round. The loop keeps it in history; any * other provider receives the tool calls without that Gemini-only field. */ function withoutGeminiToolExtras(messages: unknown[]): unknown[] { return messages.map((message) => { const calls = (message as { tool_calls?: unknown } | null)?.tool_calls; if (!Array.isArray(calls) || !calls.some((call) => call && typeof call === "object" && "extra_content" in call)) { return message; } return { ...(message as Record), tool_calls: calls.map((call) => { if (!call || typeof call !== "object" || !("extra_content" in call)) return call; const { extra_content: _extra, ...rest } = call as Record; return rest; }), }; }); } /** Translates the minddy contract to the exact JSON of the provider. Pure function. */ export function translateAiChatRequest( request: AiChatRequest, provider: AgentProviderId, ): Record { const profile = getAgentProvider(provider)?.requestProfile; if (!profile) throw new Error(`Unknown AI provider: ${provider}`); const body: Record = { model: request.model, messages: provider === "google" ? request.messages : withoutGeminiToolExtras(request.messages), ...(request.stream !== undefined ? { stream: request.stream } : {}), ...(request.tools ? { tools: request.tools } : {}), ...(request.toolChoice !== undefined ? { tool_choice: request.toolChoice } : {}), ...(request.temperature !== undefined ? { temperature: request.temperature } : {}), ...(request.stop !== undefined ? { stop: request.stop } : {}), ...(request.responseFormat !== undefined ? { response_format: request.responseFormat } : {}), ...(request.parallelToolCalls !== undefined ? { parallel_tool_calls: request.parallelToolCalls } : {}), ...request.extensions, }; const maxOutputTokens = positiveInt(request.maxOutputTokens) ?? profile.defaultMaxOutputTokens; if (maxOutputTokens !== undefined) { body[profile.outputTokenField] = maxOutputTokens; } Object.assign( body, providerReasoningFields( provider, request.model, request.reasoning, maxOutputTokens, Boolean(request.tools?.length), ), ); normalizeProviderToolChoice(body, provider); normalizeAnthropicBody(body, provider); if (profile.usageAccounting) body.usage = { include: true }; if (request.stream && profile.streamUsage) { body.stream_options = { include_usage: true }; } return body; } /** Provider-specific headers; authentication remains in transport. */ export function aiChatProviderHeaders( provider: AgentProviderId, title: string, ): Record { const profile = getAgentProvider(provider)?.requestProfile; if (!profile) throw new Error(`Unknown AI provider: ${provider}`); return profile.attribution ? { "HTTP-Referer": SITE_URL, "X-Title": title } : {}; } /** * Produces the same body with the other ceiling alias, only when the 400 * explicitly designates the one that was sent as unsupported. */ export function alternateOutputTokenBody( bodyText: string, errorText: string, ): string | null { let body: Record; try { body = JSON.parse(bodyText) as Record; } catch { return null; } const current = Object.hasOwn(body, "max_completion_tokens") ? "max_completion_tokens" : Object.hasOwn(body, "max_tokens") ? "max_tokens" : null; if ( current === null || !new RegExp( `(?:unsupported|not supported)[^\\n]{0,160}${current}|${current}[^\\n]{0,160}(?:unsupported|not supported)`, "i", ).test(errorText) ) { return null; } const alternate = current === "max_tokens" ? "max_completion_tokens" : "max_tokens"; body[alternate] = body[current]; delete body[current]; return JSON.stringify(body); } /** * Repairs the only compatibility 400s that we know how to replay without risk: * a denied cap alias, a reasoning disable refused by a model that must reason, * or GPT-5.6 / GPT-6 Sol-Luna Chat Completions that denies the couple function tools + * reasoning. Any other error keeps the initial body. */ export function repairRejectedAiChatBody( bodyText: string, errorText: string, ): string | null { const outputAliasRepair = alternateOutputTokenBody(bodyText, errorText); if (outputAliasRepair !== null) return outputAliasRepair; // "Off" sent to a model whose catalog entry did not say it must reason: // replay at the model's own default rather than failing the turn. if (/reasoning is mandatory|(?:reasoning|thinking)[^\n]{0,80}cannot be (?:turned off|disabled)/i.test(errorText)) { try { const body = JSON.parse(bodyText) as Record; const reasoning = body.reasoning as { enabled?: unknown } | undefined; if (reasoning?.enabled === false || body.reasoning_effort === "none") { delete body.reasoning; delete body.reasoning_effort; return JSON.stringify(body); } } catch { return null; } } if ( !/function tools[^\n]{0,160}reasoning_effort[^\n]{0,160}(?:not supported|unsupported)/i.test( errorText, ) ) { return null; } let body: Record; try { body = JSON.parse(bodyText) as Record; } catch { return null; } if (!Array.isArray(body.tools) || body.tools.length === 0) return null; body.reasoning_effort = "none"; return JSON.stringify(body); } /** * Compatibility boundary for a third-party client (opencode) that already manufactures * of OpenAI JSON. The provider aliases are absorbed then reissued by the same * translator that all surfaces. */ export function translateLegacyAiChatBody( input: Record, provider: AgentProviderId, reasoningEffort?: ReasoningLevel, ): Record { const profile = getAgentProvider(provider)?.requestProfile; if (!profile) throw new Error(`Unknown AI provider: ${provider}`); const body = { ...input }; const maxOutputTokens = positiveInt(body.max_completion_tokens) ?? positiveInt(body.max_tokens) ?? profile.defaultMaxOutputTokens; delete body.max_tokens; delete body.max_completion_tokens; if (maxOutputTokens !== undefined) body[profile.outputTokenField] = maxOutputTokens; delete body.reasoning; delete body.reasoning_effort; delete body.thinking; if (reasoningEffort) { Object.assign( body, providerReasoningFields( provider, typeof body.model === "string" ? body.model : "", { effort: reasoningEffort }, maxOutputTokens, Array.isArray(body.tools) && body.tools.length > 0, ), ); } normalizeProviderToolChoice(body, provider); normalizeAnthropicBody(body, provider); if (profile.usageAccounting && body.usage === undefined) body.usage = { include: true }; // This border is only used by the opencode client, which streams all its rounds. // However, some versions omit `stream: true` from the intermediate body. if (profile.streamUsage && body.stream_options === undefined) { body.stream_options = { include_usage: true }; } if (!profile.usageAccounting) delete body.usage; if (!profile.streamUsage) delete body.stream_options; return body; }