From 704f320dfb39bd1ab33d982df2c7e978154acbcd Mon Sep 17 00:00:00 2001 From: GODDiao Date: Wed, 8 Jul 2026 23:44:58 +0800 Subject: [PATCH] feat(proxy): clamp max_tokens, fix Responses content shape, surface reasoning - Add provider maxOutputTokens config (Saved/Create/Update schemas); clamp Anthropic max_tokens to the provider's known limit instead of always omitting it. Claude Code sends 128K which exceeds many providers (DeepSeek: 8192, etc.); without a configured cap we still omit the field. - Fix OpenAI Responses API content parts: emit input_text/input_image instead of reusing Chat-shaped image_url objects, and correct the flush logic so accumulated content is not dropped. - Stream reasoning items from OpenAI Responses (response.reasoning_*. delta/done and reasoning_summary_part.done) into Anthropic thinking content blocks, with lazy open for providers that omit output_item.added. - Map prompt_tokens_details.cached_tokens -> cache_read_input_tokens on both Chat and Responses streaming paths (previously only output_tokens were forwarded). - Extract shared extractToolResultContent helper; non-text tool_result blocks (images, nested tool_use, thinking) now produce placeholders instead of being silently dropped. --- desktop/src-tauri/Cargo.lock | 2 +- src/server/__tests__/proxy-streaming.test.ts | 91 ++++++++++++ src/server/__tests__/proxy-transform.test.ts | 137 ++++++++++++++++++ src/server/proxy/handler.ts | 9 +- .../streaming/openaiChatStreamToAnthropic.ts | 21 ++- .../openaiResponsesStreamToAnthropic.ts | 86 +++++++++++ .../proxy/transform/anthropicToOpenaiChat.ts | 25 ++-- .../transform/anthropicToOpenaiResponses.ts | 45 +++--- src/server/proxy/transform/toolArguments.ts | 30 ++++ src/server/proxy/transform/types.ts | 6 +- src/server/services/providerService.ts | 4 + src/server/types/provider.ts | 3 + 12 files changed, 419 insertions(+), 40 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index b08eb36..ab0fc0a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -862,7 +862,7 @@ dependencies = [ [[package]] name = "dreamcoder-desktop" -version = "0.4.0" +version = "0.4.5" dependencies = [ "anyhow", "portable-pty", diff --git a/src/server/__tests__/proxy-streaming.test.ts b/src/server/__tests__/proxy-streaming.test.ts index 56dd9ec..19cceb2 100644 --- a/src/server/__tests__/proxy-streaming.test.ts +++ b/src/server/__tests__/proxy-streaming.test.ts @@ -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).input_tokens).toBe(500) + expect((msgDelta.data.usage as Record).output_tokens).toBe(10) + }) }) // ─── OpenAI Responses SSE → Anthropic SSE ────────────────────── @@ -341,4 +374,62 @@ describe('openaiResponsesStreamToAnthropic', () => { const msgDelta = events.find((e) => e.event === 'message_delta')! expect((msgDelta.data.delta as Record).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)?.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)?.type === 'thinking_delta', + ) + expect(thinkingDeltas.length).toBe(2) + const thinkingText = thinkingDeltas.map((e) => (e.data.delta as Record).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).input_tokens).toBe(100) + expect((msgDelta.data.usage as Record).output_tokens).toBe(5) + expect((msgDelta.data.usage as Record).cache_read_input_tokens).toBe(80) + }) }) diff --git a/src/server/__tests__/proxy-transform.test.ts b/src/server/__tests__/proxy-transform.test.ts index 26433ee..9e95de3 100644 --- a/src/server/__tests__/proxy-transform.test.ts +++ b/src/server/__tests__/proxy-transform.test.ts @@ -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 ────────────────────────────────────── @@ -466,6 +515,94 @@ describe('anthropicToOpenaiResponses', () => { expect((result as Record).stop).toBeUndefined() expect((result as Record).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 ───────────────────────────────── diff --git a/src/server/proxy/handler.ts b/src/server/proxy/handler.ts index cb0fc7e..c297fd0 100644 --- a/src/server/proxy/handler.ts +++ b/src/server/proxy/handler.ts @@ -136,9 +136,9 @@ export async function handleProxyRequest(req: Request, url: URL): Promise { - const transformed = anthropicToOpenaiResponses(body) + const transformed = anthropicToOpenaiResponses(body, { maxOutputTokens }) const url = `${baseUrl}/v1/responses` const proxyOptions = getProxyFetchOptions({ proxyUrl }) diff --git a/src/server/proxy/streaming/openaiChatStreamToAnthropic.ts b/src/server/proxy/streaming/openaiChatStreamToAnthropic.ts index 5fc13fe..64b8c1f 100644 --- a/src/server/proxy/streaming/openaiChatStreamToAnthropic.ts +++ b/src/server/proxy/streaming/openaiChatStreamToAnthropic.ts @@ -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' @@ -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', @@ -500,7 +513,7 @@ function mergeUsageIntoHeldDelta( if (!state.heldMessageDelta) return const data = state.heldMessageDelta.data as Record - data.usage = { output_tokens: usage.completion_tokens || 0 } + data.usage = mapStreamUsage(usage) state.messageDeltaSent = true state.queue.push(state.heldMessageDelta) state.heldMessageDelta = null diff --git a/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts b/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts index 8721c03..a468f11 100644 --- a/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts +++ b/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts @@ -10,6 +10,7 @@ type StreamState = { nextContentIndex: number indexByKey: Map // content part key → Anthropic index toolIndexByItemId: Map // tool item ID → Anthropic index + reasoningIndexByItemId: Map // reasoning item ID → Anthropic index model: string messageStarted: boolean messageStopped: boolean @@ -34,6 +35,7 @@ export function openaiResponsesStreamToAnthropic( nextContentIndex: 0, indexByKey: new Map(), toolIndexByItemId: new Map(), + reasoningIndexByItemId: new Map(), model, messageStarted: false, messageStopped: false, @@ -158,6 +160,19 @@ function processEvent( input: {}, }, }))) + } else if (item.type === 'reasoning') { + // Open a thinking content block; reasoning summary/text deltas + // arrive via response.reasoning_summary_text.delta events. + const itemId = (item.id as string) || '' + if (itemId && !state.reasoningIndexByItemId.has(itemId)) { + const index = state.nextContentIndex++ + state.reasoningIndexByItemId.set(itemId, index) + controller.enqueue(encoder.encode(formatSse('content_block_start', { + type: 'content_block_start', + index, + content_block: { type: 'thinking', thinking: '' }, + }))) + } } break } @@ -254,6 +269,59 @@ function processEvent( break } + case 'response.reasoning_summary_text.delta': + case 'response.reasoning_text.delta': { + const itemId = (data.item_id as string) || '' + let index = state.reasoningIndexByItemId.get(itemId) + // Some providers omit the reasoning output_item.added event; open + // lazily on first delta so the thinking block still surfaces. + if (index === undefined) { + index = state.nextContentIndex++ + state.reasoningIndexByItemId.set(itemId, index) + if (!state.messageStarted) emitMessageStart(state, controller, encoder, state.model) + controller.enqueue(encoder.encode(formatSse('content_block_start', { + type: 'content_block_start', + index, + content_block: { type: 'thinking', thinking: '' }, + }))) + } + + const delta = (data.delta as string) || '' + controller.enqueue(encoder.encode(formatSse('content_block_delta', { + type: 'content_block_delta', + index, + delta: { type: 'thinking_delta', thinking: delta }, + }))) + break + } + + case 'response.reasoning_summary_text.done': + case 'response.reasoning_text.done': { + const itemId = (data.item_id as string) || '' + const index = state.reasoningIndexByItemId.get(itemId) + if (index === undefined) break + + controller.enqueue(encoder.encode(formatSse('content_block_stop', { + type: 'content_block_stop', + index, + }))) + break + } + + case 'response.reasoning_summary_part.done': { + // Some providers emit this in lieu of reasoning_summary_text.done + // to signal the end of a summary part; close the thinking block. + const itemId = (data.item_id as string) || '' + const index = state.reasoningIndexByItemId.get(itemId) + if (index === undefined) break + + controller.enqueue(encoder.encode(formatSse('content_block_stop', { + type: 'content_block_stop', + index, + }))) + break + } + case 'response.completed': { const response = data.response as Record | undefined const status = (response?.status as string) || 'completed' @@ -264,12 +332,30 @@ function processEvent( ? (hasToolUse ? 'tool_use' : 'end_turn') : status === 'incomplete' ? 'max_tokens' : 'end_turn' + // Map usage including cached tokens when provided. Some providers + // surface cache hits via input_tokens_details.cached_tokens. + const cached = (usage as Record | undefined)?.input_tokens_details as + | { cached_tokens?: number } + | undefined + + // Close any reasoning blocks that didn't receive an explicit .done + // event before emitting message_delta (Anthropic requires blocks + // closed before message_delta). + for (const [, index] of state.reasoningIndexByItemId) { + controller.enqueue(encoder.encode(formatSse('content_block_stop', { + type: 'content_block_stop', + index, + }))) + } + state.reasoningIndexByItemId.clear() + controller.enqueue(encoder.encode(formatSse('message_delta', { type: 'message_delta', delta: { stop_reason: stopReason, stop_sequence: null }, usage: { input_tokens: usage?.input_tokens ?? 0, output_tokens: usage?.output_tokens ?? 0, + ...(cached?.cached_tokens ? { cache_read_input_tokens: cached.cached_tokens } : {}), }, }))) if (!state.messageStopped) { diff --git a/src/server/proxy/transform/anthropicToOpenaiChat.ts b/src/server/proxy/transform/anthropicToOpenaiChat.ts index a637df5..9f098d4 100644 --- a/src/server/proxy/transform/anthropicToOpenaiChat.ts +++ b/src/server/proxy/transform/anthropicToOpenaiChat.ts @@ -14,13 +14,18 @@ import type { OpenAIToolCall, OpenAITool, } from './types.js' +import { extractToolResultContent } from './toolArguments.js' /** * Convert Anthropic Messages request to OpenAI Chat Completions request. */ export function anthropicToOpenaiChat( body: AnthropicRequest, - options: { roundTripReasoningContent?: boolean; passThinkingToggle?: boolean } = {}, + options: { + roundTripReasoningContent?: boolean + passThinkingToggle?: boolean + maxOutputTokens?: number + } = {}, ): OpenAIChatRequest { const messages: OpenAIChatMessage[] = [] @@ -46,9 +51,13 @@ export function anthropicToOpenaiChat( stream: body.stream, } - // max_tokens — omit to let upstream provider use its own default/max. + // max_tokens — clamp to the provider's known limit when configured. // Claude Code sends very large values (e.g. 128K) that exceed many - // providers' limits (DeepSeek: 8192, etc.). + // providers' limits (DeepSeek: 8192, etc.). Without a configured cap we + // omit the field entirely so the upstream uses its own default/max. + if (options.maxOutputTokens !== undefined && typeof body.max_tokens === 'number') { + result.max_tokens = Math.min(body.max_tokens, options.maxOutputTokens) + } // temperature & top_p if (body.temperature !== undefined) result.temperature = body.temperature @@ -99,7 +108,7 @@ export function anthropicToOpenaiChat( function convertMessage( msg: AnthropicMessage, output: OpenAIChatMessage[], - options: { roundTripReasoningContent?: boolean }, + options: { roundTripReasoningContent?: boolean; passThinkingToggle?: boolean; maxOutputTokens?: number }, ): void { const content = msg.content @@ -134,11 +143,7 @@ function convertUserMessage(blocks: AnthropicContentBlock[], output: OpenAIChatM contentParts.push({ type: 'image_url', image_url: { url } }) } else if (block.type === 'tool_result') { // tool_result → separate tool message - const resultContent = typeof block.content === 'string' - ? block.content - : Array.isArray(block.content) - ? block.content.filter((b): b is Extract => b.type === 'text').map((b) => b.text).join('\n') - : '' + const resultContent = extractToolResultContent(block.content) output.push({ role: 'tool', tool_call_id: block.tool_use_id, @@ -160,7 +165,7 @@ function convertUserMessage(blocks: AnthropicContentBlock[], output: OpenAIChatM function convertAssistantMessage( blocks: AnthropicContentBlock[], output: OpenAIChatMessage[], - options: { roundTripReasoningContent?: boolean }, + options: { roundTripReasoningContent?: boolean; passThinkingToggle?: boolean; maxOutputTokens?: number }, ): void { let textContent = '' let reasoningContent = '' diff --git a/src/server/proxy/transform/anthropicToOpenaiResponses.ts b/src/server/proxy/transform/anthropicToOpenaiResponses.ts index 1a9e31d..82c8855 100644 --- a/src/server/proxy/transform/anthropicToOpenaiResponses.ts +++ b/src/server/proxy/transform/anthropicToOpenaiResponses.ts @@ -6,17 +6,20 @@ import type { AnthropicRequest, - AnthropicContentBlock, AnthropicMessage, OpenAIResponsesRequest, OpenAIResponsesInputItem, - OpenAIChatContentPart, + OpenAIResponsesContentPart, } from './types.js' +import { extractToolResultContent } from './toolArguments.js' /** * Convert Anthropic Messages request to OpenAI Responses API request. */ -export function anthropicToOpenaiResponses(body: AnthropicRequest): OpenAIResponsesRequest { +export function anthropicToOpenaiResponses( + body: AnthropicRequest, + options: { maxOutputTokens?: number } = {}, +): OpenAIResponsesRequest { const input: OpenAIResponsesInputItem[] = [] // Convert messages to input items @@ -40,8 +43,12 @@ export function anthropicToOpenaiResponses(body: AnthropicRequest): OpenAIRespon } } - // max_tokens — omit to let upstream provider use its own default/max. + // max_output_tokens — clamp to the provider's known limit when configured. // Claude Code sends very large values that exceed many providers' limits. + // Without a configured cap we omit the field so upstream uses its default. + if (options.maxOutputTokens !== undefined && typeof body.max_tokens === 'number') { + result.max_output_tokens = Math.min(body.max_tokens, options.maxOutputTokens) + } // temperature & top_p if (body.temperature !== undefined) result.temperature = body.temperature @@ -96,23 +103,23 @@ function convertMessageToInputItems(msg: AnthropicMessage, output: OpenAIRespons } // Collect text/image parts and handle tool blocks separately - const contentParts: (string | OpenAIChatContentPart)[] = [] + const contentParts: OpenAIResponsesContentPart[] = [] for (const block of content) { if (block.type === 'text') { - contentParts.push(block.text) + contentParts.push({ type: 'input_text', text: block.text }) } else if (block.type === 'image') { contentParts.push({ - type: 'image_url', - image_url: { url: `data:${block.source.media_type};base64,${block.source.data}` }, + type: 'input_image', + image_url: `data:${block.source.media_type};base64,${block.source.data}`, }) } else if (block.type === 'tool_use') { // Flush any accumulated content first if (contentParts.length > 0) { - const flatContent = contentParts.length === 1 && typeof contentParts[0] === 'string' - ? contentParts[0] - : contentParts.map((p) => typeof p === 'string' ? p : '').join('') - if (flatContent) { + const flatContent = contentParts.length === 1 && contentParts[0].type === 'input_text' + ? contentParts[0].text + : contentParts + if (flatContent && (typeof flatContent !== 'object' || (Array.isArray(flatContent) && flatContent.length > 0))) { output.push({ type: 'message', role: msg.role, content: flatContent }) } contentParts.length = 0 @@ -126,11 +133,7 @@ function convertMessageToInputItems(msg: AnthropicMessage, output: OpenAIRespons }) } else if (block.type === 'tool_result') { // Lift to function_call_output item - const resultContent = typeof block.content === 'string' - ? block.content - : Array.isArray(block.content) - ? block.content.filter((b): b is Extract => b.type === 'text').map((b) => b.text).join('\n') - : '' + const resultContent = extractToolResultContent(block.content) output.push({ type: 'function_call_output', call_id: block.tool_use_id, @@ -142,10 +145,10 @@ function convertMessageToInputItems(msg: AnthropicMessage, output: OpenAIRespons // Flush remaining content if (contentParts.length > 0) { - const flatContent = contentParts.length === 1 && typeof contentParts[0] === 'string' - ? contentParts[0] - : contentParts.map((p) => typeof p === 'string' ? p : '').join('') - if (flatContent) { + const flatContent = contentParts.length === 1 && contentParts[0].type === 'input_text' + ? contentParts[0].text + : contentParts + if (flatContent && (typeof flatContent !== 'object' || (Array.isArray(flatContent) && flatContent.length > 0))) { output.push({ type: 'message', role: msg.role, content: flatContent }) } } diff --git a/src/server/proxy/transform/toolArguments.ts b/src/server/proxy/transform/toolArguments.ts index 56c8317..3d9d108 100644 --- a/src/server/proxy/transform/toolArguments.ts +++ b/src/server/proxy/transform/toolArguments.ts @@ -1,3 +1,5 @@ +import type { AnthropicContentBlock } from './types.js' + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -23,3 +25,31 @@ export function stringifyOpenAIToolArguments(value: unknown): string { if (value == null || value === '') return '' return typeof value === 'string' ? value : JSON.stringify(value) } + +/** + * Extract a string representation of a tool_result's content for forwarding + * to OpenAI-compatible providers (whose tool/function outputs only accept + * strings). Text blocks are joined; non-text blocks (images, etc.) become + * placeholders so the model at least knows something was returned rather + * than silently dropping it. + */ +export function extractToolResultContent(content: string | AnthropicContentBlock[]): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + + const parts: string[] = [] + for (const block of content) { + if (block.type === 'text') { + parts.push(block.text) + } else if (block.type === 'image') { + parts.push(`[image omitted: ${block.source.media_type}]`) + } else if (block.type === 'tool_use') { + parts.push(`[nested tool_use: ${block.name}]`) + } else if (block.type === 'thinking') { + parts.push(`[nested thinking]`) + } else { + parts.push(`[omitted content: ${(block as { type: string }).type}]`) + } + } + return parts.join('\n') +} diff --git a/src/server/proxy/transform/types.ts b/src/server/proxy/transform/types.ts index fd6e6de..8ba0078 100644 --- a/src/server/proxy/transform/types.ts +++ b/src/server/proxy/transform/types.ts @@ -103,8 +103,12 @@ export type OpenAIChatStreamChunk = { // ─── OpenAI Responses API ─────────────────────────────────── +export type OpenAIResponsesContentPart = + | { type: 'input_text'; text: string } + | { type: 'input_image'; image_url: string } + export type OpenAIResponsesInputItem = - | { type: 'message'; role: 'user' | 'assistant' | 'system'; content: string | OpenAIChatContentPart[] } + | { type: 'message'; role: 'user' | 'assistant' | 'system'; content: string | OpenAIResponsesContentPart[] } | { type: 'function_call'; call_id: string; name: string; arguments: unknown } | { type: 'function_call_output'; call_id: string; output: string } diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index 2fed468..39d1084 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -377,6 +377,7 @@ export class ProviderService { baseUrl: string apiKey: string apiFormat: ApiFormat + maxOutputTokens?: number } | null> { if (providerId) { if (isOpenAIOfficialProviderId(providerId)) { @@ -387,6 +388,7 @@ export class ProviderService { baseUrl: provider.baseUrl, apiKey: provider.apiKey, apiFormat: provider.apiFormat ?? 'anthropic', + maxOutputTokens: provider.maxOutputTokens, } } @@ -401,6 +403,7 @@ export class ProviderService { baseUrl: provider.baseUrl, apiKey: provider.apiKey, apiFormat: provider.apiFormat ?? 'anthropic', + maxOutputTokens: provider.maxOutputTokens, } } @@ -408,6 +411,7 @@ export class ProviderService { baseUrl: string apiKey: string apiFormat: ApiFormat + maxOutputTokens?: number } | null> { return this.getProviderForProxy() } diff --git a/src/server/types/provider.ts b/src/server/types/provider.ts index a6c0feb..184a5b6 100644 --- a/src/server/types/provider.ts +++ b/src/server/types/provider.ts @@ -54,6 +54,7 @@ export const SavedProviderSchema = z.object({ models: ModelMappingSchema, autoCompactWindow: AutoCompactWindowSchema.optional(), modelContextWindows: ModelContextWindowsSchema.optional(), + maxOutputTokens: z.number().int().positive().max(10000000).optional(), notes: z.string().optional(), }) @@ -74,6 +75,7 @@ export const CreateProviderSchema = z.object({ models: ModelMappingSchema, autoCompactWindow: AutoCompactWindowSchema.optional(), modelContextWindows: ModelContextWindowsSchema.optional(), + maxOutputTokens: z.number().int().positive().max(10000000).optional(), notes: z.string().optional(), }) @@ -87,6 +89,7 @@ export const UpdateProviderSchema = z.object({ models: ModelMappingSchema.optional(), autoCompactWindow: AutoCompactWindowSchema.nullable().optional(), modelContextWindows: ModelContextWindowsSchema.nullable().optional(), + maxOutputTokens: z.number().int().positive().max(10000000).nullable().optional(), notes: z.string().optional(), })