| |
| |
| |
|
|
| import { describe, test, expect } from 'bun:test' |
| import { openaiChatStreamToAnthropic } from '../proxy/streaming/openaiChatStreamToAnthropic.js' |
| import { openaiResponsesStreamToAnthropic } from '../proxy/streaming/openaiResponsesStreamToAnthropic.js' |
|
|
| |
|
|
| function makeStream(chunks: string[]): ReadableStream<Uint8Array> { |
| const encoder = new TextEncoder() |
| return new ReadableStream({ |
| start(controller) { |
| for (const chunk of chunks) { |
| controller.enqueue(encoder.encode(chunk)) |
| } |
| controller.close() |
| }, |
| }) |
| } |
|
|
| async function collectSse(stream: ReadableStream<Uint8Array>): Promise<Array<{ event: string; data: Record<string, unknown> }>> { |
| const decoder = new TextDecoder() |
| const reader = stream.getReader() |
| let text = '' |
| while (true) { |
| const { done, value } = await reader.read() |
| if (done) break |
| text += decoder.decode(value, { stream: true }) |
| } |
|
|
| const events: Array<{ event: string; data: Record<string, unknown> }> = [] |
| const blocks = text.split('\n\n').filter(Boolean) |
| for (const block of blocks) { |
| const lines = block.split('\n') |
| let event = '' |
| let data = '' |
| for (const line of lines) { |
| if (line.startsWith('event: ')) event = line.slice(7) |
| if (line.startsWith('data: ')) data = line.slice(6) |
| } |
| if (event && data) { |
| try { |
| events.push({ event, data: JSON.parse(data) }) |
| } catch { |
| |
| } |
| } |
| } |
| return events |
| } |
|
|
| |
|
|
| describe('openaiChatStreamToAnthropic', () => { |
| test('basic text streaming', async () => { |
| const sseChunks = [ |
| 'data: {"id":"c1","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c1","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c1","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"content":" world"},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c1","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', |
| 'data: [DONE]\n\n', |
| ] |
|
|
| const upstream = makeStream(sseChunks) |
| const anthropicStream = openaiChatStreamToAnthropic(upstream, 'gpt-4') |
| const events = await collectSse(anthropicStream) |
|
|
| |
| const eventTypes = events.map((e) => e.event) |
| expect(eventTypes[0]).toBe('message_start') |
| expect(eventTypes).toContain('content_block_start') |
| expect(eventTypes).toContain('content_block_delta') |
| expect(eventTypes).toContain('message_delta') |
| expect(eventTypes).toContain('message_stop') |
|
|
| |
| const msgStart = events.find((e) => e.event === 'message_start')! |
| expect((msgStart.data.message as Record<string, unknown>).model).toBe('gpt-4') |
| expect((msgStart.data.message as Record<string, unknown>).role).toBe('assistant') |
|
|
| |
| const textDeltas = events.filter((e) => e.event === 'content_block_delta') |
| const texts = textDeltas.map((e) => (e.data.delta as Record<string, unknown>).text) |
| expect(texts).toContain('Hello') |
| expect(texts).toContain(' world') |
|
|
| |
| const msgDelta = events.find((e) => e.event === 'message_delta')! |
| expect((msgDelta.data.delta as Record<string, unknown>).stop_reason).toBe('end_turn') |
| }) |
|
|
| test('tool call streaming', async () => { |
| const sseChunks = [ |
| 'data: {"id":"c2","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c2","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c2","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"city\\""}}]},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c2","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\\"NYC\\"}"}}]},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c2","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\n', |
| 'data: [DONE]\n\n', |
| ] |
|
|
| const upstream = makeStream(sseChunks) |
| const anthropicStream = openaiChatStreamToAnthropic(upstream, 'gpt-4') |
| const events = await collectSse(anthropicStream) |
|
|
| |
| const toolStart = events.find( |
| (e) => e.event === 'content_block_start' && (e.data.content_block as Record<string, unknown>)?.type === 'tool_use', |
| ) |
| expect(toolStart).toBeDefined() |
| expect((toolStart!.data.content_block as Record<string, unknown>).name).toBe('get_weather') |
| expect((toolStart!.data.content_block as Record<string, unknown>).id).toBe('call_1') |
|
|
| |
| const jsonDeltas = events.filter( |
| (e) => e.event === 'content_block_delta' && (e.data.delta as Record<string, unknown>)?.type === 'input_json_delta', |
| ) |
| expect(jsonDeltas.length).toBeGreaterThan(0) |
|
|
| |
| const msgDelta = events.find((e) => e.event === 'message_delta')! |
| expect((msgDelta.data.delta as Record<string, unknown>).stop_reason).toBe('tool_use') |
| }) |
|
|
| test('tool call streaming preserves object arguments from local proxies', async () => { |
| const sseChunks = [ |
| 'data: {"id":"c-write","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_write","type":"function","function":{"name":"Write","arguments":{"file_path":"/tmp/issue-288.txt","content":"ok"}}}]},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c-write","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\n', |
| 'data: [DONE]\n\n', |
| ] |
|
|
| const events = await collectSse(openaiChatStreamToAnthropic(makeStream(sseChunks), 'gpt-4')) |
| const jsonDeltas = events.filter( |
| (e) => e.event === 'content_block_delta' && (e.data.delta as Record<string, unknown>)?.type === 'input_json_delta', |
| ) |
| expect(jsonDeltas).toHaveLength(1) |
| expect((jsonDeltas[0].data.delta as Record<string, unknown>).partial_json).toBe( |
| '{"file_path":"/tmp/issue-288.txt","content":"ok"}', |
| ) |
|
|
| const blockStops = events.filter((e) => e.event === 'content_block_stop') |
| expect(blockStops).toHaveLength(1) |
| expect(blockStops[0].data.index).toBe(0) |
| }) |
|
|
| test('empty stream (just DONE)', async () => { |
| const upstream = makeStream(['data: [DONE]\n\n']) |
| const anthropicStream = openaiChatStreamToAnthropic(upstream, 'gpt-4') |
| const events = await collectSse(anthropicStream) |
| |
| expect(events.some((e) => e.event === 'message_stop')).toBe(true) |
| }) |
|
|
| test('event ordering: content_block_stop before message_delta', async () => { |
| const sseChunks = [ |
| 'data: {"id":"c3","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c3","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c3","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', |
| 'data: [DONE]\n\n', |
| ] |
|
|
| const upstream = makeStream(sseChunks) |
| const events = await collectSse(openaiChatStreamToAnthropic(upstream, 'gpt-4')) |
| const types = events.map((e) => e.event) |
|
|
| |
| const stopIdx = types.indexOf('content_block_stop') |
| const deltaIdx = types.indexOf('message_delta') |
| expect(stopIdx).toBeGreaterThan(-1) |
| expect(deltaIdx).toBeGreaterThan(-1) |
| expect(stopIdx).toBeLessThan(deltaIdx) |
|
|
| |
| const msgStopIdx = types.indexOf('message_stop') |
| expect(deltaIdx).toBeLessThan(msgStopIdx) |
| }) |
|
|
| test('reasoning_content (DeepSeek, OpenRouter, XAI)', async () => { |
| const sseChunks = [ |
| 'data: {"id":"c4","object":"chat.completion.chunk","created":0,"model":"deepseek-chat","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"Let me think"},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c4","object":"chat.completion.chunk","created":0,"model":"deepseek-chat","choices":[{"index":0,"delta":{"reasoning_content":" about this..."},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c4","object":"chat.completion.chunk","created":0,"model":"deepseek-chat","choices":[{"index":0,"delta":{"content":"Hello!"},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c4","object":"chat.completion.chunk","created":0,"model":"deepseek-chat","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', |
| 'data: [DONE]\n\n', |
| ] |
|
|
| const upstream = makeStream(sseChunks) |
| const events = await collectSse(openaiChatStreamToAnthropic(upstream, 'deepseek-chat')) |
|
|
| |
| const thinkingStart = events.find( |
| (e) => e.event === 'content_block_start' && (e.data.content_block as Record<string, unknown>)?.type === 'thinking', |
| ) |
| expect(thinkingStart).toBeDefined() |
|
|
| |
| const thinkingDeltas = events.filter( |
| (e) => e.event === 'content_block_delta' && (e.data.delta as Record<string, unknown>)?.type === 'thinking_delta', |
| ) |
| expect(thinkingDeltas.length).toBeGreaterThan(0) |
|
|
| |
| const textStart = events.find( |
| (e) => e.event === 'content_block_start' && (e.data.content_block as Record<string, unknown>)?.type === 'text', |
| ) |
| expect(textStart).toBeDefined() |
|
|
| |
| expect((textStart!.data as Record<string, unknown>).index).toBeGreaterThan( |
| (thinkingStart!.data as Record<string, unknown>).index as number, |
| ) |
| }) |
|
|
| test('reasoning field (GLM-5, Cerebras, Groq)', async () => { |
| const sseChunks = [ |
| 'data: {"id":"c5","object":"chat.completion.chunk","created":0,"model":"glm-5","choices":[{"index":0,"delta":{"role":"assistant","reasoning":"Thinking here"},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c5","object":"chat.completion.chunk","created":0,"model":"glm-5","choices":[{"index":0,"delta":{"content":"Result"},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c5","object":"chat.completion.chunk","created":0,"model":"glm-5","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', |
| 'data: [DONE]\n\n', |
| ] |
|
|
| const upstream = makeStream(sseChunks) |
| const events = await collectSse(openaiChatStreamToAnthropic(upstream, 'glm-5')) |
|
|
| |
| const thinkingDeltas = events.filter( |
| (e) => e.event === 'content_block_delta' && (e.data.delta as Record<string, unknown>)?.type === 'thinking_delta', |
| ) |
| expect(thinkingDeltas.length).toBe(1) |
| expect((thinkingDeltas[0].data.delta as Record<string, unknown>).thinking).toBe('Thinking here') |
| }) |
|
|
| test('thinking_blocks (OpenAI o-series)', async () => { |
| const sseChunks = [ |
| 'data: {"id":"c6","object":"chat.completion.chunk","created":0,"model":"o3","choices":[{"index":0,"delta":{"role":"assistant","thinking_blocks":[{"type":"thinking","thinking":"Deep thought"}]},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c6","object":"chat.completion.chunk","created":0,"model":"o3","choices":[{"index":0,"delta":{"content":"Answer"},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c6","object":"chat.completion.chunk","created":0,"model":"o3","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', |
| 'data: [DONE]\n\n', |
| ] |
|
|
| const upstream = makeStream(sseChunks) |
| const events = await collectSse(openaiChatStreamToAnthropic(upstream, 'o3')) |
|
|
| const thinkingDeltas = events.filter( |
| (e) => e.event === 'content_block_delta' && (e.data.delta as Record<string, unknown>)?.type === 'thinking_delta', |
| ) |
| expect(thinkingDeltas.length).toBe(1) |
| expect((thinkingDeltas[0].data.delta as Record<string, unknown>).thinking).toBe('Deep thought') |
| }) |
|
|
| test('text + tool transition closes text block first', async () => { |
| const sseChunks = [ |
| 'data: {"id":"c7","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"content":"Let me search"},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c7","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_x","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"test\\"}"}}]},"finish_reason":null}]}\n\n', |
| 'data: {"id":"c7","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\n', |
| 'data: [DONE]\n\n', |
| ] |
|
|
| const upstream = makeStream(sseChunks) |
| const events = await collectSse(openaiChatStreamToAnthropic(upstream, 'gpt-4')) |
| const types = events.map((e) => e.event) |
|
|
| |
| const firstBlockStop = types.indexOf('content_block_stop') |
| const toolBlockStart = types.findIndex( |
| (_, i) => events[i].event === 'content_block_start' && (events[i].data.content_block as Record<string, unknown>)?.type === 'tool_use', |
| ) |
| expect(firstBlockStop).toBeLessThan(toolBlockStart) |
| }) |
| }) |
|
|
| |
|
|
| describe('openaiResponsesStreamToAnthropic', () => { |
| test('basic text streaming', async () => { |
| const sseChunks = [ |
| 'event: response.created\ndata: {"id":"r1","model":"gpt-4o","status":"in_progress"}\n\n', |
| 'event: response.output_item.added\ndata: {"output_index":0,"item":{"type":"message","role":"assistant"}}\n\n', |
| 'event: response.content_part.added\ndata: {"output_index":0,"content_index":0,"part":{"type":"output_text","text":""}}\n\n', |
| 'event: response.output_text.delta\ndata: {"output_index":0,"content_index":0,"delta":"Hello"}\n\n', |
| 'event: response.output_text.delta\ndata: {"output_index":0,"content_index":0,"delta":" world"}\n\n', |
| 'event: response.output_text.done\ndata: {"output_index":0,"content_index":0,"text":"Hello world"}\n\n', |
| 'event: response.completed\ndata: {"response":{"id":"r1","model":"gpt-4o","status":"completed","usage":{"input_tokens":10,"output_tokens":5}}}\n\n', |
| ] |
|
|
| const upstream = makeStream(sseChunks) |
| const anthropicStream = openaiResponsesStreamToAnthropic(upstream, 'gpt-4o') |
| const events = await collectSse(anthropicStream) |
|
|
| const eventTypes = events.map((e) => e.event) |
| expect(eventTypes[0]).toBe('message_start') |
| expect(eventTypes).toContain('content_block_start') |
| expect(eventTypes).toContain('content_block_delta') |
| expect(eventTypes).toContain('content_block_stop') |
| expect(eventTypes).toContain('message_delta') |
| expect(eventTypes).toContain('message_stop') |
|
|
| |
| const textDeltas = events.filter((e) => e.event === 'content_block_delta') |
| const texts = textDeltas.map((e) => (e.data.delta as Record<string, unknown>).text) |
| expect(texts).toContain('Hello') |
| expect(texts).toContain(' world') |
|
|
| const msgDelta = events.find((e) => e.event === 'message_delta')! |
| expect(msgDelta.data.usage).toEqual({ |
| input_tokens: 10, |
| output_tokens: 5, |
| }) |
| }) |
|
|
| test('function call streaming', async () => { |
| const sseChunks = [ |
| 'event: response.created\ndata: {"id":"r2","model":"gpt-4o","status":"in_progress"}\n\n', |
| 'event: response.output_item.added\ndata: {"output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"search"}}\n\n', |
| 'event: response.function_call_arguments.delta\ndata: {"item_id":"fc_1","delta":"{\\"q\\":"}\n\n', |
| 'event: response.function_call_arguments.delta\ndata: {"item_id":"fc_1","delta":"\\"test\\"}"}\n\n', |
| 'event: response.function_call_arguments.done\ndata: {"item_id":"fc_1","arguments":"{\\"q\\":\\"test\\"}"}\n\n', |
| 'event: response.completed\ndata: {"response":{"id":"r2","model":"gpt-4o","status":"completed","usage":{"input_tokens":10,"output_tokens":5}}}\n\n', |
| ] |
|
|
| const upstream = makeStream(sseChunks) |
| const anthropicStream = openaiResponsesStreamToAnthropic(upstream, 'gpt-4o') |
| const events = await collectSse(anthropicStream) |
|
|
| |
| const toolStart = events.find( |
| (e) => e.event === 'content_block_start' && (e.data.content_block as Record<string, unknown>)?.type === 'tool_use', |
| ) |
| expect(toolStart).toBeDefined() |
| expect((toolStart!.data.content_block as Record<string, unknown>).name).toBe('search') |
|
|
| |
| const jsonDeltas = events.filter( |
| (e) => e.event === 'content_block_delta' && (e.data.delta as Record<string, unknown>)?.type === 'input_json_delta', |
| ) |
| expect(jsonDeltas.length).toBeGreaterThan(0) |
|
|
| |
| const msgDelta = events.find((e) => e.event === 'message_delta')! |
| expect((msgDelta.data.delta as Record<string, unknown>).stop_reason).toBe('tool_use') |
| }) |
| }) |
|
|