refactor(openai-shim): extract response adapters (#2072)

* refactor(openai-shim): extract response adapters

* test(openai-shim): cover response adapter stream wrappers

Add focused regression tests for geminiSseToAnthropic and
openaiStreamToAnthropic through the responseAdapters facade wiring.

Validated with: bun test src/services/api/openaiShim/responseAdapters.test.ts

* test(openai-shim): assert Gemini tool-use stream blocks in adapter test

Extend the responseAdapters geminiSseToAnthropic wrapper test to cover
tool_use content_block_start, input_json_delta, and content_block_stop.
Remove stale post-extraction imports from the openaiShim facade.

* test(openai-shim): cover facade parser re-exports

Add a focused openaiShim.test.ts case that imports parseTextToolCalls and
parseXmlToolCalls through the public facade and asserts shared sequencing.
This commit is contained in:
JATMN
2026-08-07 23:32:31 +08:00
committed by GitHub
parent c327805e1d
commit deb91941e1
4 changed files with 428 additions and 247 deletions
+8 -6
View File
@@ -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('<tool_call>{"name":"from_xml","arguments":{}}</tool_call>')
const xml = parseXmlToolCalls(
'<tool_call>{"name":"from_xml","arguments":{}}</tool_call>',
)
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)
})
// ---------------------------------------------------------------------------
+17 -241
View File
@@ -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<string, unknown>
tool_calls?: Array<{
index: number
id?: string
type?: string
function?: { name?: string; arguments?: string }
extra_content?: Record<string, unknown>
}>
}
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<AnthropicUsage> | undefined {
return convertOpenAIStreamUsage(usage as Record<string, unknown> | 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<AnthropicStreamEvent> {
yield* parseAnthropicSsePassthrough<AnthropicStreamEvent>(
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<AnthropicStreamEvent> {
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<AnthropicStreamEvent> {
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<string, unknown>,
model: string,
) {
const content: Array<Record<string, unknown>> = []
let hasToolUse = false
const candidates = data.candidates as Array<Record<string, unknown>> | undefined
const candidate = candidates?.[0]
const candidateContent = candidate?.content as { parts?: Array<Record<string, unknown>> } | 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<string, number> | undefined
const usage = buildAnthropicUsageFromRawUsage({
input_tokens: usageMetadata?.promptTokenCount ?? 0,
output_tokens: (usageMetadata?.candidatesTokenCount ?? 0) + (usageMetadata?.thoughtsTokenCount ?? 0),
} as unknown as Record<string, unknown>)
return {
id: makeMessageId(),
type: 'message',
role: 'assistant',
content,
model,
stop_reason: stopReason,
stop_sequence: null,
usage,
}
return convertGeminiToAnthropicResponse(data, model)
}
}
@@ -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<Uint8Array>({
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<AnthropicStreamEvent>,
): Promise<AnthropicStreamEvent[]> {
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(
'<tool_call>{"name":"from_xml","arguments":{}}</tool_call>',
)
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' })
})
@@ -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<AnthropicStreamEvent> {
yield* parseAnthropicSsePassthrough<AnthropicStreamEvent>(
response,
signal,
(message, options) => options?.level
? logForDebugging(message, { level: options.level })
: logForDebugging(message),
)
}
export async function* geminiSseToAnthropic(
response: Response,
model: string,
signal?: AbortSignal,
): AsyncGenerator<AnthropicStreamEvent> {
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<AnthropicStreamEvent> {
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<string, unknown>,
model: string,
) {
const content: Array<Record<string, unknown>> = []
let hasToolUse = false
const candidates = data.candidates as Array<Record<string, unknown>> | undefined
const candidate = candidates?.[0]
const candidateContent = candidate?.content as {
parts?: Array<Record<string, unknown>>
} | 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<string, number> | 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<string, unknown>),
}
}