blob: 774c62a6bbd7dcea92131817740e930593fea92d [file]
import { createAnthropic } from '@ai-sdk/anthropic';
import { createCohere } from '@ai-sdk/cohere';
import { createGoogle } from '@ai-sdk/google';
import { createOpenAI } from '@ai-sdk/openai';
import { createOpenAICompatible, type MetadataExtractor } from '@ai-sdk/openai-compatible';
import {
isJSONArray,
type JSONArray,
type LanguageModelV4,
type LanguageModelV4StreamPart,
type SharedV4ProviderMetadata,
type SharedV4ProviderOptions,
} from '@ai-sdk/provider';
import { type RuntimeExecutionConnection } from '@maka/core/llm-connections';
import type { ProviderRuntimeAdapter } from '@maka/core/llm-connections';
import type { ThinkingLevel } from '@maka/core/model-thinking';
import {
thinkingOptionsForModel,
thinkingVariantsForModel,
type ThinkingOptions,
} from '@maka/core/model-thinking';
import {
createOpenAiChatReasoningTransport,
createOpenAiChatReasoningTransportState,
type OpenAiChatReasoningTransportState,
} from './openai-chat-reasoning-transport.js';
import type { OpenAiResponsesTransportState } from './openai-responses-websocket.js';
import { anthropicV1BaseUrl, googleV1BetaBaseUrl } from './provider-urls.js';
import { resolveModelRuntime, type ResolvedModelRuntime } from './model-runtime.js';
import { claudeSubscriptionHeaders, openAiCodexHeaders } from './subscription-auth.js';
export interface ModelFactoryInput {
connection: RuntimeExecutionConnection;
apiKey: string;
modelId: string;
fetch?: typeof globalThis.fetch;
resolvedRuntime?: ResolvedModelRuntime;
openAiChatReasoningTransportState?: OpenAiChatReasoningTransportState;
openAiResponsesTransportState?: OpenAiResponsesTransportState;
}
const ANTHROPIC_BETA = 'interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14';
export function getAIModel(input: ModelFactoryInput): LanguageModelV4 {
const {
connection,
apiKey,
modelId,
fetch,
resolvedRuntime,
openAiChatReasoningTransportState,
openAiResponsesTransportState,
} = input;
const runtime = resolvedRuntime ?? resolveModelRuntime(connection, modelId);
const { adapter, baseUrl: baseURL, wire, reasoningReplay } = runtime;
if (adapter.kind === 'google' && adapter.normalizeBaseUrl === false) {
return createGoogle({ apiKey, baseURL, fetch }).chat(modelId);
}
switch (adapter.kind) {
case 'anthropic':
return createAnthropic({
...(adapter.auth === 'bearer' ? { authToken: apiKey } : { apiKey }),
baseURL: adapter.normalizeBaseUrl ? anthropicV1BaseUrl(baseURL) : baseURL,
fetch,
headers: { 'anthropic-beta': ANTHROPIC_BETA },
}).chat(modelId);
case 'claude-subscription':
return createAnthropic({
authToken: apiKey,
baseURL: anthropicV1BaseUrl(baseURL),
fetch,
headers: claudeSubscriptionHeaders(),
}).chat(modelId);
case 'openai-codex':
return createOpenAI({
apiKey,
baseURL,
fetch: openAiResponsesTransportState?.wrapFetch(fetch ?? globalThis.fetch) ?? fetch,
headers: openAiCodexHeaders(apiKey),
}).responses(modelId);
case 'github-copilot': {
if (wire === 'openai-responses') {
return createOpenAI({ apiKey, baseURL, fetch }).responses(modelId);
}
if (wire === 'anthropic-messages') {
return createAnthropic({
authToken: apiKey,
baseURL: anthropicV1BaseUrl(baseURL),
fetch,
}).chat(modelId);
}
return createOpenAICompatible({
name: 'github-copilot',
apiKey,
baseURL,
fetch,
}).chatModel(modelId);
}
case 'unavailable':
throw new Error(`${connection.providerType} is experimental and not wired yet`);
case 'openai': {
const openai = createOpenAI({
apiKey,
baseURL,
fetch: openAiResponsesTransportState?.wrapFetch(fetch ?? globalThis.fetch) ?? fetch,
});
return wire === 'openai-responses' ? openai.responses(modelId) : openai.chat(modelId);
}
case 'google':
return createGoogle({
apiKey,
baseURL: googleV1BetaBaseUrl(baseURL),
fetch,
}).chat(modelId);
case 'cohere':
return createCohere({ apiKey, baseURL, fetch })(modelId);
case 'openai-compatible': {
if (adapter.requireBaseUrl && !baseURL) {
throw new Error(
`${connection.providerType} connection ${connection.slug} requires a base URL`,
);
}
if (wire === 'openai-responses') {
return createOpenAI({ apiKey, baseURL, fetch }).responses(modelId);
}
if (reasoningReplay.kind !== 'openai-chat-plaintext') {
throw new Error('OpenAI-compatible Chat wire requires plaintext reasoning replay');
}
const reasoningTransport = createOpenAiChatReasoningTransport(
fetch ?? globalThis.fetch,
openAiChatReasoningTransportState ??
createOpenAiChatReasoningTransportState(reasoningReplay.requestField),
connection.providerType === 'kimi-coding-plan',
);
const transformRequestBody = adapter.replayAssistantReasoningDetails
? composeRequestTransforms(
reasoningTransport.transformRequestBody,
replayAssistantReasoning('reasoning', true),
)
: reasoningTransport.transformRequestBody;
const model = createOpenAICompatible({
name: openAiCompatibleProviderOptionsName(adapter, connection),
apiKey,
baseURL,
includeUsage: adapter.includeUsage,
fetch: reasoningTransport.fetch,
transformRequestBody,
...(adapter.replayAssistantReasoningDetails
? { metadataExtractor: reasoningDetailsMetadataExtractor() }
: {}),
}).chatModel(modelId);
return adapter.replayAssistantReasoningDetails ? attachReasoningDetails(model) : model;
}
}
}
function composeRequestTransforms(
first: (body: Record<string, unknown>) => Record<string, unknown>,
second: (body: Record<string, unknown>) => Record<string, unknown>,
) {
return (body: Record<string, unknown>) => second(first(body));
}
function replayAssistantReasoning(field: 'reasoning', replayDetails: boolean) {
return (body: Record<string, unknown>): Record<string, unknown> => {
if (!Array.isArray(body.messages)) return body;
let changed = false;
const messages = body.messages.map((value) => {
if (!isRecord(value)) return value;
if (value.role !== 'assistant') {
if (!replayDetails || !Array.isArray(value.reasoning_details)) return value;
const { reasoning_details: _reasoningDetails, ...message } = value;
changed = true;
return message;
}
let message = value;
if (typeof message.reasoning_content === 'string') {
const { reasoning_content: reasoningContent, ...rest } = message;
message = { ...rest, [field]: reasoningContent };
changed = true;
}
if (!replayDetails || !Array.isArray(message.tool_calls)) return message;
let reasoningDetails: unknown[] | undefined;
const toolCalls = message.tool_calls.map((toolCall) => {
if (!isRecord(toolCall) || !Array.isArray(toolCall.reasoning_details)) return toolCall;
reasoningDetails ??= toolCall.reasoning_details;
const { reasoning_details: _reasoningDetails, ...rest } = toolCall;
changed = true;
return rest;
});
return reasoningDetails
? { ...message, reasoning_details: reasoningDetails, tool_calls: toolCalls }
: message;
});
return changed ? { ...body, messages } : body;
};
}
function reasoningDetailsMetadataExtractor(): MetadataExtractor {
return {
async extractMetadata({ parsedBody }) {
const details = reasoningDetailsFromBody(parsedBody);
return details ? { zenmux: { reasoningDetails: details } } : undefined;
},
createStreamExtractor() {
let details: JSONArray | undefined;
return {
processChunk(parsedChunk) {
details = reasoningDetailsFromBody(parsedChunk) ?? details;
},
buildMetadata() {
return details ? { zenmux: { reasoningDetails: details } } : undefined;
},
};
},
};
}
function reasoningDetailsFromBody(body: unknown): JSONArray | undefined {
if (!isRecord(body) || !Array.isArray(body.choices)) return undefined;
for (const choice of body.choices) {
if (!isRecord(choice)) continue;
for (const carrier of [choice.message, choice.delta]) {
if (isRecord(carrier) && isJSONArray(carrier.reasoning_details)) {
return carrier.reasoning_details;
}
}
}
return undefined;
}
function attachReasoningDetails(model: LanguageModelV4): LanguageModelV4 {
return new Proxy(model, {
get(target, property, receiver) {
if (property === 'doGenerate') {
return async (...args: Parameters<LanguageModelV4['doGenerate']>) => {
const result = await target.doGenerate(...args);
const details = reasoningDetailsFromMetadata(result.providerMetadata);
return details
? { ...result, content: withReasoningDetails(result.content, details) }
: result;
};
}
if (property === 'doStream') {
return async (...args: Parameters<LanguageModelV4['doStream']>) => {
const result = await target.doStream(...args);
let pendingToolCalls: Array<Extract<LanguageModelV4StreamPart, { type: 'tool-call' }>> =
[];
const stream = result.stream.pipeThrough(
new TransformStream({
transform(chunk, controller) {
if (chunk.type === 'tool-call') {
pendingToolCalls.push(chunk);
return;
}
if (chunk.type === 'finish') {
const details = reasoningDetailsFromMetadata(chunk.providerMetadata);
for (const toolCall of pendingToolCalls) {
controller.enqueue(
details ? withReasoningDetails([toolCall], details)[0] : toolCall,
);
}
pendingToolCalls = [];
}
controller.enqueue(chunk);
},
flush(controller) {
for (const toolCall of pendingToolCalls) controller.enqueue(toolCall);
},
}),
);
return { ...result, stream };
};
}
const value = Reflect.get(target, property, receiver);
return typeof value === 'function' ? value.bind(target) : value;
},
});
}
function reasoningDetailsFromMetadata(
metadata: SharedV4ProviderMetadata | undefined,
): JSONArray | undefined {
const details = metadata?.zenmux?.reasoningDetails;
return isJSONArray(details) ? details : undefined;
}
function withReasoningDetails<
Content extends { type: string; providerMetadata?: SharedV4ProviderMetadata },
>(content: Content[], details: JSONArray): Content[] {
return content.map((part) =>
part.type === 'tool-call'
? {
...part,
providerMetadata: {
...part.providerMetadata,
openaiCompatible: {
...part.providerMetadata?.openaiCompatible,
reasoning_details: details,
},
},
}
: part,
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function buildProviderOptions(
connection: RuntimeExecutionConnection,
modelId: string,
thinkingLevel?: ThinkingLevel,
): SharedV4ProviderOptions {
const thinkingOptions = thinkingOptionsForModel(connection.providerType, modelId);
const variants = thinkingVariantsForModel(connection.providerType, modelId);
const level = thinkingLevel && variants.includes(thinkingLevel) ? thinkingLevel : undefined;
switch (connection.providerType) {
case 'kimi-coding-plan': {
// Kimi's coding route has no off wire. Check the raw argument, not the
// normalized level: the entry gate above drops unsupported levels to
// undefined (default max), but an explicit off must be rejected, never
// silently upgraded to max. Today off cannot reach this branch through
// the UI (variants exclude it), but a direct runtime caller or a future
// models.dev `none` declaration must fail loudly, and the wire-contract
// sweep keeps that tripwire armed.
if (thinkingLevel === 'off') return {};
const effort = level ?? 'max';
if (connection.models?.find((model) => model.id === modelId)?.apiProtocol === 'openai-chat') {
// The kimiCodingPlan provider-options namespace is the
// openai-compatible adapter name; ai-sdk resolves its camelCase
// alias to the kimi-coding-plan schema key (reasoning_effort).
return {
kimiCodingPlan: { reasoningEffort: effort },
};
}
return {
anthropic:
modelId === 'k3' || modelId === 'k3-256k'
? {
// K3 (and its 256k-context variant) supports adaptive thinking
// only; effort defaults to max when unset.
thinking: { type: 'adaptive' as const },
effort,
}
: modelId === 'kimi-for-coding'
? {
// Kimi's managed coding route requires enabled thinking; the
// Anthropic AI SDK also requires a compatibility budget and
// otherwise injects the same value with a warning.
thinking: { type: 'enabled' as const, budgetTokens: 1_024 },
effort,
}
: {
// kimi-for-coding-highspeed has no declared effort and no
// known thinking requirements; send nothing rather than
// inventing a wire (mirrors main's prior behavior).
},
};
}
// Anthropic-protocol: effort enum models send `effort`; toggle/budget
// models send `thinking.disabled` for off. No budget-token mapping — the
// provider's native effort values pass through unchanged.
case 'anthropic':
case 'MiniMax':
case 'MiniMax-cn':
case 'claude-subscription': {
let reasoning = {};
if (level === 'off' && thinkingOptions?.offBehavior === 'anthropic-thinking-disabled') {
reasoning = { thinking: { type: 'disabled' as const } };
} else if (level && level !== 'off') {
reasoning = { effort: level };
}
return {
anthropic: {
...(connection.providerType === 'anthropic'
? { cacheControl: { type: 'ephemeral' as const } }
: {}),
...reasoning,
},
};
}
case 'openai-codex':
return {
openai: {
store: false,
textVerbosity: 'medium',
...(level ? { reasoningEffort: level === 'off' ? 'none' : level } : {}),
},
};
case 'openai':
return {
openai: {
store: false,
...(level ? { reasoningEffort: level === 'off' ? 'none' : level } : {}),
},
};
case 'volcengine-agent-plan':
return {
openai: {
store: false,
forceReasoning: true,
},
};
case 'xai':
case 'xai-oauth':
// Only grok-4.5 needs the Responses reasoning extras; every other xAI
// model serves the plain OpenAI-compatible chat wire handled below.
if (modelId === 'grok-4.5') {
return {
openai: {
store: false,
forceReasoning: true,
reasoningSummary: null,
include: ['reasoning.encrypted_content'],
...(level ? { reasoningEffort: level } : {}),
},
};
}
return buildFamilyWire(connection, modelId, level, thinkingOptions);
case 'volcengine-ark':
return {
[connection.providerType]: {
thinking: { type: level === 'off' ? 'disabled' : 'enabled' },
...(level && level !== 'off' ? { reasoningEffort: level } : {}),
},
};
case 'google':
return {
google: {
safetySettings: [
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_NONE' },
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'BLOCK_NONE' },
],
// Google effort models use thinkingLevel; Gemini 2.5 Flash disables
// thinking via the budget-zero wire. Omitting thinkingConfig means
// provider default, not "off".
...(level === 'off' && thinkingOptions?.offBehavior === 'google-thinking-budget-zero'
? { thinkingConfig: { thinkingBudget: 0 } }
: level && level !== 'off'
? { thinkingConfig: { includeThoughts: true, thinkingLevel: level } }
: {}),
},
};
case 'cloudflare-workers-ai':
return level
? {
[connection.providerType]:
level === 'off'
? thinkingOptions?.offBehavior === 'cloudflare-chat-template-thinking-false'
? { chat_template_kwargs: { thinking: false } }
: {}
: { reasoningEffort: level },
}
: {};
// Every remaining path resolves to one of a handful of wire families.
// Keying the fallback on the resolved adapter — the same object
// `getAIModel` switches on, including per-model models.dev package
// overrides — keeps declaration and wire in one seam. The variant gate
// above (level is defined only when metadata declares it) is what makes
// this safe to generalize: undeclared models never reach the wire.
default:
return buildFamilyWire(connection, modelId, level, thinkingOptions);
}
}
function buildFamilyWire(
connection: RuntimeExecutionConnection,
modelId: string,
level: ThinkingLevel | undefined,
thinkingOptions: ThinkingOptions | undefined,
): SharedV4ProviderOptions {
if (!level) return {};
const { adapter } = resolveModelRuntime(connection, modelId);
const reasoningEffort = level === 'off' ? 'none' : level;
switch (adapter.kind) {
case 'openai-compatible':
return {
[openAiCompatibleProviderOptionsName(adapter, connection)]: { reasoningEffort },
};
case 'openai':
return { openai: { reasoningEffort } };
case 'anthropic':
// Anthropic-protocol models declare no `none` effort, so an off
// choice only exists where an explicit case wires it.
return level !== 'off' ? { anthropic: { effort: level } } : {};
case 'google':
return level !== 'off'
? { google: { thinkingConfig: { includeThoughts: true, thinkingLevel: level } } }
: {};
case 'cohere':
return {
cohere:
level === 'off' && thinkingOptions?.offBehavior === 'cohere-thinking-disabled'
? { thinking: { type: 'disabled' as const } }
: {},
};
case 'github-copilot': {
// Copilot routes per account-declared model protocol (mirrors the
// getAIModel case), defaulting to its OpenAI-compatible chat wire.
const copilotProtocol = connection.models?.find((model) => model.id === modelId)?.apiProtocol;
if (copilotProtocol === 'anthropic-messages') {
return level !== 'off' ? { anthropic: { effort: level } } : {};
}
if (copilotProtocol === 'openai-responses') {
return { openai: { reasoningEffort } };
}
return { 'github-copilot': { reasoningEffort } };
}
default:
return {};
}
}
/**
* providerOptions namespace matches the `name` passed to `createOpenAICompatible`
* in `getAIModel`; both derive it from this one rule so the two can never drift.
*/
function openAiCompatibleProviderOptionsName(
adapter: ProviderRuntimeAdapter,
connection: RuntimeExecutionConnection,
): string {
return adapter.kind === 'openai-compatible' && adapter.name === 'connection'
? connection.slug
: connection.providerType;
}