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),
+ }
+}