Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

91 changes: 91 additions & 0 deletions src/server/__tests__/proxy-streaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,39 @@ describe('openaiChatStreamToAnthropic', () => {
)
expect(firstBlockStop).toBeLessThan(toolBlockStart)
})

test('message_delta usage includes input_tokens + cached tokens from final chunk', async () => {
// Issue 1 + 4: streaming usage must surface prompt_tokens and
// prompt_tokens_details.cached_tokens, not just completion_tokens.
const sseChunks = [
'data: {"id":"c-usage","object":"chat.completion.chunk","created":0,"model":"deepseek-chat","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"},"finish_reason":null}]}\n\n',
'data: {"id":"c-usage","object":"chat.completion.chunk","created":0,"model":"deepseek-chat","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1234,"completion_tokens":42,"total_tokens":1276,"prompt_tokens_details":{"cached_tokens":900}}}\n\n',
'data: [DONE]\n\n',
]

const events = await collectSse(openaiChatStreamToAnthropic(makeStream(sseChunks), 'deepseek-chat'))
const msgDelta = events.find((e) => e.event === 'message_delta')!
expect(msgDelta.data.usage).toEqual({
input_tokens: 1234,
output_tokens: 42,
cache_read_input_tokens: 900,
})
})

test('held message_delta merges usage from separate chunk', async () => {
// Some providers send finish_reason and usage in separate chunks.
const sseChunks = [
'data: {"id":"c-split","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}\n\n',
'data: {"id":"c-split","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n',
'data: {"id":"c-split","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[],"usage":{"prompt_tokens":500,"completion_tokens":10,"total_tokens":510}}\n\n',
'data: [DONE]\n\n',
]

const events = await collectSse(openaiChatStreamToAnthropic(makeStream(sseChunks), 'gpt-4'))
const msgDelta = events.find((e) => e.event === 'message_delta')!
expect((msgDelta.data.usage as Record<string, number>).input_tokens).toBe(500)
expect((msgDelta.data.usage as Record<string, number>).output_tokens).toBe(10)
})
})

// ─── OpenAI Responses SSE → Anthropic SSE ──────────────────────
Expand Down Expand Up @@ -341,4 +374,62 @@ describe('openaiResponsesStreamToAnthropic', () => {
const msgDelta = events.find((e) => e.event === 'message_delta')!
expect((msgDelta.data.delta as Record<string, unknown>).stop_reason).toBe('tool_use')
})

test('reasoning summary streaming produces thinking block', async () => {
// Issue 3: response.reasoning_summary_text.delta must surface as a
// thinking content block with thinking_delta events.
const sseChunks = [
'event: response.created\ndata: {"id":"r-reason","model":"o3","status":"in_progress"}\n\n',
'event: response.output_item.added\ndata: {"output_index":0,"item":{"type":"reasoning","id":"rsn_1"}}\n\n',
'event: response.reasoning_summary_text.delta\ndata: {"item_id":"rsn_1","delta":"Step 1: "}\n\n',
'event: response.reasoning_summary_text.delta\ndata: {"item_id":"rsn_1","delta":"analyze"}\n\n',
'event: response.reasoning_summary_text.done\ndata: {"item_id":"rsn_1","text":"Step 1: analyze"}\n\n',
'event: response.output_item.added\ndata: {"output_index":1,"item":{"type":"message","role":"assistant"}}\n\n',
'event: response.content_part.added\ndata: {"output_index":1,"content_index":0,"part":{"type":"output_text","text":""}}\n\n',
'event: response.output_text.delta\ndata: {"output_index":1,"content_index":0,"delta":"Answer"}\n\n',
'event: response.output_text.done\ndata: {"output_index":1,"content_index":0,"text":"Answer"}\n\n',
'event: response.completed\ndata: {"response":{"id":"r-reason","model":"o3","status":"completed","usage":{"input_tokens":50,"output_tokens":20}}}\n\n',
]

const events = await collectSse(openaiResponsesStreamToAnthropic(makeStream(sseChunks), 'o3'))

// Should open a thinking content block
const thinkingStart = events.find(
(e) => e.event === 'content_block_start' && (e.data.content_block as Record<string, unknown>)?.type === 'thinking',
)
expect(thinkingStart).toBeDefined()

// Should emit thinking_delta with the reasoning text
const thinkingDeltas = events.filter(
(e) => e.event === 'content_block_delta' && (e.data.delta as Record<string, unknown>)?.type === 'thinking_delta',
)
expect(thinkingDeltas.length).toBe(2)
const thinkingText = thinkingDeltas.map((e) => (e.data.delta as Record<string, unknown>).thinking).join('')
expect(thinkingText).toBe('Step 1: analyze')

// Reasoning block must be closed before message_delta
const types = events.map((e) => e.event)
const lastBlockStopIdx = types.lastIndexOf('content_block_stop')
const msgDeltaIdx = types.indexOf('message_delta')
expect(lastBlockStopIdx).toBeLessThan(msgDeltaIdx)
})

test('cached tokens surfaced in message_delta usage', async () => {
// Issue 4 (Responses path): input_tokens_details.cached_tokens must
// propagate as cache_read_input_tokens.
const sseChunks = [
'event: response.created\ndata: {"id":"r-cache","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":"Hi"}\n\n',
'event: response.output_text.done\ndata: {"output_index":0,"content_index":0,"text":"Hi"}\n\n',
'event: response.completed\ndata: {"response":{"id":"r-cache","model":"gpt-4o","status":"completed","usage":{"input_tokens":100,"output_tokens":5,"total_tokens":105,"input_tokens_details":{"cached_tokens":80}}}}\n\n',
]

const events = await collectSse(openaiResponsesStreamToAnthropic(makeStream(sseChunks), 'gpt-4o'))
const msgDelta = events.find((e) => e.event === 'message_delta')!
expect((msgDelta.data.usage as Record<string, number>).input_tokens).toBe(100)
expect((msgDelta.data.usage as Record<string, number>).output_tokens).toBe(5)
expect((msgDelta.data.usage as Record<string, number>).cache_read_input_tokens).toBe(80)
})
})
137 changes: 137 additions & 0 deletions src/server/__tests__/proxy-transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,55 @@ describe('anthropicToOpenaiChat', () => {
expect(content[0].type).toBe('image_url')
expect(content[0].image_url!.url).toBe('data:image/png;base64,abc123')
})

test('tool_result with image content becomes placeholder, not dropped', () => {
// Issue 5: non-text tool_result content must surface as a placeholder
// rather than being silently discarded.
const req: AnthropicRequest = {
model: 'gpt-4',
max_tokens: 100,
messages: [{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'tc_img',
content: [
{ type: 'text', text: 'Screenshot below:' },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'deadbeef' } },
],
},
],
}],
}
const result = anthropicToOpenaiChat(req)
expect(result.messages[0].role).toBe('tool')
expect(result.messages[0].tool_call_id).toBe('tc_img')
expect(result.messages[0].content).toBe('Screenshot below:\n[image omitted: image/png]')
})

test('max_tokens clamped to provider cap', () => {
// Issue 6: with maxOutputTokens configured, max_tokens is clamped to the
// cap rather than dropped, so user-set limits still take effect.
const req: AnthropicRequest = {
model: 'deepseek-chat',
max_tokens: 128000,
messages: [{ role: 'user', content: 'Hi' }],
}
const result = anthropicToOpenaiChat(req, { maxOutputTokens: 8192 })
expect(result.max_tokens).toBe(8192)
})

test('max_tokens omitted when no provider cap configured', () => {
// Backward-compat: without a cap, max_tokens is omitted (prior behavior).
const req: AnthropicRequest = {
model: 'deepseek-chat',
max_tokens: 128000,
messages: [{ role: 'user', content: 'Hi' }],
}
const result = anthropicToOpenaiChat(req)
expect(result.max_tokens).toBeUndefined()
})
})

// ─── openaiChatToAnthropic ──────────────────────────────────────
Expand Down Expand Up @@ -466,6 +515,94 @@ describe('anthropicToOpenaiResponses', () => {
expect((result as Record<string, unknown>).stop).toBeUndefined()
expect((result as Record<string, unknown>).stop_sequences).toBeUndefined()
})

test('image content uses input_image format (not Chat image_url)', () => {
// Issue 2: Responses API requires input_image with a string image_url,
// not the Chat Completions image_url object shape.
const req: AnthropicRequest = {
model: 'gpt-4o',
max_tokens: 100,
messages: [{
role: 'user',
content: [
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'abc123' } },
],
}],
}
const result = anthropicToOpenaiResponses(req)
const msg = result.input.find((i) => i.type === 'message')!
expect(msg).toBeDefined()
const content = msg.content as Array<{ type: string; image_url?: string }>
expect(content[0].type).toBe('input_image')
expect(content[0].image_url).toBe('data:image/png;base64,abc123')
})

test('text content uses input_text format in multi-part message', () => {
// Issue 2: text parts in Responses input must be input_text.
const req: AnthropicRequest = {
model: 'gpt-4o',
max_tokens: 100,
messages: [{
role: 'user',
content: [
{ type: 'text', text: 'What is this?' },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'abc123' } },
],
}],
}
const result = anthropicToOpenaiResponses(req)
const msg = result.input.find((i) => i.type === 'message')!
const content = msg.content as Array<{ type: string }>
expect(content[0].type).toBe('input_text')
expect(content[1].type).toBe('input_image')
})

test('tool_result with image content becomes placeholder, not dropped', () => {
// Issue 5: non-text tool_result content must surface as a placeholder.
const req: AnthropicRequest = {
model: 'gpt-4o',
max_tokens: 100,
messages: [{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'tc_img',
content: [
{ type: 'text', text: 'Screenshot:' },
{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: 'ff' } },
],
},
],
}],
}
const result = anthropicToOpenaiResponses(req)
const fco = result.input.find((i) => i.type === 'function_call_output')!
expect(fco).toBeDefined()
expect(fco.output).toBe('Screenshot:\n[image omitted: image/jpeg]')
})

test('max_output_tokens clamped to provider cap', () => {
// Issue 6: with maxOutputTokens configured, max_output_tokens is clamped.
const req: AnthropicRequest = {
model: 'gpt-4o',
max_tokens: 128000,
messages: [{ role: 'user', content: 'Hi' }],
}
const result = anthropicToOpenaiResponses(req, { maxOutputTokens: 16384 })
expect(result.max_output_tokens).toBe(16384)
})

test('max_output_tokens omitted when no provider cap configured', () => {
// Backward-compat: without a cap, max_output_tokens is omitted.
const req: AnthropicRequest = {
model: 'gpt-4o',
max_tokens: 128000,
messages: [{ role: 'user', content: 'Hi' }],
}
const result = anthropicToOpenaiResponses(req)
expect(result.max_output_tokens).toBeUndefined()
})
})

// ─── openaiResponsesToAnthropic ─────────────────────────────────
Expand Down
9 changes: 6 additions & 3 deletions src/server/proxy/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,9 @@ export async function handleProxyRequest(req: Request, url: URL): Promise<Respon

try {
if (config.apiFormat === 'openai_chat') {
return await handleOpenaiChat(body, baseUrl, config.apiKey, isStream, networkSettings.aiRequestTimeoutMs, proxyUrl)
return await handleOpenaiChat(body, baseUrl, config.apiKey, config.maxOutputTokens, isStream, networkSettings.aiRequestTimeoutMs, proxyUrl)
} else {
return await handleOpenaiResponses(body, baseUrl, config.apiKey, isStream, networkSettings.aiRequestTimeoutMs, proxyUrl)
return await handleOpenaiResponses(body, baseUrl, config.apiKey, config.maxOutputTokens, isStream, networkSettings.aiRequestTimeoutMs, proxyUrl)
}
} catch (err) {
console.error('[Proxy] Upstream request failed:', err)
Expand All @@ -159,6 +159,7 @@ async function handleOpenaiChat(
body: AnthropicRequest,
baseUrl: string,
apiKey: string,
maxOutputTokens: number | undefined,
isStream: boolean,
aiRequestTimeoutMs: number,
proxyUrl: string | undefined,
Expand All @@ -167,6 +168,7 @@ async function handleOpenaiChat(
const transformed = anthropicToOpenaiChat(body, {
roundTripReasoningContent: deepSeekCompatible,
passThinkingToggle: deepSeekCompatible,
maxOutputTokens,
})
const url = `${baseUrl}/v1/chat/completions`
const proxyOptions = getProxyFetchOptions({ proxyUrl })
Expand Down Expand Up @@ -230,11 +232,12 @@ async function handleOpenaiResponses(
body: AnthropicRequest,
baseUrl: string,
apiKey: string,
maxOutputTokens: number | undefined,
isStream: boolean,
aiRequestTimeoutMs: number,
proxyUrl: string | undefined,
): Promise<Response> {
const transformed = anthropicToOpenaiResponses(body)
const transformed = anthropicToOpenaiResponses(body, { maxOutputTokens })
const url = `${baseUrl}/v1/responses`
const proxyOptions = getProxyFetchOptions({ proxyUrl })

Expand Down
21 changes: 17 additions & 4 deletions src/server/proxy/streaming/openaiChatStreamToAnthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@
import type { OpenAIChatStreamChunk } from '../transform/types.js'
import { stringifyOpenAIToolArguments } from '../transform/toolArguments.js'

// ─── Usage mapping ─────────────────────────────────────────

function mapStreamUsage(usage?: OpenAIChatStreamChunk['usage']): {
input_tokens: number
output_tokens: number
cache_read_input_tokens?: number
} {
if (!usage) return { input_tokens: 0, output_tokens: 0 }
return {
input_tokens: usage.prompt_tokens ?? 0,
output_tokens: usage.completion_tokens ?? 0,
cache_read_input_tokens: usage.prompt_tokens_details?.cached_tokens ?? 0,
}
}

// ─── Types ─────────────────────────────────────────────────

type ContentBlockType = 'text' | 'thinking' | 'tool_use'
Expand Down Expand Up @@ -470,9 +485,7 @@ function handleFinishReason(
closeAllOpenBlocks(state)

const stopReason = mapFinishReason(finishReason)
const usage = chunk.usage
? { output_tokens: chunk.usage.completion_tokens || 0 }
: { output_tokens: 0 }
const usage = mapStreamUsage(chunk.usage)

const messageDelta: SseEvent = {
event: 'message_delta',
Expand Down Expand Up @@ -500,7 +513,7 @@ function mergeUsageIntoHeldDelta(
if (!state.heldMessageDelta) return

const data = state.heldMessageDelta.data as Record<string, unknown>
data.usage = { output_tokens: usage.completion_tokens || 0 }
data.usage = mapStreamUsage(usage)
state.messageDeltaSent = true
state.queue.push(state.heldMessageDelta)
state.heldMessageDelta = null
Expand Down
Loading