mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
@@ -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
@@ -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>),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user