blob: d93fe05039a5f9bcb2100c3cf24bb2a1b345ab0e [file]
/*
* 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 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,
ModelToolSet,
ToolCallPart,
} from './model-protocol.js';
export type {
NormalizedUsage,
RawUsageFields,
ModelStreamEvent,
ModelStreamResult,
ModelStepOutcome,
ModelFinishReason,
ModelFailure,
ModelToolSet,
} from './model-protocol.js';
import { resolveModelRuntime, type ResolvedModelRuntime } from './model-runtime.js';
import {
plaintextResponsesReasoningProviderOptions,
safePlaintextResponsesReasoningItemId,
} from './responses-reasoning-state.js';
import { classifyError, providerModelFailure } from './provider-error-classification.js';
import {
withProviderStreamTracking,
type ProviderRequestTracker,
type ProviderStreamResult,
} 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;
resolvedRuntime?: ResolvedModelRuntime;
providerOptions?: Record<string, unknown>;
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<RepairableAiSdkToolCall | null>;
/** 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;
}
export class ModelAdapter {
private readonly runtime: ResolvedModelRuntime;
private readonly openAiChatReasoningTransportState: OpenAiChatReasoningTransportState;
private readonly openAiResponsesTransportState: OpenAiResponsesTransportState;
constructor(private readonly input: ModelAdapterInput) {
this.runtime = input.resolvedRuntime ?? 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({
sessionId: this.input.sessionId,
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,
);
let settleAccounting: ((outcome: ModelStepOutcome) => Promise<void>) | undefined;
const terminalModel = withProviderFinishBoundary(input.model, wrapLanguageModel);
const trackedModel = input.providerRequestTracker
? withProviderStreamTracking({
model: terminalModel,
wrapLanguageModel,
tracker: input.providerRequestTracker,
abortSignal: input.abortSignal,
onAttempt: (settle) => {
settleAccounting = settle;
},
...(input.historyCompactBoundary
? { historyCompactBoundary: input.historyCompactBoundary }
: {}),
})
: terminalModel;
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,
// 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,
settleAccounting: async (outcome) => {
await settleAccounting?.(outcome);
},
});
}
/**
* 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;
settleAccounting: (outcome: ModelStepOutcome) => Promise<void>;
},
): 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<ModelStepOutcome>((resolve) => {
settleOutcome = resolve;
});
const events: AsyncIterable<ModelStreamEvent> = {
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<AiSdkStreamChunk>) {
onStreamActivity();
if (
chunk.type === 'finish' ||
chunk.type === 'finish-step' ||
chunk.type === 'step-finish'
) {
streamedRawFinishReason =
rawFinishReasonString(chunk.rawFinishReason) ?? streamedRawFinishReason;
streamedFinishReason = chunkFinishReason(chunk) ?? streamedFinishReason;
if (chunk.type === 'finish') sawFinish = true;
continue;
}
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;
yield event;
}
}
} catch (error) {
if (!failure) {
failure = normalizeProviderFailure(error);
yield { kind: 'error', failure };
}
} finally {
if (continuation.abortSignal.aborted) {
failure = normalizeProviderFailure(continuation.abortSignal.reason);
}
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,
});
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,
});
}
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 {
try {
await continuation.settleAccounting(settled);
} 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 = normalizeProviderFailure(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'
? { reason: failure.kind }
: {}),
message: failure.message,
};
}
normalizeFailure(error: unknown): ModelFailure {
return normalizeProviderFailure(error);
}
classifyError(error: unknown): ModelFailureKind {
if (isModelFailure(error)) return 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;
}
export function settleModelStepOutcome(evidence: ModelStepSettlementEvidence): ModelStepOutcome {
const { aborted, failure, sawFinish, finishReason, rawFinishReason, usage } = evidence;
if (aborted || failure?.kind === 'abort') {
return failedStepOutcome(
failure ??
normalizeProviderFailure(Object.assign(new Error('aborted'), { name: 'AbortError' })),
usage,
);
}
if (failure) {
return failedStepOutcome(failure, usage);
}
if (!sawFinish || finishReason === 'other' || finishReason === 'unknown') {
return failedStepOutcome(
modelStepFailure(
'stream_truncated',
`Provider stream ended without finishing (${finishReason})`,
),
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, usage);
}
return {
kind: 'completed',
finishReason,
...(usage ? { usage } : {}),
continuation: 'none',
};
}
function modelStepFailure(kind: ModelFailureKind, message: string): ModelFailure {
return { type: 'model_failure', kind, message, retryable: kind === 'stream_truncated' };
}
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(
failure: ModelFailure,
usage?: NormalizedUsage,
): Exclude<ModelStepOutcome, { kind: 'completed' }> {
return {
kind: 'failed',
failure,
...(usage ? { usage } : {}),
continuation: 'none',
};
}
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';
const requestedBudget = connection.modelOverrides?.[modelId]?.maxOutputTokens;
if (requestedBudget === undefined && !anthropicMessages && !kimiOpenAiChat) return undefined;
const capacity =
connection.models?.find((model) => model.id === modelId)?.maxOutputTokens ??
lookupModelMetadata(connection.providerType, modelId).maxOutputTokens;
const wireOutputLimit =
requestedBudget === undefined
? capacity
: capacity === undefined
? requestedBudget
: Math.min(requestedBudget, capacity);
if (wireOutputLimit === undefined) return undefined;
const outputTokens = anthropicMessages
? wireOutputLimit - fixedAnthropicThinkingBudget(providerOptions)
: wireOutputLimit;
if (outputTokens <= 0) {
throw new Error(
'Output budget must exceed the fixed thinking budget. Increase the output limit or reduce thinking.',
);
}
return outputTokens;
}
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<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;
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<AiSdkStreamChunk>;
usage: Promise<AiSdkUsageLike | undefined>;
finishReason: Promise<unknown>;
response: PromiseLike<{
id: string;
}>;
}
/**
* A provider `finish` part is the LanguageModel stream's terminal semantic
* boundary. Expose EOF at that boundary so the SDK can flush its public
* finish/usage promises even when the transport keeps the connection open.
*/
function withProviderFinishBoundary(
model: unknown,
wrapLanguageModel: (input: Record<string, unknown>) => unknown,
): unknown {
return wrapLanguageModel({
model,
middleware: {
wrapStream: async ({
doStream,
}: {
doStream: () => PromiseLike<ProviderStreamResult>;
}): Promise<ProviderStreamResult> => {
const result = await doStream();
return {
...result,
stream: result.stream.pipeThrough(
new TransformStream<unknown, unknown>({
transform(part, controller) {
controller.enqueue(part);
if (
part !== null &&
typeof part === 'object' &&
!Array.isArray(part) &&
(part as { type?: unknown }).type === 'finish'
) {
controller.terminate();
}
},
}),
),
};
},
},
});
}
/**
* 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<ModelMessage['providerOptions']> | 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<ModelMessage['providerOptions']>)
: undefined;
}
function openAiResponsesReasoningProviderOptionsFromChunk(
chunk: AiSdkStreamChunk,
runtime: ResolvedModelRuntime,
): NonNullable<ModelMessage['providerOptions']> | 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<string, unknown>)[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<string, unknown>)[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': {
const providerOptions =
chunk.providerMetadata && typeof chunk.providerMetadata === 'object'
? (chunk.providerMetadata as NonNullable<ModelMessage['providerOptions']>)
: undefined;
const providerItemBoundary =
runtime !== undefined &&
hasOpenAiResponsesAdapter(runtime) &&
providerOptions !== undefined;
return [
{
kind: 'text-start',
...(providerItemBoundary ? { providerItemBoundary: true } : {}),
},
];
}
case 'text-delta': {
const text = chunk.text ?? chunk.textDelta ?? chunk.delta ?? '';
return text ? [{ kind: 'text', text }] : [];
}
case 'text-end': {
const providerOptions =
chunk.providerMetadata && typeof chunk.providerMetadata === 'object'
? (chunk.providerMetadata as NonNullable<ModelMessage['providerOptions']>)
: undefined;
const providerItemBoundary =
runtime !== undefined &&
hasOpenAiResponsesAdapter(runtime) &&
providerOptions !== undefined;
return [
{
kind: 'text-end',
...(providerOptions !== undefined ? { providerOptions } : {}),
...(providerItemBoundary ? { providerItemBoundary: true } : {}),
},
];
}
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 [{ kind: 'tool-input', providerExecuted: chunk.providerExecuted === true }];
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 = <T extends { type: string }>(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<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-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 normalizeProviderFailure(error: unknown): ModelFailure {
return isModelFailure(error) ? error : providerModelFailure(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'
);
}
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;
}