diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 1e1db35c3..6a024b7ad 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -3764,15 +3764,17 @@ test('the OpenAI shim façade creates independent client instances', () => { }) // openaiShim test extraction seam 112 end -test('raw-text and XML fallback tool calls use one unique sequence', () => { +test('facade parseTextToolCalls and parseXmlToolCalls share adapter sequencing', () => { const text = parseTextToolCalls('{"name":"from_text","arguments":{}}') - const xml = parseXmlToolCalls('{"name":"from_xml","arguments":{}}') + const xml = parseXmlToolCalls( + '{"name":"from_xml","arguments":{}}', + ) + expect(text.calls[0]?.id).toMatch(/^ollama_tc_\d+$/) expect(xml.calls[0]?.id).toMatch(/^xml_tc_\d+$/) - const textNum = Number(text.calls[0]?.id?.replace(/^\D+/, '')) - const xmlNum = Number(xml.calls[0]?.id?.replace(/^\D+/, '')) - // Same session counter: the second mint must be exactly one greater than the first. - expect(xmlNum).toBe(textNum + 1) + const textSequence = Number(text.calls[0]?.id?.replace(/^\D+/, '')) + const xmlSequence = Number(xml.calls[0]?.id?.replace(/^\D+/, '')) + expect(xmlSequence).toBe(textSequence + 1) }) // --------------------------------------------------------------------------- diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index d64b84b58..00bd14627 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -42,7 +42,7 @@ import { refreshCodexAccessTokenIfNeeded, } from '../../utils/codexCredentials.js' import { logForDebugging } from '../../utils/debug.js' -import { anthropicSsePassthrough as parseAnthropicSsePassthrough, createReaderCanceller, createStreamAbortError, getStreamIdleTimeoutMs, readWithIdleTimeout, StreamIdleTimeoutError, throwIfStreamAborted } from './openaiShim/streamControl.js' +import { createStreamAbortError, getStreamIdleTimeoutMs, readWithIdleTimeout, StreamIdleTimeoutError } from './openaiShim/streamControl.js' export { getStreamIdleTimeoutMs } from './openaiShim/streamControl.js' import { isBareMode, isEnvTruthy } from '../../utils/envUtils.js' import { @@ -68,10 +68,6 @@ import { resolveRouteCredentialValue, } from '../../integrations/routeMetadata.js' import { getSessionId } from '../../bootstrap/state.js' -import { - createThinkTagFilter, - stripThinkTags, -} from './thinkTagSanitizer.js' import { codexStreamToAnthropic, collectCodexCompletedResponse, @@ -80,19 +76,21 @@ import { convertToolsToResponsesTools, performCodexRequest, type AnthropicStreamEvent, - type AnthropicUsage, type ShimCreateParams, } from './codexShim.js' import { createRequestBodyPlanner, hydrateOpenAIShimCompatibilityEnv as hydrateRequestPlanningEnv, } from './openaiShim/requestPlanner.js' -import { buildAnthropicUsageFromRawUsage } from './cacheMetrics.js' import { - convertOpenAIStreamUsage, - openaiStreamToAnthropic as convertOpenAIStream, -} from './openaiShim/streamConversion.js' -import { geminiSseToAnthropic as convertGeminiStream } from './openaiShim/geminiStreamConversion.js' + anthropicSsePassthrough, + convertGeminiToAnthropicResponse, + convertNonStreamingResponseToAnthropicMessage, + geminiSseToAnthropic, + makeMessageId, + openaiStreamToAnthropic as convertOpenAIResponseStream, +} from './openaiShim/responseAdapters.js' +export { parseTextToolCalls, parseXmlToolCalls } from './openaiShim/responseAdapters.js' import { compressToolHistory } from './compressToolHistory.js' import { createClassifiedTransportError, @@ -127,10 +125,6 @@ import { markOpenAIRequestNonReplayable, } from './openaiErrorClassification.js' import { redactSecretValueForDisplay, type SecretValueSource } from '../../utils/providerProfile.js' -import { - normalizeToolArguments, - hasToolFieldMapping, -} from './toolArgumentNormalization.js' import { logApiCallStart, logApiCallEnd } from '../../utils/requestLogging.js' import { createStreamState, @@ -139,13 +133,6 @@ import { } from '../../utils/streamingOptimizer.js' import { stableStringifyJson } from '../../utils/stableStringify.js' import { - findXmlToolCallOpener as findXmlToolCallOpenerModule, - isHy3Model as isHy3ModelModule, - parseXmlToolCalls as parseXmlToolCallsModule, - trailingXmlOpenerPrefixLen as trailingXmlOpenerPrefixLenModule, -} from './openaiShim/xmlToolCallParsing.js' -import { - convertNonStreamingResponseToAnthropicMessage as convertResponseToAnthropicMessage, type NonStreamingOpenAIResponse, } from './openaiShim/responseConversion.js' import { @@ -179,17 +166,6 @@ import { convertMessages as convertAnthropicMessages, convertSystemPrompt as convertSystemPromptImpl, } from './openaiShim/messageConversion.js' -import { - JSON_REPAIR_SUFFIXES, - couldBeRawToolCallsRequestedPrefix, - extractBalancedJson, - parseRawToolCallsRequestedText, - parseTextToolCalls as parseTextToolCallsModule, - repairPossiblyTruncatedObjectJson, - stripRanges, - type ParsedRawToolCall, - type ParsedTextToolCall, -} from './openaiShim/rawToolCallParsing.js' import { convertTools as convertToolsModule, normalizeSchemaForOpenAI as normalizeSchemaForOpenAIModule, @@ -414,142 +390,6 @@ function convertTools( // Streaming: OpenAI SSE → Anthropic stream events // --------------------------------------------------------------------------- -interface OpenAIStreamChunk { - id: string - object: string - model: string - choices: Array<{ - index: number - delta: { - role?: string - content?: string | null - reasoning_content?: string | null - extra_content?: Record - tool_calls?: Array<{ - index: number - id?: string - type?: string - function?: { name?: string; arguments?: string } - extra_content?: Record - }> - } - finish_reason: string | null - }> - usage?: { - prompt_tokens?: number - completion_tokens?: number - total_tokens?: number - prompt_tokens_details?: { - cached_tokens?: number - } - } -} - -function makeMessageId(): string { - return `msg_${crypto.randomUUID().replace(/-/g, '')}` -} - -function convertChunkUsage(usage: OpenAIStreamChunk['usage'] | undefined): Partial | undefined { - return convertOpenAIStreamUsage(usage as Record | undefined) -} - -export function parseTextToolCalls(text: string): { - calls: ParsedTextToolCall[] - toolCallRanges: Array<[number, number]> -} { - return parseTextToolCallsModule(text, nextTextToolCallSequence) -} - -// Shared façade state keeps raw-text and XML fallback IDs unique per session. -let textToolCallSequence = 0 - -function nextTextToolCallSequence(): number { - return ++textToolCallSequence -} - -// --------------------------------------------------------------------------- -// XML tool parsing façade. Dialect handling lives in xmlToolCallParsing.ts. -// --------------------------------------------------------------------------- - -function findXmlToolCallOpener(text: string, allowHy3: boolean): number { - return findXmlToolCallOpenerModule(text, allowHy3) -} - -function isHy3Model(model: string): boolean { - return isHy3ModelModule(model) -} - -export function parseXmlToolCalls(text: string, allowHy3 = false) { - return parseXmlToolCallsModule(text, allowHy3, nextTextToolCallSequence) -} - -function trailingXmlOpenerPrefixLen(text: string, allowHy3: boolean): number { - return trailingXmlOpenerPrefixLenModule(text, allowHy3) -} - -// The streaming finalize path buffers from this opener onward so the raw XML -// is never surfaced as text before extraction. -/** - * Async generator that transforms an OpenAI SSE stream into - * Anthropic-format BetaRawMessageStreamEvent objects. - */ -/** - * Passthrough for Anthropic Messages API SSE streams. - * The response events are already in AnthropicStreamEvent format — - * we just parse the SSE frames and yield them directly. - */ -async function* anthropicSsePassthrough( - response: Response, - _model: string, - signal?: AbortSignal, -): AsyncGenerator { - yield* parseAnthropicSsePassthrough( - response, - signal, - (message, options) => options?.level - ? logForDebugging(message, { level: options.level }) - : logForDebugging(message), - ) -} - -/** - * Transforms Google AI SDK SSE stream into Anthropic-format stream events. - * Google AI SDK yields frames with { candidates: [{ content: { role, parts } }] }. - */ -async function* geminiSseToAnthropic( - response: Response, - model: string, - signal?: AbortSignal, -): AsyncGenerator { - yield* convertGeminiStream(response, model, signal, { - createReaderCanceller, - createStreamAbortError, - getStreamIdleTimeoutMs, - makeMessageId, - readWithIdleTimeout, - throwIfStreamAborted, - }) -} -// Extraction seam: Gemini streaming | completed response conversion. - -function convertNonStreamingResponseToAnthropicMessage( - data: NonStreamingOpenAIResponse, - model: string, -) { - return convertResponseToAnthropicMessage(data, model, { - makeMessageId, - buildUsage: usage => buildAnthropicUsageFromRawUsage(usage), - stripThinkTags, - parseXmlToolCalls, - isHy3Model, - stripRanges, - parseRawToolCalls: parseRawToolCallsRequestedText, - normalizeToolArguments, - getGeminiThoughtSignature: geminiThoughtSignatureFromExtraContent, - mergeGeminiThoughtSignature, - }) -} - import { headersWithRequestUrl as buildHeadersWithRequestUrl } from './openaiShim/clientDispatch.js' function headersWithRequestUrl(headers: Headers, requestUrl?: string): Headers { @@ -565,31 +405,14 @@ async function* openaiStreamToAnthropic( isOllama = false, requestUrl?: string, ): AsyncGenerator { - yield* convertOpenAIStream(response, model, signal, isOllama, requestUrl, { - convertNonStreamingResponseToAnthropicMessage: (data, streamModel) => - convertNonStreamingResponseToAnthropicMessage( - data as NonStreamingOpenAIResponse, - streamModel, - ), - couldBeRawToolCallsRequestedPrefix, - createReaderCanceller, - createStreamAbortError, - findXmlToolCallOpener, - geminiThoughtSignatureFromExtraContent, - getStreamIdleTimeoutMs, + yield* convertOpenAIResponseStream( + response, + model, + signal, + isOllama, + requestUrl, headersWithRequestUrl, - isHy3Model, - makeMessageId, - mergeGeminiThoughtSignature, - parseRawToolCallsRequestedText, - parseTextToolCalls, - parseXmlToolCalls, - readWithIdleTimeout, - repairPossiblyTruncatedObjectJson, - stripRanges, - throwIfStreamAborted, - trailingXmlOpenerPrefixLen, - }) + ) } @@ -1113,54 +936,7 @@ class OpenAIShimMessages { data: Record, model: string, ) { - const content: Array> = [] - let hasToolUse = false - const candidates = data.candidates as Array> | undefined - const candidate = candidates?.[0] - const candidateContent = candidate?.content as { parts?: Array> } | undefined - - if (candidateContent?.parts) { - for (const part of candidateContent.parts) { - const text = part.text as string | undefined - if (text) { - content.push({ type: 'text', text }) - } - const fc = part.functionCall as { name?: string; args?: unknown } | undefined - if (fc?.name) { - hasToolUse = true - content.push({ - type: 'tool_use', - id: `toolu_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`, - name: fc.name, - input: fc.args ?? {}, - }) - } - } - } - - const stopReason = - hasToolUse - ? 'tool_use' - : candidate?.finishReason === 'MAX_TOKENS' - ? 'max_tokens' - : 'end_turn' - - const usageMetadata = data.usageMetadata as Record | undefined - const usage = buildAnthropicUsageFromRawUsage({ - input_tokens: usageMetadata?.promptTokenCount ?? 0, - output_tokens: (usageMetadata?.candidatesTokenCount ?? 0) + (usageMetadata?.thoughtsTokenCount ?? 0), - } as unknown as Record) - - return { - id: makeMessageId(), - type: 'message', - role: 'assistant', - content, - model, - stop_reason: stopReason, - stop_sequence: null, - usage, - } + return convertGeminiToAnthropicResponse(data, model) } } diff --git a/src/services/api/openaiShim/responseAdapters.test.ts b/src/services/api/openaiShim/responseAdapters.test.ts new file mode 100644 index 000000000..b61dbaf90 --- /dev/null +++ b/src/services/api/openaiShim/responseAdapters.test.ts @@ -0,0 +1,195 @@ +import { expect, test } from 'bun:test' +import type { AnthropicStreamEvent } from '../codexShim.js' +import { + convertGeminiToAnthropicResponse, + geminiSseToAnthropic, + openaiStreamToAnthropic, + parseTextToolCalls, + parseXmlToolCalls, +} from './responseAdapters.js' + +function makeSseResponse(frames: unknown[]): Response { + const encoder = new TextEncoder() + return new Response( + new ReadableStream({ + start(controller) { + for (const frame of frames) { + const data = frame === '[DONE]' ? frame : JSON.stringify(frame) + controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + } + controller.close() + }, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ) +} + +async function collectStreamEvents( + generator: AsyncGenerator, +): Promise { + const events: AnthropicStreamEvent[] = [] + for await (const event of generator) events.push(event) + return events +} + +test('raw-text and XML fallback tool calls use one unique sequence', () => { + const text = parseTextToolCalls('{"name":"from_text","arguments":{}}') + const xml = parseXmlToolCalls( + '{"name":"from_xml","arguments":{}}', + ) + + expect(text.calls[0]?.id).toMatch(/^ollama_tc_\d+$/) + expect(xml.calls[0]?.id).toMatch(/^xml_tc_\d+$/) + const textSequence = Number(text.calls[0]?.id?.replace(/^\D+/, '')) + const xmlSequence = Number(xml.calls[0]?.id?.replace(/^\D+/, '')) + expect(xmlSequence).toBe(textSequence + 1) +}) + +test('converts Gemini text and function calls into an Anthropic message', () => { + const message = convertGeminiToAnthropicResponse({ + candidates: [{ + content: { + parts: [ + { text: 'Checking the workspace.' }, + { functionCall: { name: 'Read', args: { file_path: 'a.ts' } } }, + ], + }, + finishReason: 'STOP', + }], + usageMetadata: { + promptTokenCount: 5, + candidatesTokenCount: 3, + thoughtsTokenCount: 2, + }, + }, 'gemini-test') + + expect(message).toMatchObject({ + type: 'message', + role: 'assistant', + model: 'gemini-test', + stop_reason: 'tool_use', + content: [ + { type: 'text', text: 'Checking the workspace.' }, + { type: 'tool_use', name: 'Read', input: { file_path: 'a.ts' } }, + ], + usage: { input_tokens: 5, output_tokens: 5 }, + }) +}) + +test('maps Gemini max-token completion without tool calls', () => { + const message = convertGeminiToAnthropicResponse({ + candidates: [{ + content: { parts: [{ text: 'partial' }] }, + finishReason: 'MAX_TOKENS', + }], + }, 'gemini-test') + + expect(message.stop_reason).toBe('max_tokens') + expect(message.content).toEqual([{ type: 'text', text: 'partial' }]) +}) + +test('geminiSseToAnthropic wrapper emits content, usage, and terminal stop', async () => { + const events = await collectStreamEvents(geminiSseToAnthropic( + makeSseResponse([ + { + usageMetadata: { + promptTokenCount: 4, + candidatesTokenCount: 2, + thoughtsTokenCount: 1, + }, + candidates: [{ + content: { + parts: [ + { text: 'Inspecting.' }, + { functionCall: { name: 'Read', args: { file_path: 'a.ts' } } }, + ], + }, + finishReason: 'STOP', + }], + }, + '[DONE]', + ]), + 'gemini-test', + )) + + expect(events[0]).toMatchObject({ + type: 'message_start', + message: { model: 'gemini-test' }, + }) + expect(events.some(event => + event.type === 'content_block_delta' && + (event.delta as { text?: string })?.text === 'Inspecting.', + )).toBe(true) + + const toolStartIndex = events.findIndex(event => + event.type === 'content_block_start' && + (event.content_block as { type?: string; name?: string })?.type === 'tool_use' && + (event.content_block as { name?: string })?.name === 'Read', + ) + expect(toolStartIndex).toBeGreaterThan(-1) + expect(events[toolStartIndex + 1]).toMatchObject({ + type: 'content_block_delta', + delta: { + type: 'input_json_delta', + partial_json: '{"file_path":"a.ts"}', + }, + }) + expect(events[toolStartIndex + 2]).toEqual({ + type: 'content_block_stop', + index: (events[toolStartIndex] as { index: number }).index, + }) + + expect(events.at(-2)).toMatchObject({ + type: 'message_delta', + delta: { stop_reason: 'tool_use' }, + usage: { + input_tokens: 4, + output_tokens: 3, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }) + expect(events.at(-1)).toEqual({ type: 'message_stop' }) +}) + +test('openaiStreamToAnthropic wrapper emits text, usage, and terminal stop', async () => { + const events = await collectStreamEvents(openaiStreamToAnthropic( + makeSseResponse([ + { + choices: [{ + index: 0, + delta: { content: 'hello' }, + finish_reason: null, + }], + }, + { + choices: [{ + index: 0, + delta: {}, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 7, completion_tokens: 2 }, + }, + '[DONE]', + ]), + 'test-model', + )) + + expect(events.map(event => event.type)).toContain('message_start') + expect(events).toContainEqual({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'hello' }, + }) + expect(events.at(-2)).toMatchObject({ + type: 'message_delta', + delta: { stop_reason: 'end_turn', stop_sequence: null }, + usage: { + input_tokens: 7, + output_tokens: 2, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }) + expect(events.at(-1)).toEqual({ type: 'message_stop' }) +}) diff --git a/src/services/api/openaiShim/responseAdapters.ts b/src/services/api/openaiShim/responseAdapters.ts new file mode 100644 index 000000000..fc626183f --- /dev/null +++ b/src/services/api/openaiShim/responseAdapters.ts @@ -0,0 +1,208 @@ +import { logForDebugging } from '../../../utils/debug.js' +import { buildAnthropicUsageFromRawUsage } from '../cacheMetrics.js' +import { + type AnthropicStreamEvent, +} from '../codexShim.js' +import { normalizeToolArguments } from '../toolArgumentNormalization.js' +import { stripThinkTags } from '../thinkTagSanitizer.js' +import { + geminiThoughtSignatureFromExtraContent, + mergeGeminiThoughtSignature, +} from './providerCompatibility.js' +import { + couldBeRawToolCallsRequestedPrefix, + parseRawToolCallsRequestedText, + parseTextToolCalls as parseTextToolCallsModule, + repairPossiblyTruncatedObjectJson, + stripRanges, + type ParsedTextToolCall, +} from './rawToolCallParsing.js' +import { + convertNonStreamingResponseToAnthropicMessage as convertResponseToAnthropicMessage, + type NonStreamingOpenAIResponse, +} from './responseConversion.js' +import { openaiStreamToAnthropic as convertOpenAIStream } from './streamConversion.js' +import { geminiSseToAnthropic as convertGeminiStream } from './geminiStreamConversion.js' +import { + anthropicSsePassthrough as parseAnthropicSsePassthrough, + createReaderCanceller, + createStreamAbortError, + getStreamIdleTimeoutMs, + readWithIdleTimeout, + throwIfStreamAborted, +} from './streamControl.js' +import { + findXmlToolCallOpener as findXmlToolCallOpenerModule, + isHy3Model as isHy3ModelModule, + parseXmlToolCalls as parseXmlToolCallsModule, + trailingXmlOpenerPrefixLen as trailingXmlOpenerPrefixLenModule, +} from './xmlToolCallParsing.js' + +export function makeMessageId(): string { + return `msg_${crypto.randomUUID().replace(/-/g, '')}` +} + +// Raw-text and XML fallbacks share one sequence so their generated IDs cannot +// collide when both syntaxes occur during the same process lifetime. +let textToolCallSequence = 0 + +function nextTextToolCallSequence(): number { + return ++textToolCallSequence +} + +export function parseTextToolCalls(text: string): { + calls: ParsedTextToolCall[] + toolCallRanges: Array<[number, number]> +} { + return parseTextToolCallsModule(text, nextTextToolCallSequence) +} + +function findXmlToolCallOpener(text: string, allowHy3: boolean): number { + return findXmlToolCallOpenerModule(text, allowHy3) +} + +function isHy3Model(model: string): boolean { + return isHy3ModelModule(model) +} + +export function parseXmlToolCalls(text: string, allowHy3 = false) { + return parseXmlToolCallsModule(text, allowHy3, nextTextToolCallSequence) +} + +function trailingXmlOpenerPrefixLen(text: string, allowHy3: boolean): number { + return trailingXmlOpenerPrefixLenModule(text, allowHy3) +} + +export async function* anthropicSsePassthrough( + response: Response, + _model: string, + signal?: AbortSignal, +): AsyncGenerator { + yield* parseAnthropicSsePassthrough( + response, + signal, + (message, options) => options?.level + ? logForDebugging(message, { level: options.level }) + : logForDebugging(message), + ) +} + +export async function* geminiSseToAnthropic( + response: Response, + model: string, + signal?: AbortSignal, +): AsyncGenerator { + yield* convertGeminiStream(response, model, signal, { + createReaderCanceller, + createStreamAbortError, + getStreamIdleTimeoutMs, + makeMessageId, + readWithIdleTimeout, + throwIfStreamAborted, + }) +} + +export function convertNonStreamingResponseToAnthropicMessage( + data: NonStreamingOpenAIResponse, + model: string, +) { + return convertResponseToAnthropicMessage(data, model, { + makeMessageId, + buildUsage: usage => buildAnthropicUsageFromRawUsage(usage), + stripThinkTags, + parseXmlToolCalls, + isHy3Model, + stripRanges, + parseRawToolCalls: parseRawToolCallsRequestedText, + normalizeToolArguments, + getGeminiThoughtSignature: geminiThoughtSignatureFromExtraContent, + mergeGeminiThoughtSignature, + }) +} + +export async function* openaiStreamToAnthropic( + response: Response, + model: string, + signal?: AbortSignal, + isOllama = false, + requestUrl?: string, + headersWithRequestUrl?: (headers: Headers, requestUrl?: string) => Headers, +): AsyncGenerator { + yield* convertOpenAIStream(response, model, signal, isOllama, requestUrl, { + convertNonStreamingResponseToAnthropicMessage: (data, streamModel) => + convertNonStreamingResponseToAnthropicMessage( + data as NonStreamingOpenAIResponse, + streamModel, + ), + couldBeRawToolCallsRequestedPrefix, + createReaderCanceller, + createStreamAbortError, + findXmlToolCallOpener, + geminiThoughtSignatureFromExtraContent, + getStreamIdleTimeoutMs, + headersWithRequestUrl: headersWithRequestUrl ?? ((headers) => headers), + isHy3Model, + makeMessageId, + mergeGeminiThoughtSignature, + parseRawToolCallsRequestedText, + parseTextToolCalls, + parseXmlToolCalls, + readWithIdleTimeout, + repairPossiblyTruncatedObjectJson, + stripRanges, + throwIfStreamAborted, + trailingXmlOpenerPrefixLen, + }) +} + +export function convertGeminiToAnthropicResponse( + data: Record, + model: string, +) { + const content: Array> = [] + let hasToolUse = false + const candidates = data.candidates as Array> | undefined + const candidate = candidates?.[0] + const candidateContent = candidate?.content as { + parts?: Array> + } | undefined + + for (const part of candidateContent?.parts ?? []) { + const text = part.text as string | undefined + if (text) content.push({ type: 'text', text }) + const functionCall = part.functionCall as { + name?: string + args?: unknown + } | undefined + if (functionCall?.name) { + hasToolUse = true + content.push({ + type: 'tool_use', + id: `toolu_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`, + name: functionCall.name, + input: functionCall.args ?? {}, + }) + } + } + + const usageMetadata = data.usageMetadata as Record | undefined + return { + id: makeMessageId(), + type: 'message', + role: 'assistant', + content, + model, + stop_reason: hasToolUse + ? 'tool_use' + : candidate?.finishReason === 'MAX_TOKENS' + ? 'max_tokens' + : 'end_turn', + stop_sequence: null, + usage: buildAnthropicUsageFromRawUsage({ + input_tokens: usageMetadata?.promptTokenCount ?? 0, + output_tokens: + (usageMetadata?.candidatesTokenCount ?? 0) + + (usageMetadata?.thoughtsTokenCount ?? 0), + } as unknown as Record), + } +}