blob: ffc2419bf047f568e6d7c841a2ae5820daa2dcc4 [file]
import type { ErrorEvent, CompleteEvent } from '@maka/core';
import { openai } from '@ai-sdk/openai';
import { anthropic } from '@ai-sdk/anthropic';
import { providerAuthRequiresSecret, type RuntimeExecutionConnection } from '@maka/core';
import { lookupModelMetadata } from '@maka/core';
import { generalizedErrorMessage } from '@maka/core';
import type { CacheMissInputSource } from '@maka/core';
import { rawFinishReasonString } from './model-protocol.js';
import type {
ModelMessage,
NormalizedUsage,
RawUsageFields,
ModelStreamEvent,
ModelStreamResult,
ModelFinishReason,
ModelFailure,
ModelFailureKind,
ModelRequestMetadata,
ModelToolSet,
ToolCallPart,
} from './model-protocol.js';
export type {
NormalizedUsage,
RawUsageFields,
ModelStreamEvent,
ModelStreamResult,
ModelFinishReason,
ModelFailure,
ModelFailureKind,
ModelRequestMetadata,
ModelToolSet,
} from './model-protocol.js';
import { resolveModelRuntime, type ResolvedModelRuntime } from './model-runtime.js';
import {
classifyError,
errorPresentationFromClass,
providerRetryMetadata,
} from './provider-error-classification.js';
import {
withProviderGenerateTracking,
type ProviderRequestTracker,
} from './provider-request-telemetry.js';
import {
createOpenAiChatReasoningTransportState,
openAiChatReasoningFieldProviderOptions,
restoreOpenAiChatEmptyReasoning,
type OpenAiChatReasoningTransportState,
} from './openai-chat-reasoning-transport.js';
import type { ModelFactoryInput } from './model-factory.js';
import {
mergeOpenAiResponsesProviderOptions,
planOpenAiResponsesContinuation,
} from './openai-responses-continuation.js';
import {
createOpenAiResponsesTransportState,
OPENAI_RESPONSES_LANE_HEADER,
type OpenAiResponsesTransportState,
} from './openai-responses-websocket.js';
/**
* Build an ai-sdk LanguageModel from a single input object.
* Matches the signature exported by `runtime/model-factory.ts` (@kabi):
* `getAIModel(input: ModelFactoryInput): LanguageModelV2`
*
* We type-erase the return as `unknown` here to avoid pulling ai-sdk's
* `LanguageModelV2` type into core's dependency graph.
*/
export type { ModelFactoryInput };
export type ModelFactory = (input: ModelFactoryInput) => unknown;
export interface RepairableAiSdkToolCall {
toolCallId: string;
toolName: string;
input: string;
providerExecuted?: boolean;
providerMetadata?: unknown;
}
export interface ModelAdapterInput {
sessionId?: string;
connection: RuntimeExecutionConnection;
apiKey: string;
modelId: string;
modelFactory: ModelFactory;
providerOptions?: Record<string, unknown>;
newId: () => string;
now: () => number;
/** Test seam; production adapters own one state instance for their lifetime. */
openAiResponsesTransportState?: OpenAiResponsesTransportState;
}
export interface CompactSummaryRequest {
model: unknown;
system: string;
messages: readonly ModelMessage[];
maxOutputTokens: number;
abortSignal?: AbortSignal;
/**
* Physical provider-call tracking for this summarization. Attaching it here
* rather than at the call site keeps "wrap a model with a tracker" in the one
* place that already owns it for streams.
*/
providerRequestTracker?: ProviderRequestTracker;
}
export interface CompactSummaryResult {
text: string;
usage?: NormalizedAiSdkUsage;
finishReason?: string;
providerRequestId?: string;
}
export interface ModelAdapterStreamInput {
model: unknown;
messages: ModelMessage[];
tools: ModelToolSet;
activeTools: string[];
/** Observe each successfully pulled SDK stream part before semantic translation. */
onStreamActivity: () => void;
system?: string;
abortSignal: AbortSignal;
repairToolCall: (input: {
toolCall: RepairableAiSdkToolCall;
error: unknown;
}) => RepairableAiSdkToolCall | null | Promise<RepairableAiSdkToolCall | null>;
/** Main-agent provider-call tracker. Auxiliary calls track their own generates. */
providerRequestTracker?: ProviderRequestTracker;
/** Turn-scoped continuation lane. Omitted callers keep the full-request path. */
continuationKey?: string;
}
interface ProviderMiddlewareStreamInput {
doStream: () => PromiseLike<{
stream: ReadableStream<unknown>;
request?: unknown;
response?: unknown;
}>;
params: Record<string, unknown> & { abortSignal?: AbortSignal };
model: { provider: string; modelId: string };
}
export class ModelAdapter {
private readonly runtime: ResolvedModelRuntime;
private readonly openAiChatReasoningTransportState: OpenAiChatReasoningTransportState;
private readonly openAiResponsesTransportState: OpenAiResponsesTransportState;
constructor(private readonly input: ModelAdapterInput) {
this.runtime = resolveModelRuntime(input.connection, input.modelId);
this.openAiChatReasoningTransportState = createOpenAiChatReasoningTransportState(
this.runtime.reasoningReplay.kind === 'openai-chat-plaintext'
? this.runtime.reasoningReplay.requestField
: 'observed',
);
this.openAiResponsesTransportState =
input.openAiResponsesTransportState ?? createOpenAiResponsesTransportState();
}
runtimeEventReplaySupport(): ModelAdapterRuntimeEventReplaySupport {
return {
toolCalls: true,
toolResults: true,
signedThinking: this.runtime.reasoningReplay.kind === 'anthropic-signed',
// openai-compatible transports replay stored reasoning unconditionally:
// DeepSeek-style endpoints 400 tool calls whose history lacks it, and
// relays that don't need the field ignore it. Reasoning is still
// recorded to the event log and rendered regardless.
unsignedThinking: this.runtime.reasoningReplay.kind === 'openai-chat-plaintext',
openAiResponsesThinking: this.runtime.reasoningReplay.kind === 'openai-responses-item',
};
}
resolveModel(): unknown {
if (providerAuthRequiresSecret(this.input.connection.providerType) && !this.input.apiKey) {
throw new Error(`No API key stored for connection "${this.input.connection.slug}"`);
}
return this.input.modelFactory({
connection: this.input.connection,
apiKey: this.input.apiKey,
modelId: this.input.modelId,
resolvedRuntime: this.runtime,
...(this.runtime.reasoningReplay.kind === 'openai-chat-plaintext'
? { openAiChatReasoningTransportState: this.openAiChatReasoningTransportState }
: {}),
...(usesNativeOpenAiResponses(this.input.connection, this.runtime)
? { openAiResponsesTransportState: this.openAiResponsesTransportState }
: {}),
});
}
maxOutputTokens(): number | undefined {
return selectedModelMaxOutputTokens(
this.input.connection,
this.input.modelId,
this.input.providerOptions,
this.runtime,
);
}
async startStream(input: ModelAdapterStreamInput): Promise<ModelStreamResult> {
const ai = await import('ai').catch((err) => {
throw new Error(
`Failed to load 'ai' package. Run \`npm install ai\`. Inner: ${(err as Error).message}`,
);
});
const { streamText, wrapLanguageModel } = ai as unknown as {
streamText: (opts: Record<string, unknown>) => SdkStreamResult;
wrapLanguageModel: (input: Record<string, unknown>) => unknown;
};
const maxOutputTokens = selectedModelMaxOutputTokens(
this.input.connection,
this.input.modelId,
this.input.providerOptions,
this.runtime,
);
const trackedModel = input.providerRequestTracker
? wrapLanguageModel({
model: input.model,
middleware: {
wrapStream: async ({ doStream, params, model }: ProviderMiddlewareStreamInput) =>
await input.providerRequestTracker!.trackStream({
providerId: model.provider,
modelId: model.modelId,
params,
abortSignal: input.abortSignal,
doStream,
}),
},
})
: input.model;
const sdkTools = lowerModelTools(input.tools);
const fullMessages = input.messages;
const responsesLane =
input.continuationKey && usesNativeOpenAiResponses(this.input.connection, this.runtime)
? input.continuationKey
: undefined;
const continuation = responsesLane
? planOpenAiResponsesContinuation(
fullMessages,
this.openAiResponsesTransportState.semanticBaseline(responsesLane),
)
: { messages: fullMessages };
const providerOptions = usesNativeOpenAiResponses(this.input.connection, this.runtime)
? mergeOpenAiResponsesProviderOptions(
this.input.providerOptions,
this.input.sessionId ?? this.input.connection.slug,
continuation.previousResponseId,
)
: this.input.providerOptions;
const sdkResult = streamText({
model: trackedModel,
messages: continuation.messages,
tools: sdkTools,
activeTools: input.activeTools,
// An empty active set is an authoritative tool-free request (not merely
// an empty provider schema). Some OpenAI-compatible models, including
// DeepSeek, otherwise keep emitting their native tool-call envelope as
// ordinary text when the SDK leaves toolChoice at its `auto` default.
// The child-agent finalization step relies on this boundary to spend its
// last budgeted request on a summary instead of one more unusable call.
...(input.activeTools.length === 0 ? { toolChoice: 'none' } : {}),
repairToolCall: input.repairToolCall,
...(input.system ? { instructions: input.system } : {}),
...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
providerOptions,
...(responsesLane ? { headers: { [OPENAI_RESPONSES_LANE_HEADER]: responsesLane } } : {}),
maxRetries: 0,
// Preserve the final request's Maka-owned message projection without
// retaining the provider request body. ProviderRequestTracker owns body
// capture; duplicating it here can retain large base64 image payloads.
include: { requestMessages: true },
// With no continuation predicate, streamText performs one provider step.
// Continuation belongs to the Runtime above this adapter.
abortSignal: input.abortSignal,
// The SDK default onError console.errors the raw error object (stack,
// request bodies), which lands on the terminal outside the TUI
// transcript. Stream failures already surface through the stream
// `error` event → ErrorEvent path, so silence the default.
onError: () => {},
}) as unknown as SdkStreamResult;
return this.toModelStreamResult(sdkResult, input.onStreamActivity, {
...(responsesLane ? { lane: responsesLane } : {}),
requestMessages: fullMessages,
});
}
/**
* Lower an AI SDK `streamText` result into the Maka-owned `ModelStreamResult`.
* The raw SDK chunk stream is translated lazily to `ModelStreamEvent`s so
* streaming stays live; failures, usage, finish reason, and request messages
* are normalized to Maka-owned contracts. No AI SDK type escapes this method.
*/
private toModelStreamResult(
sdk: SdkStreamResult,
onStreamActivity: () => void,
continuation: { lane?: string; requestMessages: ModelMessage[] },
): ModelStreamResult {
const openAiChatReasoningTransportState =
this.runtime.reasoningReplay.kind === 'openai-chat-plaintext'
? this.openAiChatReasoningTransportState
: undefined;
const openAiResponsesTransportState = this.openAiResponsesTransportState;
const events: AsyncIterable<ModelStreamEvent> = {
async *[Symbol.asyncIterator]() {
let succeeded = true;
try {
for await (const chunk of sdk.stream as AsyncIterable<AiSdkStreamChunk>) {
onStreamActivity();
for (const event of translateChunk(chunk, openAiChatReasoningTransportState)) {
if (event.kind === 'error') succeeded = false;
yield event;
}
}
} catch (error) {
succeeded = false;
yield { kind: 'error', failure: normalizeModelFailure(error) };
} finally {
if (!continuation.lane) return;
if (!succeeded) {
openAiResponsesTransportState.clearSemantic(continuation.lane);
return;
}
const response = await Promise.resolve(sdk.response).catch(() => undefined);
if (
response?.id &&
openAiResponsesTransportState.canRecordSemantic(continuation.lane, response.id)
) {
openAiResponsesTransportState.recordSemanticRequest(continuation.lane, {
requestMessages: structuredClone(continuation.requestMessages),
responseId: response.id,
});
} else {
openAiResponsesTransportState.clearSemantic(continuation.lane);
}
}
},
};
const usage = (async () => {
const [sdkUsage, sdkFinishReason] = await Promise.all([
sdk.usage.catch(() => undefined),
sdk.finishReason.catch(() => undefined),
]);
return normalizeAiSdkUsage(sdkUsage, { rawFinishReason: sdkFinishReason });
})();
const finishReason = (async () =>
rawFinishReasonString(await sdk.finishReason.catch(() => undefined)))();
// The SDK request contains only the wire delta during continuation. Keep
// Maka's public request metadata anchored to the complete durable
// projection so diagnostics and recovery never mistake an optimization for
// lost history.
const request = Promise.resolve({ messages: continuation.requestMessages });
return { events, usage, finishReason, request };
}
endContinuation(lane: string): void {
this.openAiResponsesTransportState.endLane(lane);
}
recordContinuationResponse(lane: string, responseMessages: readonly ModelMessage[]): void {
this.openAiResponsesTransportState.recordSemanticResponse(lane, responseMessages);
}
continuationResponsePending(lane: string): boolean {
return this.openAiResponsesTransportState.hasPendingSemantic(lane);
}
clearContinuation(lane: string): void {
this.openAiResponsesTransportState.clearSemantic(lane);
}
dispose(): void {
this.openAiResponsesTransportState.close();
}
async generateCompactSummary(input: CompactSummaryRequest): Promise<CompactSummaryResult> {
const ai = await import('ai').catch((err) => {
throw new Error(
`Failed to load 'ai' package. Run \`npm install ai\`. Inner: ${(err as Error).message}`,
);
});
const { generateText, wrapLanguageModel } = ai as unknown as {
generateText: (opts: Record<string, unknown>) => Promise<{
text?: string;
usage?: AiSdkUsageLike;
finishReason?: unknown;
providerMetadata?: unknown;
finalStep?: { response?: { id?: string } };
}>;
wrapLanguageModel: (input: Record<string, unknown>) => unknown;
};
const trackedModel = input.providerRequestTracker
? withProviderGenerateTracking({
model: input.model,
wrapLanguageModel,
tracker: input.providerRequestTracker,
...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
})
: input.model;
const result = await generateText({
model: trackedModel,
instructions: input.system,
messages: input.messages,
maxOutputTokens: input.maxOutputTokens,
abortSignal: input.abortSignal,
});
const usage = normalizeAiSdkUsage(result.usage, {
rawFinishReason: result.finishReason,
});
return {
text: result.text ?? '',
...(usage ? { usage } : {}),
...(result.finishReason !== undefined
? { finishReason: rawFinishReasonString(result.finishReason) }
: {}),
...(typeof result.finalStep?.response?.id === 'string'
? { providerRequestId: result.finalStep.response.id }
: {}),
};
}
/**
* Translate one raw AI SDK stream chunk into zero or more Maka-owned
* `ModelStreamEvent`s. This is the sole place that parses SDK chunk names
* (`text-delta` / `reasoning-delta` / `finish-step` / `finish` / `error` / …);
* the backend never sees them. Pure and side-effect-free so it is directly
* testable through the Maka-owned event contract.
*/
translateChunk(chunk: AiSdkStreamChunk): ModelStreamEvent[] {
return translateChunk(
chunk,
this.runtime.reasoningReplay.kind === 'openai-chat-plaintext'
? this.openAiChatReasoningTransportState
: undefined,
);
}
makeErrorEvent(turnId: string, err: unknown): ErrorEvent {
const failure = normalizeModelFailure(err);
return {
type: 'error',
id: this.input.newId(),
turnId,
ts: this.input.now(),
recoverable: false,
...(failure.code !== undefined ? { code: failure.code } : {}),
...(failure.kind !== 'abort' && failure.kind !== 'unknown' ? { reason: failure.kind } : {}),
message: failure.message,
};
}
normalizeFailure(error: unknown): ModelFailure {
return normalizeModelFailure(error);
}
classifyError(error: unknown): string {
if (isModelFailure(error)) return errorClassFromFailureKind(error.kind);
return classifyError(error);
}
mapFinishReason(reason: unknown): CompleteEvent['stopReason'] {
switch (reason) {
case 'stop':
return 'end_turn';
case 'length':
return 'max_tokens';
case 'content-filter':
return 'error';
case 'error':
return 'error';
case 'tool-calls':
return 'end_turn';
// The SDK's own two names for "the stream stopped and nothing named why".
// An upstream that drops the connection mid-answer lands here: it yields
// no error part and throws nothing, so these are the only signal that it
// happened. Calling them `end_turn` asserts the model said its piece —
// the one thing we know we cannot claim. A benchmark cell recorded
// `status: completed` on exactly this shape while its agent was still
// mid-task, caught only because the proxy noticed the terminal SSE event
// never arrived.
//
// These are reached when nothing else named the stop: the SDK buckets
// every reason it does not recognize into `other` too, and
// `translateChunk` forwards the provider's own spelling in that case, so
// a genuinely new reason arrives here as itself and takes the tolerant
// default below. A provider spelling its reason `other` is
// indistinguishable from the bucket and lands here; the ambiguity is
// real and this resolves it toward the safe answer.
case 'other':
case 'unknown':
return 'error';
default:
return 'end_turn';
}
}
}
function selectedModelMaxOutputTokens(
connection: RuntimeExecutionConnection,
modelId: string,
providerOptions: Record<string, unknown> | undefined,
runtime: ResolvedModelRuntime,
): number | undefined {
const anthropicMessages = runtime.wire === 'anthropic-messages';
const kimiOpenAiChat =
connection.providerType === 'kimi-coding-plan' && runtime.wire === 'openai-chat';
if (!anthropicMessages && !kimiOpenAiChat) return undefined;
const wireOutputLimit =
connection.models?.find((model) => model.id === modelId)?.maxOutputTokens ??
lookupModelMetadata(connection.providerType, modelId).maxOutputTokens;
if (wireOutputLimit === undefined) return undefined;
return anthropicMessages
? wireOutputLimit - fixedAnthropicThinkingBudget(providerOptions)
: wireOutputLimit;
}
function usesNativeOpenAiResponses(
connection: RuntimeExecutionConnection,
runtime: ResolvedModelRuntime,
): boolean {
return connection.providerType === 'openai' && runtime.wire === 'openai-responses';
}
function fixedAnthropicThinkingBudget(
providerOptions: Record<string, unknown> | undefined,
): number {
const anthropic = providerOptions?.anthropic;
if (!anthropic || typeof anthropic !== 'object' || Array.isArray(anthropic)) return 0;
const thinking = (anthropic as { thinking?: unknown }).thinking;
if (!thinking || typeof thinking !== 'object' || Array.isArray(thinking)) return 0;
const { type, budgetTokens } = thinking as { type?: unknown; budgetTokens?: unknown };
return type === 'enabled' && typeof budgetTokens === 'number' ? budgetTokens : 0;
}
export interface ModelAdapterRuntimeEventReplaySupport {
toolCalls: boolean;
toolResults: boolean;
signedThinking: boolean;
unsignedThinking: boolean;
openAiResponsesThinking: boolean;
}
/**
* Internal, adapter-only shape of an AI SDK `streamText` stream chunk. This
* type never crosses the `ModelAdapter` boundary — `ModelAdapter.translateChunk`
* consumes it and emits the Maka-owned `ModelStreamEvent`. It mirrors the AI
* SDK chunk union just enough to read the fields Maka cares about.
*/
interface AiSdkStreamChunk {
type: string;
text?: string;
delta?: string;
textDelta?: string;
toolCallId?: string;
toolName?: string;
input?: unknown;
args?: unknown;
providerExecuted?: boolean;
result?: unknown;
output?: unknown;
isError?: boolean;
usage?: AiSdkUsageLike;
finishReason?: unknown;
/** What the provider itself called it, before the SDK bucketed it. */
rawFinishReason?: unknown;
error?: unknown;
/** Provider-specific metadata; carries the Anthropic reasoning signature. */
providerMetadata?: unknown;
}
/**
* Internal, adapter-only shape of an AI SDK `streamText` result. The public
* boundary contract is `ModelStreamResult`; this exists only to type the
* lowering cast inside `ModelAdapter`.
*/
interface SdkStreamResult {
stream: AsyncIterable<AiSdkStreamChunk>;
usage: Promise<AiSdkUsageLike | undefined>;
finishReason: Promise<unknown>;
response: PromiseLike<{
id: string;
}>;
}
/**
* The finish reason to forward, preferring what the provider actually said.
*
* The SDK splits the reason in two: a closed unified enum, and the provider's
* own spelling. Unified is the right thing to forward — `runtime-runner` and
* the backend compare against `'tool-calls'`, which is a name only the SDK
* uses. Except when unified is `other`, which is not a reason but the SDK
* declining to name one; there it hides the only distinction that matters
* downstream. `other` with a provider spelling is a model that stopped for a
* reason we have no case for — an ordinary finished turn. `other` with nothing
* behind it is a stream that died without anyone saying so.
*/
function chunkFinishReason(chunk: AiSdkStreamChunk): string | undefined {
const unified = rawFinishReasonString(chunk.finishReason);
if (unified !== 'other' && unified !== 'unknown') return unified;
return rawFinishReasonString(chunk.rawFinishReason) ?? unified;
}
/**
* Extract the provider-signed reasoning signature from a stream chunk.
* Anthropic delivers it via `providerMetadata.anthropic.signature`; other
* providers omit it and this returns undefined.
*/
function reasoningSignatureFromChunk(chunk: AiSdkStreamChunk): string | undefined {
const meta = chunk.providerMetadata;
if (!meta || typeof meta !== 'object') return undefined;
const anthropic = (meta as { anthropic?: unknown }).anthropic;
if (!anthropic || typeof anthropic !== 'object') return undefined;
const signature = (anthropic as { signature?: unknown }).signature;
return typeof signature === 'string' && signature.length > 0 ? signature : undefined;
}
function openAiResponsesReasoningProviderOptionsFromChunk(
chunk: AiSdkStreamChunk,
): NonNullable<ModelMessage['providerOptions']> | undefined {
const meta = chunk.providerMetadata;
if (!meta || typeof meta !== 'object') return undefined;
const openai = (meta as { openai?: unknown }).openai;
if (!openai || typeof openai !== 'object' || Array.isArray(openai)) return undefined;
const { itemId, reasoningEncryptedContent } = openai as {
itemId?: unknown;
reasoningEncryptedContent?: unknown;
};
if (typeof itemId !== 'string' || itemId.length === 0) return undefined;
return {
openai: {
itemId,
...(typeof reasoningEncryptedContent === 'string' || reasoningEncryptedContent === null
? { reasoningEncryptedContent }
: {}),
},
};
}
/**
* Translate one raw AI SDK stream chunk into zero or more Maka-owned
* `ModelStreamEvent`s. The sole site that parses SDK chunk names; the backend
* never sees raw chunks. Pure and side-effect-free.
*/
function translateChunk(
chunk: AiSdkStreamChunk,
openAiChatReasoningTransportState?: OpenAiChatReasoningTransportState,
): ModelStreamEvent[] {
switch (chunk.type) {
case 'text-start':
return [{ kind: 'text-start' }];
case 'text-delta': {
const text = chunk.text ?? chunk.textDelta ?? chunk.delta ?? '';
return text ? [{ kind: 'text', text }] : [];
}
case 'text-end': {
if (!chunk.providerMetadata || typeof chunk.providerMetadata !== 'object') return [];
return [
{
kind: 'text-metadata',
providerOptions: chunk.providerMetadata as NonNullable<ModelMessage['providerOptions']>,
},
];
}
case 'reasoning':
case 'reasoning-delta': {
const text =
typeof chunk.text === 'string'
? chunk.text
: typeof chunk.textDelta === 'string'
? chunk.textDelta
: typeof chunk.delta === 'string'
? chunk.delta
: undefined;
const signature = reasoningSignatureFromChunk(chunk);
const responsesProviderOptions = openAiResponsesReasoningProviderOptionsFromChunk(chunk);
const events: ModelStreamEvent[] = [];
if (signature) events.push({ kind: 'thinking-signature', signature });
// The signed reasoning chunk arrives as a standalone delta with empty
// text; preserve provider-authored empty reasoning, but do not surface a
// signature-only carrier as an additional empty reasoning fragment.
if (text !== undefined && (text.length > 0 || signature === undefined)) {
events.push({
kind: 'thinking',
text: restoreOpenAiChatEmptyReasoning(text),
...(responsesProviderOptions
? { providerOptions: responsesProviderOptions }
: openAiChatReasoningTransportState
? {
providerOptions: openAiChatReasoningFieldProviderOptions(
openAiChatReasoningTransportState.reasoningField,
),
providerOptionsOrigin: 'maka_transport' as const,
}
: {}),
});
}
return events;
}
case 'reasoning-end': {
const signature = reasoningSignatureFromChunk(chunk);
const responsesProviderOptions = openAiResponsesReasoningProviderOptionsFromChunk(chunk);
return [
...(signature ? [{ kind: 'thinking-signature' as const, signature }] : []),
...(responsesProviderOptions
? [{ kind: 'thinking' as const, text: '', providerOptions: responsesProviderOptions }]
: []),
];
}
case 'tool-input-start':
case 'tool-input-delta':
case 'tool-input-end':
return chunk.providerExecuted === true ? [{ kind: 'provider-tool-input' }] : [];
// Step boundaries (`start-step` / `finish-step`) and the terminal `finish`
// carry no text/thinking to stream. The backend owns step accounting: it
// counts and flushes one AssistantMessage per step and rotates the
// messageId at each `finish-step`. `step-finish` is legacy replay fixture
// compatibility — handled as a step boundary, not a text carrier.
case 'finish-step':
case 'step-finish': {
const finishReason = chunkFinishReason(chunk);
// The same value the turn's outcome is decided from, so the record and
// the outcome cannot name different reasons for the same stream.
const usage = normalizeAiSdkUsage(chunk.usage, { rawFinishReason: finishReason });
return [
{
kind: 'step-finish',
...(usage ? { usage } : {}),
...(finishReason ? { finishReason } : {}),
},
];
}
case 'finish': {
const finishReason = chunkFinishReason(chunk);
return [{ kind: 'finish', ...(finishReason ? { finishReason } : {}) }];
}
case 'reasoning-start':
case 'start-step':
case 'tool-result':
case 'tool-error': {
if (
chunk.providerExecuted !== true ||
typeof chunk.toolCallId !== 'string' ||
typeof chunk.toolName !== 'string'
) {
return [];
}
return [
{
kind: 'provider-tool-result',
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
output: chunk.type === 'tool-error' ? chunk.error : (chunk.output ?? chunk.result),
...(chunk.type === 'tool-error' || chunk.isError === true ? { isError: true } : {}),
},
];
}
case 'tool-call': {
if (typeof chunk.toolCallId !== 'string' || typeof chunk.toolName !== 'string') return [];
const toolCall: ToolCallPart = {
type: 'tool-call',
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
input:
chunk.providerExecuted === true
? parseProviderExecutedToolInput(chunk.input ?? chunk.args)
: (chunk.input ?? chunk.args),
...(chunk.providerExecuted !== undefined
? { providerExecuted: chunk.providerExecuted }
: {}),
...(chunk.providerMetadata !== undefined
? { providerOptions: chunk.providerMetadata as ToolCallPart['providerOptions'] }
: {}),
};
return [{ kind: 'tool-call', toolCall }];
}
case 'error':
return [{ kind: 'error', failure: normalizeModelFailure(chunk.error) }];
default:
return [];
}
}
function parseProviderExecutedToolInput(input: unknown): unknown {
if (typeof input !== 'string') return input;
try {
return JSON.parse(input);
} catch {
return input;
}
}
export function lowerModelTools(tools: ModelToolSet): Record<string, unknown> {
return Object.fromEntries(
Object.entries(tools).map(([name, definition]) => [
name,
definition.kind === 'provider'
? compileProviderTool(definition.providerTool)
: {
...(definition.description !== undefined
? { description: definition.description }
: {}),
inputSchema: definition.inputSchema,
},
]),
);
}
function compileProviderTool(
tool: NonNullable<import('./tool-runtime.js').MakaTool['providerTool']>,
): unknown {
switch (tool.kind) {
case 'openai-web-search':
return openai.tools.webSearch({
...(tool.searchContextSize ? { searchContextSize: tool.searchContextSize } : {}),
});
case 'anthropic-web-search-20250305':
return anthropic.tools.webSearch_20250305({
...(tool.maxUses !== undefined ? { maxUses: tool.maxUses } : {}),
});
}
}
function normalizeModelFailure(error: unknown): ModelFailure {
if (isModelFailure(error)) return error;
const errorClass = classifyError(error);
const presentation = errorPresentationFromClass(errorClass);
const retry = providerRetryMetadata(error);
const code =
error instanceof Error && 'code' in error
? String((error as { code?: unknown }).code)
: undefined;
return {
type: 'model_failure',
kind: modelFailureKind(errorClass),
retryable: retry.retryable,
...(retry.retryAfterMs !== undefined ? { retryAfterMs: retry.retryAfterMs } : {}),
...(code !== undefined ? { code } : {}),
message: presentation.message ?? generalizedErrorMessage(error),
};
}
function isModelFailure(value: unknown): value is ModelFailure {
return (
typeof value === 'object' &&
value !== null &&
(value as { type?: unknown }).type === 'model_failure' &&
typeof (value as { kind?: unknown }).kind === 'string' &&
typeof (value as { message?: unknown }).message === 'string'
);
}
function modelFailureKind(errorClass: string): ModelFailureKind {
switch (errorClass) {
case 'Abort':
return 'abort';
case 'Auth':
return 'auth';
case 'ContextLength':
return 'context_overflow';
case 'Network':
return 'network';
case 'ProviderBilling':
return 'provider_billing';
case 'ProviderUnavailable':
return 'provider_unavailable';
case 'RateLimit':
return 'rate_limit';
case 'Timeout':
return 'timeout';
default:
return 'unknown';
}
}
function errorClassFromFailureKind(kind: ModelFailureKind): string {
switch (kind) {
case 'abort':
return 'Abort';
case 'auth':
return 'Auth';
case 'context_overflow':
return 'ContextLength';
case 'network':
return 'Network';
case 'provider_billing':
return 'ProviderBilling';
case 'provider_unavailable':
return 'ProviderUnavailable';
case 'rate_limit':
return 'RateLimit';
case 'timeout':
return 'Timeout';
case 'unknown':
return 'Other';
}
}
type TokenCountBreakdown = {
total?: number;
noCache?: number;
cacheRead?: number;
cacheWrite?: number;
text?: number;
reasoning?: number;
};
/**
* Internal, adapter-only mirror of the AI SDK raw usage fields. The public
* `RawUsageFields` contract lives in `model-protocol.ts`; this stays here as
* the lowering input shape and is assigned to `NormalizedUsage.raw`.
*/
export type AiSdkRawUsageFields = RawUsageFields;
export interface AiSdkUsageLike {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
inputTokens?: number | TokenCountBreakdown;
outputTokens?: number | TokenCountBreakdown;
cacheHitInputTokens?: number;
cacheMissInputTokens?: number;
cachedInputTokens?: number;
cacheWriteInputTokens?: number;
reasoningTokens?: number;
cacheReadInputTokens?: number;
cacheCreationInputTokens?: number;
prompt_cache_hit_tokens?: number;
prompt_cache_miss_tokens?: number;
prompt_tokens_details?: {
cached_tokens?: number;
};
completion_tokens_details?: {
reasoning_tokens?: number;
};
inputTokenDetails?: {
cachedTokens?: number;
cacheMissTokens?: number;
noCacheTokens?: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
reasoningTokens?: number;
};
outputTokenDetails?: {
textTokens?: number;
reasoningTokens?: number;
};
raw?: AiSdkRawUsageFields;
}
/**
* @deprecated alias for the Maka-owned `NormalizedUsage` contract exported
* from `model-protocol.ts`. Kept for backward compatibility with existing
* internal import sites during the slice-1 transition.
*/
export type NormalizedAiSdkUsage = NormalizedUsage;
export function normalizeAiSdkUsage(
usage: AiSdkUsageLike | undefined,
options: { rawFinishReason?: unknown } = {},
): NormalizedUsage | undefined {
if (!usage) return undefined;
const reportedInputTokens =
finiteTokenFromValueOrBreakdown(usage.inputTokens, 'total') ??
finiteTokenBreakdownSum(usage.inputTokens, ['noCache', 'cacheRead', 'cacheWrite']) ??
finiteToken(usage.promptTokens) ??
finiteToken(usage.raw?.prompt_tokens) ??
finiteToken(usage.prompt_tokens) ??
finiteTokenSum([
usage.inputTokenDetails?.noCacheTokens,
usage.inputTokenDetails?.cacheReadTokens,
usage.inputTokenDetails?.cacheWriteTokens,
]);
const reportedOutputTokens =
finiteTokenFromValueOrBreakdown(usage.outputTokens, 'total') ??
finiteTokenBreakdownSum(usage.outputTokens, ['text', 'reasoning']) ??
finiteToken(usage.completionTokens) ??
finiteToken(usage.raw?.completion_tokens) ??
finiteToken(usage.completion_tokens) ??
finiteTokenSum([
usage.outputTokenDetails?.textTokens,
usage.outputTokenDetails?.reasoningTokens,
]);
const reportedCacheHitInputTokens =
finiteToken(usage.cacheHitInputTokens) ??
finiteToken(usage.cachedInputTokens) ??
finiteToken(usage.cacheReadInputTokens) ??
finiteToken(usage.raw?.prompt_cache_hit_tokens) ??
finiteToken(usage.prompt_cache_hit_tokens) ??
finiteToken(usage.raw?.prompt_tokens_details?.cached_tokens) ??
finiteToken(usage.prompt_tokens_details?.cached_tokens) ??
finiteTokenFromBreakdown(usage.inputTokens, 'cacheRead') ??
finiteToken(usage.inputTokenDetails?.cacheReadTokens) ??
finiteToken(usage.inputTokenDetails?.cachedTokens);
const reportedCacheWriteInputTokens =
finiteToken(usage.cacheWriteInputTokens) ??
finiteToken(usage.cacheCreationInputTokens) ??
finiteTokenFromBreakdown(usage.inputTokens, 'cacheWrite') ??
finiteToken(usage.inputTokenDetails?.cacheWriteTokens);
const explicitCacheMissInputTokens =
finiteToken(usage.cacheMissInputTokens) ??
finiteToken(usage.raw?.prompt_cache_miss_tokens) ??
finiteToken(usage.prompt_cache_miss_tokens) ??
finiteTokenFromBreakdown(usage.inputTokens, 'noCache') ??
finiteToken(usage.inputTokenDetails?.noCacheTokens) ??
finiteToken(usage.inputTokenDetails?.cacheMissTokens);
const reportedReasoningTokens =
finiteToken(usage.reasoningTokens) ??
finiteTokenFromBreakdown(usage.outputTokens, 'reasoning') ??
finiteToken(usage.outputTokenDetails?.reasoningTokens) ??
finiteToken(usage.raw?.completion_tokens_details?.reasoning_tokens) ??
finiteToken(usage.completion_tokens_details?.reasoning_tokens) ??
finiteToken(usage.inputTokenDetails?.reasoningTokens);
const reportedTotalTokens =
finiteToken(usage.totalTokens) ??
finiteToken(usage.raw?.total_tokens) ??
finiteToken(usage.total_tokens);
const inputTokens =
reportedInputTokens ??
(reportedTotalTokens !== undefined &&
reportedOutputTokens !== undefined &&
reportedTotalTokens >= reportedOutputTokens
? reportedTotalTokens - reportedOutputTokens
: undefined);
const outputTokens =
reportedOutputTokens ??
(reportedTotalTokens !== undefined &&
reportedInputTokens !== undefined &&
reportedTotalTokens >= reportedInputTokens
? reportedTotalTokens - reportedInputTokens
: undefined);
if (inputTokens === undefined || outputTokens === undefined) return undefined;
const cacheHitInputTokens = reportedCacheHitInputTokens ?? 0;
const cacheWriteInputTokens = reportedCacheWriteInputTokens ?? 0;
const cacheMissInputTokens =
explicitCacheMissInputTokens ??
Math.max(0, inputTokens - cacheHitInputTokens - cacheWriteInputTokens);
const cacheMissInputSource: CacheMissInputSource =
explicitCacheMissInputTokens !== undefined ? 'explicit' : 'derived';
const reasoningTokens = reportedReasoningTokens ?? 0;
const totalTokens = reportedTotalTokens ?? inputTokens + outputTokens;
const raw = rawUsageFields(usage);
const rawFinishReason = rawFinishReasonString(options.rawFinishReason);
return {
inputTokens,
outputTokens,
cacheHitInputTokens,
cacheMissInputTokens,
cacheMissInputSource,
cacheWriteInputTokens,
reasoningTokens,
totalTokens,
...(rawFinishReason !== undefined ? { rawFinishReason } : {}),
...(raw !== undefined ? { raw } : {}),
cachedInputTokens: cacheHitInputTokens,
};
}
function finiteToken(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
}
function finiteTokenFromBreakdown(
value: number | TokenCountBreakdown | undefined,
key: keyof TokenCountBreakdown,
): number | undefined {
if (!value || typeof value !== 'object') return undefined;
return finiteToken(value[key]);
}
function finiteTokenFromValueOrBreakdown(
value: number | TokenCountBreakdown | undefined,
key: keyof TokenCountBreakdown,
): number | undefined {
return finiteToken(value) ?? finiteTokenFromBreakdown(value, key);
}
function finiteTokenBreakdownSum(
value: number | TokenCountBreakdown | undefined,
keys: readonly (keyof TokenCountBreakdown)[],
): number | undefined {
if (!value || typeof value !== 'object') return undefined;
const parts = keys.map((key) => finiteToken(value[key]));
return parts.every((part) => part === undefined)
? undefined
: parts.reduce<number>((sum, part) => sum + (part ?? 0), 0);
}
function finiteTokenSum(values: readonly unknown[]): number | undefined {
const tokens = values.map(finiteToken);
return tokens.every((token) => token === undefined)
? undefined
: tokens.reduce<number>((sum, token) => sum + (token ?? 0), 0);
}
function rawUsageFields(usage: AiSdkUsageLike): AiSdkRawUsageFields | undefined {
const raw: AiSdkRawUsageFields = {};
const promptTokens = finiteToken(usage.prompt_tokens) ?? finiteToken(usage.raw?.prompt_tokens);
if (promptTokens !== undefined) raw.prompt_tokens = promptTokens;
const completionTokens =
finiteToken(usage.completion_tokens) ?? finiteToken(usage.raw?.completion_tokens);
if (completionTokens !== undefined) raw.completion_tokens = completionTokens;
const totalTokens = finiteToken(usage.total_tokens) ?? finiteToken(usage.raw?.total_tokens);
if (totalTokens !== undefined) raw.total_tokens = totalTokens;
const promptCacheHitTokens =
finiteToken(usage.prompt_cache_hit_tokens) ?? finiteToken(usage.raw?.prompt_cache_hit_tokens);
if (promptCacheHitTokens !== undefined) raw.prompt_cache_hit_tokens = promptCacheHitTokens;
const promptCacheMissTokens =
finiteToken(usage.prompt_cache_miss_tokens) ?? finiteToken(usage.raw?.prompt_cache_miss_tokens);
if (promptCacheMissTokens !== undefined) raw.prompt_cache_miss_tokens = promptCacheMissTokens;
const cachedTokens =
finiteToken(usage.prompt_tokens_details?.cached_tokens) ??
finiteToken(usage.raw?.prompt_tokens_details?.cached_tokens);
if (cachedTokens !== undefined) raw.prompt_tokens_details = { cached_tokens: cachedTokens };
const reasoningTokens =
finiteToken(usage.completion_tokens_details?.reasoning_tokens) ??
finiteToken(usage.raw?.completion_tokens_details?.reasoning_tokens);
if (reasoningTokens !== undefined) {
raw.completion_tokens_details = { reasoning_tokens: reasoningTokens };
}
return Object.keys(raw).length > 0 ? raw : undefined;
}