/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import type { ErrorEvent, CompleteEvent } from '@maka/core/events'; import { openai } from '@ai-sdk/openai'; import { anthropic } from '@ai-sdk/anthropic'; import { providerAuthRequiresSecret, type RuntimeExecutionConnection, } from '@maka/core/llm-connections'; import { lookupModelMetadata } from '@maka/core/model-metadata'; import { generalizedErrorMessage } from '@maka/core/redaction'; import type { CacheMissInputSource } from '@maka/core/usage-stats/types'; import { rawFinishReasonString } from './model-protocol.js'; import type { ModelMessage, NormalizedUsage, RawUsageFields, ModelStreamEvent, ModelStreamResult, ModelStepOutcome, ModelFinishReason, ModelFailure, ModelFailureKind, ModelRequestMetadata, ModelToolSet, ToolCallPart, } from './model-protocol.js'; export type { NormalizedUsage, RawUsageFields, ModelStreamEvent, ModelStreamResult, ModelStepOutcome, ModelFinishReason, ModelFailure, ModelFailureKind, ModelRequestMetadata, ModelToolSet, } from './model-protocol.js'; import { resolveModelRuntime, type ResolvedModelRuntime } from './model-runtime.js'; import { plaintextResponsesReasoningProviderOptions, safePlaintextResponsesReasoningItemId, } from './responses-reasoning-state.js'; import { classifyError, errorPresentationFromClass, providerFailureSummary, providerRetryMetadata, } from './provider-error-classification.js'; import type { ProviderRequestTracker } from './provider-request-telemetry.js'; import type { ContextDiagnosticsCompaction } from './context-diagnostics.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'; import { openAiApplyPatchProviderTool } from './openai-apply-patch.js'; import { TOOL_SEARCH_NAME, TOOL_SEARCH_PROVIDER_NAME } from './tool-availability.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; newId: () => string; now: () => number; /** Test seam; production adapters own one state instance for their lifetime. */ openAiResponsesTransportState?: OpenAiResponsesTransportState; } 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; /** Main-agent provider-call tracker. Auxiliary calls track their own generates. */ providerRequestTracker?: ProviderRequestTracker; /** * The compaction boundary the messages of THIS call were projected under * (#2323). Travels with the messages rather than being read from session * state at settlement, which is why it is an argument here at all: the * caller dispatches once per physical request and knows which fold each one * was built from; nothing downstream can recover that afterwards. */ historyCompactBoundary?: ContextDiagnosticsCompaction; /** Turn-scoped continuation lane. Omitted callers keep the full-request path. */ continuationKey?: string; } interface ProviderMiddlewareStreamInput { doStream: () => PromiseLike<{ stream: ReadableStream; request?: unknown; response?: unknown; }>; params: Record & { 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, // Verified against @ai-sdk/open-responses@2.0.34: replay preserves // item order and IDs, but a provider-executed result embedded in the // assistant message (Maka's provider-tool chronology) is still dropped, // leaving a dangling function_call on the wire. Fail closed until the // upstream extension seam (vercel/ai#18899) can round-trip the pair. providerExecutedTools: this.runtime.reasoningReplay.kind !== 'responses' || this.runtime.reasoningReplay.contract.adapter !== 'open-responses', 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', responsesReasoning: this.runtime.reasoningReplay.kind !== 'responses' ? 'none' : this.runtime.reasoningReplay.contract.reasoningReplay === 'encrypted-content' ? 'encrypted-content' : this.runtime.reasoningReplay.contract.reasoningReplay === 'plaintext-content' ? 'plaintext-content' : { kind: 'plaintext-item', profile: requireResponsesReplayProfile(this.runtime), providerOptionsKey: requireResponsesProviderOptionsKey(this.runtime), }, }; } 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 { 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) => SdkStreamResult; wrapLanguageModel: (input: Record) => 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.historyCompactBoundary ? { historyCompactBoundary: input.historyCompactBoundary } : {}), }), }, }) : input.model; const usesOpenAiResponsesAdapter = hasOpenAiResponsesAdapter(this.runtime); const providerToolName = (name: string): string => usesOpenAiResponsesAdapter && name === TOOL_SEARCH_NAME ? TOOL_SEARCH_PROVIDER_NAME : name; const runtimeToolName = (name: string): string => usesOpenAiResponsesAdapter && name === TOOL_SEARCH_PROVIDER_NAME ? TOOL_SEARCH_NAME : name; const sdkTools = lowerModelTools(input.tools); if (usesOpenAiResponsesAdapter && sdkTools[TOOL_SEARCH_NAME] !== undefined) { sdkTools[TOOL_SEARCH_PROVIDER_NAME] = sdkTools[TOOL_SEARCH_NAME]; delete sdkTools[TOOL_SEARCH_NAME]; } 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 providerMessages = remapModelMessageToolNames(continuation.messages, providerToolName); const providerSystem = input.system ? remapProviderToolNamesInText(input.system, providerToolName) : undefined; 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: providerMessages, tools: sdkTools, activeTools: input.activeTools.map(providerToolName), // 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: async ({ toolCall, error, }: { toolCall: RepairableAiSdkToolCall; error: unknown; }) => { const repaired = await input.repairToolCall({ toolCall: { ...toolCall, toolName: runtimeToolName(toolCall.toolName) }, error, }); return repaired ? { ...repaired, toolName: providerToolName(repaired.toolName) } : repaired; }, ...(providerSystem ? { instructions: providerSystem } : {}), ...(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, abortSignal: input.abortSignal, runtimeToolName, }); } /** * 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[]; abortSignal: AbortSignal; runtimeToolName?: (name: string) => string; }, ): ModelStreamResult { const openAiChatReasoningTransportState = this.runtime.reasoningReplay.kind === 'openai-chat-plaintext' ? this.openAiChatReasoningTransportState : undefined; const openAiResponsesTransportState = this.openAiResponsesTransportState; const resolvedRuntime = this.runtime; let settleOutcome!: (outcome: ModelStepOutcome) => void; const outcome = new Promise((resolve) => { settleOutcome = resolve; }); const request = { messages: continuation.requestMessages }; const events: AsyncIterable = { async *[Symbol.asyncIterator]() { let failure: ModelFailure | undefined; let sawFinish = false; let streamedFinishReason: string | undefined; let streamedRawFinishReason: string | undefined; let sawUnfinalizedPlaintextSummary = false; try { for await (const chunk of sdk.stream as AsyncIterable) { onStreamActivity(); if ( chunk.type === 'finish' || chunk.type === 'finish-step' || chunk.type === 'step-finish' ) { streamedRawFinishReason = rawFinishReasonString(chunk.rawFinishReason) ?? streamedRawFinishReason; } if (isUnfinalizedPlaintextSummaryReasoningEnd(chunk, resolvedRuntime)) { // The SDK emits this trailer from flush() when no // response.output_item.done finalized the active item. Defer the // decision until the terminal outcome is known: an existing // provider failure must win, while a successful stream must // still fail closed instead of losing replay state silently. sawUnfinalizedPlaintextSummary = true; continue; } for (const event of translateChunk( chunk, openAiChatReasoningTransportState, resolvedRuntime, continuation.runtimeToolName, )) { if (event.kind === 'error') failure = event.failure; if (event.kind === 'finish') sawFinish = true; if (event.kind === 'finish' || event.kind === 'step-finish') { streamedFinishReason = event.finishReason ?? streamedFinishReason; } yield event; } } } catch (error) { if (!failure) { failure = normalizeProviderFailure(error); yield { kind: 'error', failure }; } } finally { const [sdkUsage, sdkFinishReason] = await Promise.all([ sdk.usage.catch(() => undefined), sdk.finishReason.catch(() => undefined), ]); // An early-stopping consumer (a provider-mismatch throw, a user // stop) ends this stream before any finish chunk exists, so the SDK // rejects every result promise during teardown. `usage` and // `finishReason` are consumed above; `response` is only read on the // completed continuation path below. Sink it unconditionally so the // error path can never surface an unhandled rejection after the // turn unwinds — the timing of that settlement is scheduler-owned // (observed post-test on Windows), and Node's default makes an // unhandled rejection a crash. void Promise.resolve(sdk.response).catch(() => undefined); const finishReason = streamedFinishReason ?? rawFinishReasonString(sdkFinishReason) ?? 'unknown'; const rawFinishReason = streamedRawFinishReason ?? rawFinishReasonString(sdkFinishReason) ?? finishReason; const usage = normalizeAiSdkUsage(sdkUsage, { rawFinishReason }); let settled = settleModelStepOutcome({ aborted: continuation.abortSignal.aborted, failure, sawFinish, finishReason, rawFinishReason, usage, request, }); let deferredFailure: ModelFailure | undefined; if (sawUnfinalizedPlaintextSummary && settled.kind === 'completed') { failure = normalizeProviderFailure( new Error('Plaintext Responses reasoning item is missing final summary metadata'), ); deferredFailure = failure; settled = settleModelStepOutcome({ aborted: continuation.abortSignal.aborted, failure, sawFinish, finishReason, rawFinishReason, usage, request, }); } try { if (continuation.lane) { if (settled.kind === 'completed') { 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, }); settled = { ...settled, continuation: 'pending' }; } else { openAiResponsesTransportState.clearSemantic(continuation.lane); } } else { openAiResponsesTransportState.clearSemantic(continuation.lane); } } } finally { settleOutcome(settled); } if (deferredFailure) { // Consumers may stop iterating at the first error. The outcome and // continuation lane must already be settled before this yield so a // generator return cannot strand the caller awaiting result.outcome. yield { kind: 'error', failure: deferredFailure }; } } }, }; return { events, outcome }; } endContinuation(lane: string): void { this.openAiResponsesTransportState.endLane(lane); } recordContinuationResponse(lane: string, responseMessages: readonly ModelMessage[]): void { this.openAiResponsesTransportState.recordSemanticResponse(lane, responseMessages); } clearContinuation(lane: string): void { this.openAiResponsesTransportState.clearSemantic(lane); } dispose(): void { this.openAiResponsesTransportState.close(); } /** * 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, this.runtime, ); } makeErrorEvent(turnId: string, err: unknown, reasonOverride?: string): 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 } : {}), ...(reasonOverride !== undefined ? { reason: reasonOverride } : failure.kind !== 'abort' && failure.kind !== 'unknown' ? { reason: failure.kind } : {}), message: failure.message, }; } normalizeFailure(error: unknown): ModelFailure { return normalizeProviderFailure(error); } classifyError(error: unknown): string { if (isModelFailure(error)) return errorClassFromFailureKind(error.kind); return classifyError(error); } /** Map a successfully settled provider step to its runtime stop reason. */ mapFinishReason(reason: ModelFinishReason): CompleteEvent['stopReason'] { switch (reason) { case 'stop': return 'end_turn'; case 'length': return 'max_tokens'; case 'tool-calls': return 'end_turn'; default: return 'end_turn'; } } } interface ModelStepSettlementEvidence { aborted: boolean; failure?: ModelFailure; sawFinish: boolean; finishReason: ModelFinishReason; rawFinishReason?: string; usage?: NormalizedUsage; request: ModelRequestMetadata; } export function settleModelStepOutcome(evidence: ModelStepSettlementEvidence): ModelStepOutcome { const { aborted, failure, sawFinish, finishReason, rawFinishReason, usage, request } = evidence; if (aborted || failure?.kind === 'abort') { return failedStepOutcome( 'aborted', failure ?? normalizeModelFailure(Object.assign(new Error('aborted'), { name: 'AbortError' })), request, usage, ); } if (failure) { return failedStepOutcome( failure.retryable ? 'retryable-failure' : 'terminal-failure', failure, request, usage, ); } if (!sawFinish || finishReason === 'other' || finishReason === 'unknown') { return failedStepOutcome( 'truncated', modelStepFailure( 'provider_unavailable', `Provider stream ended without finishing (${finishReason})`, ), request, usage, ); } if (finishReason === 'content-filter' || finishReason === 'error') { const terminalFailure = finishReason === 'error' ? providerFinishFailure(rawFinishReason) : modelStepFailure('unknown', 'Provider stopped the stream on a content filter'); return failedStepOutcome( terminalFailure.retryable ? 'retryable-failure' : 'terminal-failure', terminalFailure, request, usage, ); } return { kind: 'completed', finishReason, ...(usage ? { usage } : {}), request, continuation: 'none', }; } function modelStepFailure(kind: ModelFailureKind, message: string): ModelFailure { return { type: 'model_failure', kind, message, retryable: false }; } function providerFinishFailure(rawFinishReason: string | undefined): ModelFailure { if (rawFinishReason && rawFinishReason !== 'error') { const normalized = normalizeProviderFailure({ code: rawFinishReason, message: 'Provider stopped the stream with an error', }); // A finish reason carries no request-level Retry-After or transport // evidence. Preserve its classification for diagnostics without widening // the pre-existing retry policy for every provider. if (normalized.kind !== 'unknown') return { ...normalized, retryable: false }; return { ...modelStepFailure('provider_unavailable', 'Provider stopped the stream with an error'), ...(normalized.code ? { code: normalized.code } : {}), }; } return modelStepFailure('provider_unavailable', 'Provider stopped the stream with an error'); } function failedStepOutcome( kind: Exclude, failure: ModelFailure, request: ModelRequestMetadata, usage?: NormalizedUsage, ): Exclude { return { kind, failure, ...(usage ? { usage } : {}), request, continuation: 'none', }; } function selectedModelMaxOutputTokens( connection: RuntimeExecutionConnection, modelId: string, providerOptions: Record | 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 hasOpenAiResponsesAdapter(runtime: ResolvedModelRuntime): boolean { return ( runtime.wire === 'openai-responses' && runtime.reasoningReplay.kind === 'responses' && runtime.reasoningReplay.contract.adapter === 'openai' ); } function fixedAnthropicThinkingBudget( providerOptions: Record | 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; providerExecutedTools: boolean; signedThinking: boolean; unsignedThinking: boolean; responsesReasoning: | 'none' | 'encrypted-content' | 'plaintext-content' | { kind: 'plaintext-item'; profile: string; providerOptionsKey: string; }; } function requireResponsesProviderOptionsKey(runtime: ResolvedModelRuntime): string { if (!runtime.responsesProviderOptionsKey) { throw new Error('Plaintext Responses replay requires a provider-options key'); } return runtime.responsesProviderOptionsKey; } function requireResponsesReplayProfile(runtime: ResolvedModelRuntime): string { if (!runtime.responsesReplayProfile) { throw new Error('Plaintext Responses replay requires a source profile'); } return runtime.responsesReplayProfile; } /** * 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; id?: unknown; 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; usage: Promise; finishReason: Promise; 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 — `RuntimeKernel` 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 anthropicRedactedThinkingProviderOptionsFromChunk( chunk: AiSdkStreamChunk, ): NonNullable | undefined { const meta = chunk.providerMetadata; if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return undefined; const anthropic = (meta as { anthropic?: unknown }).anthropic; if (!anthropic || typeof anthropic !== 'object' || Array.isArray(anthropic)) return undefined; const redactedData = (anthropic as { redactedData?: unknown }).redactedData; return typeof redactedData === 'string' ? (meta as NonNullable) : undefined; } function openAiResponsesReasoningProviderOptionsFromChunk( chunk: AiSdkStreamChunk, runtime: ResolvedModelRuntime, ): NonNullable | undefined { const meta = chunk.providerMetadata; if ( runtime.reasoningReplay.kind === 'responses' && runtime.reasoningReplay.contract.reasoningReplay === 'plaintext-summary' ) { if (chunk.type !== 'reasoning' && chunk.type !== 'reasoning-end') return undefined; const providerOptionsKey = runtime.responsesProviderOptionsKey; const provider = providerOptionsKey && meta && typeof meta === 'object' ? (meta as Record)[providerOptionsKey] : undefined; const metadataItemIdValue = provider && typeof provider === 'object' && !Array.isArray(provider) ? (provider as { itemId?: unknown }).itemId : undefined; const streamItemIdValue = (chunk as { id?: unknown }).id; if ( typeof metadataItemIdValue === 'string' && typeof streamItemIdValue === 'string' && metadataItemIdValue !== streamItemIdValue ) { throw new Error('Plaintext Responses reasoning item id changed within one stream item'); } const itemId = safePlaintextResponsesReasoningItemId(metadataItemIdValue) ?? safePlaintextResponsesReasoningItemId(streamItemIdValue); const summaryParts = plaintextSummaryParts(provider); if (!itemId || !summaryParts) { throw new Error('Plaintext Responses reasoning item is missing final summary metadata'); } const providerOptions = plaintextResponsesReasoningProviderOptions( itemId, requireResponsesReplayProfile(runtime), summaryParts, ); if (!providerOptions) { throw new Error('Plaintext Responses reasoning summary exceeds durable state bounds'); } return providerOptions; } 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 } : {}), }, }; } function isUnfinalizedPlaintextSummaryReasoningEnd( chunk: AiSdkStreamChunk, runtime: ResolvedModelRuntime, ): boolean { return ( runtime.reasoningReplay.kind === 'responses' && runtime.reasoningReplay.contract.reasoningReplay === 'plaintext-summary' && chunk.type === 'reasoning-end' && (chunk.providerMetadata === undefined || chunk.providerMetadata === null) ); } function plaintextSummaryParts(provider: unknown): string[] | undefined { if (!provider || typeof provider !== 'object' || Array.isArray(provider)) return undefined; const summary = (provider as { reasoningSummary?: unknown }).reasoningSummary; if (!Array.isArray(summary)) return undefined; const parts: string[] = []; for (const part of summary) { if (!part || typeof part !== 'object' || Array.isArray(part)) return undefined; const { type, text } = part as { type?: unknown; text?: unknown }; if (type !== 'summary_text' || typeof text !== 'string') return undefined; parts.push(text); } return parts; } function plaintextSummaryTextFromChunk( chunk: AiSdkStreamChunk, runtime: ResolvedModelRuntime | undefined, ): string | undefined { if ( runtime?.reasoningReplay.kind !== 'responses' || runtime.reasoningReplay.contract.reasoningReplay !== 'plaintext-summary' || (chunk.type !== 'reasoning' && chunk.type !== 'reasoning-end') ) { return undefined; } const providerOptionsKey = runtime.responsesProviderOptionsKey; const meta = chunk.providerMetadata; const provider = providerOptionsKey && meta && typeof meta === 'object' ? (meta as Record)[providerOptionsKey] : undefined; return plaintextSummaryParts(provider)?.join(''); } function reasoningPartIdFromChunk(chunk: AiSdkStreamChunk): string | undefined { return typeof chunk.id === 'string' && chunk.id.length > 0 && chunk.id.length <= 512 ? chunk.id : undefined; } /** * 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, runtime?: ResolvedModelRuntime, runtimeToolName?: (name: string) => string, ): ModelStreamEvent[] { switch (chunk.type) { case 'reasoning-start': { const reasoningPartId = reasoningPartIdFromChunk(chunk); const redactedThinkingProviderOptions = anthropicRedactedThinkingProviderOptionsFromChunk(chunk); return [ { kind: 'thinking-start', ...(redactedThinkingProviderOptions ? { providerOptions: redactedThinkingProviderOptions } : {}), ...(reasoningPartId ? { reasoningPartId } : {}), }, ]; } 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, }, ]; } 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 = runtime ? openAiResponsesReasoningProviderOptionsFromChunk(chunk, runtime) : undefined; const reasoningPartId = reasoningPartIdFromChunk(chunk); const reasoningSummaryText = plaintextSummaryTextFromChunk(chunk, runtime); const events: ModelStreamEvent[] = []; if (signature) { events.push({ kind: 'thinking-signature', signature, ...(reasoningPartId ? { reasoningPartId } : {}), }); } // 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, } : {}), ...(reasoningPartId ? { reasoningPartId } : {}), ...(reasoningSummaryText !== undefined ? { reasoningSummaryText } : {}), }); } return events; } case 'reasoning-end': { const signature = reasoningSignatureFromChunk(chunk); const responsesProviderOptions = runtime ? openAiResponsesReasoningProviderOptionsFromChunk(chunk, runtime) : undefined; const reasoningPartId = reasoningPartIdFromChunk(chunk); const reasoningSummaryText = plaintextSummaryTextFromChunk(chunk, runtime); return [ ...(signature ? [ { kind: 'thinking-signature' as const, signature, ...(reasoningPartId ? { reasoningPartId } : {}), }, ] : []), ...(responsesProviderOptions ? [ { kind: 'thinking' as const, text: '', providerOptions: responsesProviderOptions, ...(reasoningPartId ? { reasoningPartId } : {}), ...(reasoningSummaryText !== undefined ? { reasoningSummaryText } : {}), }, ] : []), ]; } 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); const rawFinishReason = rawFinishReasonString(chunk.rawFinishReason); // 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: rawFinishReason ?? finishReason, }); return [ { kind: 'step-finish', ...(usage ? { usage } : {}), ...(finishReason ? { finishReason } : {}), }, ]; } case 'finish': { const finishReason = chunkFinishReason(chunk); return [{ kind: 'finish', ...(finishReason ? { finishReason } : {}) }]; } 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: runtimeToolName?.(chunk.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: runtimeToolName?.(chunk.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: normalizeProviderFailure(chunk.error) }]; default: return []; } } /** * OpenAI Responses reserves the provider name `tool_search`. Keep Maka's * persisted/history name intact and translate only the provider-bound copy. */ function remapModelMessageToolNames( messages: readonly ModelMessage[], providerToolName: (name: string) => string, ): ModelMessage[] { const remapContent = (content: readonly T[]): T[] => content.map((part) => { if ( (part.type === 'tool-call' || part.type === 'tool-result') && 'toolName' in part && typeof part.toolName === 'string' ) { const remapped = { ...part, toolName: providerToolName(part.toolName) } as T & { output?: { type?: string; value?: unknown }; }; if (part.type === 'tool-result' && remapped.output) { if ( (remapped.output.type === 'text' || remapped.output.type === 'error-text') && typeof remapped.output.value === 'string' ) { remapped.output = { ...remapped.output, value: remapProviderToolNamesInText(remapped.output.value, providerToolName), }; } } return remapped as T; } return part; }); return messages.map((message) => { if (message.role === 'assistant' && Array.isArray(message.content)) { return { ...message, content: remapContent(message.content) }; } if (message.role === 'tool') { return { ...message, content: remapContent(message.content) }; } return message; }); } function remapProviderToolNamesInText( text: string, providerToolName: (name: string) => string, ): string { return text.replace(/\btool_search\b/gu, providerToolName(TOOL_SEARCH_NAME)); } 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 { 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, ): unknown { switch (tool.kind) { case 'openai-apply-patch': return openAiApplyPatchProviderTool; 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 normalizeProviderFailure(error: unknown): ModelFailure { if (isModelFailure(error)) return error; const summary = providerFailureSummary(error); const failure = normalizeModelFailure(error); return { ...failure, ...(summary?.code !== undefined ? { code: summary.code } : {}), ...(failure.kind === 'unknown' && summary !== undefined ? { message: summary.message } : {}), }; } 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 'ProviderCapacity': return 'provider_capacity'; 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_capacity': return 'ProviderCapacity'; 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((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((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; }