diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts index 6b294dac..c1ebd5d7 100644 --- a/src/proxy/transformers/request-transformer.ts +++ b/src/proxy/transformers/request-transformer.ts @@ -1,5 +1,5 @@ interface AnthropicThinking { - type?: 'enabled' | 'disabled' | string; + type?: 'enabled' | 'disabled' | 'adaptive' | string; budget_tokens?: number; } @@ -14,6 +14,7 @@ interface AnthropicImageBlock { type?: string; media_type?: string; data?: string; + url?: string; }; } @@ -28,6 +29,7 @@ interface AnthropicToolResultBlock { type: 'tool_result'; tool_use_id?: string; content?: unknown; + is_error?: boolean; } type AnthropicContentBlock = @@ -42,6 +44,16 @@ interface AnthropicMessage { content?: string | AnthropicContentBlock[]; } +interface AnthropicOutputConfig { + effort?: 'low' | 'medium' | 'high' | 'max' | string; +} + +interface AnthropicToolChoice { + type?: 'auto' | 'any' | 'tool' | 'none' | string; + name?: string; + disable_parallel_tool_use?: boolean; +} + interface AnthropicProxyRequestShape { model?: unknown; system?: unknown; @@ -52,8 +64,10 @@ interface AnthropicProxyRequestShape { stop_sequences?: unknown; metadata?: unknown; tools?: unknown; + tool_choice?: AnthropicToolChoice; stream?: unknown; thinking?: AnthropicThinking; + output_config?: AnthropicOutputConfig; } interface OpenAITextPart { @@ -100,6 +114,17 @@ export interface ProxyOpenAIRequest { parameters: Record; }; }>; + tool_choice?: + | 'auto' + | 'none' + | 'required' + | { + type: 'function'; + function: { + name: string; + }; + }; + parallel_tool_calls?: boolean; messages: OpenAIMessage[]; max_tokens?: number; temperature?: number; @@ -108,7 +133,6 @@ export interface ProxyOpenAIRequest { metadata?: Record; } -const TOOL_RESULT_SERIALIZATION_FALLBACK = '[unserializable content]'; const TOOL_USE_ARGUMENTS_FALLBACK = '{}'; function assertObject(value: unknown, label: string): Record { @@ -168,17 +192,49 @@ function flattenTextContent(content: unknown, label: string): string { .join('\n'); } -function toToolResultContent(content: unknown, label: string): string { +/** + * Convert tool_result content to OpenAI-compatible format. + * Handles strings, arrays with text/image blocks, and error prefixing. + * Ported from openclaude's convertToolResultContent. + */ +function convertToolResultContent(content: unknown, isError: boolean, label: string): string { if (content === undefined) { return ''; } if (typeof content === 'string') { - return content; + return isError ? `Error: ${content}` : content; } - if (Array.isArray(content)) { - return flattenTextContent(content, label); + if (!Array.isArray(content)) { + const text = safeJsonStringify(content, '[unserializable content]'); + return isError ? `Error: ${text}` : text; } - return safeJsonStringify(content, TOOL_RESULT_SERIALIZATION_FALLBACK); + + const parts: string[] = []; + for (const [index, block] of content.entries()) { + const parsed = assertObject(block, `${label}[${index}]`); + + if (parsed.type === 'text' && typeof parsed.text === 'string') { + parts.push(parsed.text); + continue; + } + + if (parsed.type === 'image') { + throw new Error(`${label}[${index}].type "image" is not supported in tool_result content`); + } + + if (typeof parsed.text === 'string') { + parts.push(parsed.text); + continue; + } + + throw new Error(`${label}[${index}].type "${String(parsed.type)}" is not supported`); + } + + const text = parts.join('\n'); + if (!text) { + return isError ? 'Error:' : ''; + } + return isError ? `Error: ${text}` : text; } function createFallbackToolId(messageIndex: number, blockIndex: number): string { @@ -187,16 +243,27 @@ function createFallbackToolId(messageIndex: number, blockIndex: number): string function toImagePart(block: AnthropicImageBlock, label: string): OpenAIImagePart { const source = block.source; - if (!source || source.type !== 'base64' || !source.media_type || !source.data) { - throw new Error(`${label}.source must be a base64 image payload`); + if (!source) { + throw new Error(`${label}.source is missing`); } - return { - type: 'image_url', - image_url: { - url: `data:${source.media_type};base64,${source.data}`, - }, - }; + if (source.type === 'url' && source.url) { + return { + type: 'image_url', + image_url: { url: source.url }, + }; + } + + if (source.type === 'base64' && source.media_type && source.data) { + return { + type: 'image_url', + image_url: { + url: `data:${source.media_type};base64,${source.data}`, + }, + }; + } + + throw new Error(`${label}.source must be a base64 or url image payload`); } function isImageBlock(block: AnthropicContentBlock): block is AnthropicImageBlock { @@ -234,30 +301,85 @@ function transformTools(value: unknown): ProxyOpenAIRequest['tools'] { (entry): entry is { name?: unknown; description?: unknown; input_schema?: unknown } => typeof entry === 'object' && entry !== null ) - .map((entry) => ({ - type: 'function' as const, - function: { - name: typeof entry.name === 'string' ? entry.name : 'tool', - ...(typeof entry.description === 'string' ? { description: entry.description } : {}), - parameters: - typeof entry.input_schema === 'object' && entry.input_schema !== null - ? (entry.input_schema as Record) - : { type: 'object', properties: {} }, - }, - })); + .map((entry) => { + const rawSchema = + typeof entry.input_schema === 'object' && entry.input_schema !== null + ? (entry.input_schema as Record) + : { type: 'object', properties: {} }; + + return { + type: 'function' as const, + function: { + name: typeof entry.name === 'string' ? entry.name : 'tool', + ...(typeof entry.description === 'string' ? { description: entry.description } : {}), + parameters: rawSchema, + }, + }; + }); return tools.length > 0 ? tools : undefined; } +function transformToolChoice( + value: AnthropicToolChoice | undefined, + hasTools: boolean +): Pick { + if (!value) { + return hasTools ? { tool_choice: 'auto' } : {}; + } + + if (!hasTools) { + throw new Error('tool_choice requires tools'); + } + + const parallelToolCalls = + value.disable_parallel_tool_use === true ? { parallel_tool_calls: false } : {}; + + switch (value.type) { + case undefined: + case 'auto': + return { tool_choice: 'auto', ...parallelToolCalls }; + case 'none': + return { tool_choice: 'none' }; + case 'any': + return { tool_choice: 'required', ...parallelToolCalls }; + case 'tool': + if (typeof value.name !== 'string' || value.name.trim().length === 0) { + throw new Error('tool_choice.name must be a non-empty string when type is "tool"'); + } + return { + tool_choice: { + type: 'function', + function: { name: value.name.trim() }, + }, + ...parallelToolCalls, + }; + default: + throw new Error('tool_choice.type must be "auto", "any", "tool", or "none"'); + } +} + function mapThinkingToReasoning( - thinking: AnthropicThinking | undefined + thinking: AnthropicThinking | undefined, + outputConfig: AnthropicOutputConfig | undefined ): Pick { if (!thinking || thinking.type === 'disabled') { return {}; } + if (thinking.type === 'adaptive') { + const effort = toOpenAIEffort(resolveOutputConfigEffort(outputConfig) ?? 'high'); + return { + reasoning_effort: effort, + reasoning: { + enabled: true, + effort, + }, + }; + } + if (thinking.type !== 'enabled') { - throw new Error('thinking.type must be "enabled" or "disabled"'); + throw new Error('thinking.type must be "enabled", "adaptive", or "disabled"'); } const effort = @@ -274,12 +396,37 @@ function mapThinkingToReasoning( }; } +const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']); + +function resolveOutputConfigEffort( + outputConfig: AnthropicOutputConfig | undefined +): string | undefined { + if (!outputConfig || typeof outputConfig.effort !== 'string') { + return undefined; + } + const normalized = outputConfig.effort.trim().toLowerCase(); + return VALID_EFFORT_LEVELS.has(normalized) ? normalized : undefined; +} + +/** + * Map Anthropic effort levels to OpenAI-compatible reasoning_effort. + * Anthropic's `max` has no standard OpenAI equivalent — most providers + * only accept low/medium/high and reject unknown values with a 400. + * Ported from openclaude's standardEffortToOpenAI() which maps max -> xhigh + * for Codex; for generic OpenAI-compat providers we clamp to high. + */ +function toOpenAIEffort(effort: string): string { + return effort === 'max' ? 'high' : effort; +} + function transformMessages(messagesValue: unknown): OpenAIMessage[] { if (!Array.isArray(messagesValue)) { throw new Error('messages must be an array'); } const translatedMessages: OpenAIMessage[] = []; + let pendingToolUseIds: Set | null = null; + let hasPendingToolUseIds = false; messagesValue.forEach((message, messageIndex) => { const parsedMessage = assertObject(message, `messages[${messageIndex}]`) as AnthropicMessage; @@ -288,8 +435,19 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { throw new Error(`messages[${messageIndex}].role must be "user" or "assistant"`); } + if (pendingToolUseIds && pendingToolUseIds.size > 0 && role !== 'user') { + throw new Error( + `messages[${messageIndex}].role must be "user" with tool_result blocks after assistant tool_use` + ); + } + const content = parsedMessage.content; if (typeof content === 'string') { + if (pendingToolUseIds && pendingToolUseIds.size > 0) { + throw new Error( + `messages[${messageIndex}].content must start with tool_result blocks for pending tool_use ids` + ); + } translatedMessages.push({ role, content }); return; } @@ -298,10 +456,119 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { throw new Error(`messages[${messageIndex}].content must be a string or array`); } - const userParts: OpenAIContentPart[] = []; + if (role === 'user') { + const userParts: OpenAIContentPart[] = []; + let sawToolResult = false; + const resolvedToolUseIds = new Set(); + + content.forEach((block, blockIndex) => { + const parsed = assertObject( + block, + `messages[${messageIndex}].content[${blockIndex}]` + ) as AnthropicContentBlock; + + if (parsed.type === 'thinking' || parsed.type === 'redacted_thinking') { + return; + } + + if (parsed.type === 'text') { + if (sawToolResult) { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] text is not allowed after tool_result blocks` + ); + } + const text = typeof parsed.text === 'string' ? parsed.text : ''; + userParts.push({ type: 'text', text }); + return; + } + + if (isImageBlock(parsed)) { + if (sawToolResult) { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] image is not allowed after tool_result blocks` + ); + } + userParts.push(toImagePart(parsed, `messages[${messageIndex}].content[${blockIndex}]`)); + return; + } + + if (isToolResultBlock(parsed)) { + if (!pendingToolUseIds || pendingToolUseIds.size === 0) { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] tool_result requires a preceding assistant tool_use` + ); + } + if (userParts.length > 0) { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] tool_result blocks must come before other user content` + ); + } + if (typeof parsed.tool_use_id !== 'string' || parsed.tool_use_id.trim().length === 0) { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}].tool_use_id must be a non-empty string` + ); + } + if (!pendingToolUseIds.has(parsed.tool_use_id)) { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}].tool_use_id "${parsed.tool_use_id}" does not match a pending tool_use` + ); + } + if (resolvedToolUseIds.has(parsed.tool_use_id)) { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}].tool_use_id "${parsed.tool_use_id}" is duplicated` + ); + } + sawToolResult = true; + resolvedToolUseIds.add(parsed.tool_use_id); + translatedMessages.push({ + role: 'tool', + tool_call_id: parsed.tool_use_id, + content: convertToolResultContent( + parsed.content, + parsed.is_error === true, + `messages[${messageIndex}].content[${blockIndex}].content` + ), + }); + return; + } + + if (isToolUseBlock(parsed)) { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] tool_use requires assistant role` + ); + } + + throw new Error( + `messages[${messageIndex}].content[${blockIndex}].type "${String(parsed.type)}" is not supported` + ); + }); + + if (sawToolResult) { + if (resolvedToolUseIds.size !== pendingToolUseIds?.size) { + throw new Error( + `messages[${messageIndex}].content must provide tool_result blocks for all pending tool_use ids` + ); + } + pendingToolUseIds = null; + hasPendingToolUseIds = false; + return; + } + + if (pendingToolUseIds && pendingToolUseIds.size > 0) { + throw new Error( + `messages[${messageIndex}].content must start with tool_result blocks for pending tool_use ids` + ); + } + + if (userParts.length > 0) { + flushUserContent(translatedMessages, userParts); + } + return; + } + + // Assistant role const assistantTextParts: string[] = []; const toolCalls: NonNullable = []; - let sawToolResult = false; content.forEach((block, blockIndex) => { const parsed = assertObject( @@ -309,32 +576,17 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { `messages[${messageIndex}].content[${blockIndex}]` ) as AnthropicContentBlock; - if (parsed.type === 'text') { - const text = typeof parsed.text === 'string' ? parsed.text : ''; - if (role === 'user') { - userParts.push({ type: 'text', text }); - } else { - assistantTextParts.push(text); - } + if (parsed.type === 'thinking' || parsed.type === 'redacted_thinking') { return; } - if (isImageBlock(parsed)) { - if (role !== 'user') { - throw new Error( - `messages[${messageIndex}].content[${blockIndex}] image requires user role` - ); - } - userParts.push(toImagePart(parsed, `messages[${messageIndex}].content[${blockIndex}]`)); + if (parsed.type === 'text') { + const text = typeof parsed.text === 'string' ? parsed.text : ''; + assistantTextParts.push(text); return; } if (isToolUseBlock(parsed)) { - if (role !== 'assistant') { - throw new Error( - `messages[${messageIndex}].content[${blockIndex}] tool_use requires assistant role` - ); - } toolCalls.push({ id: typeof parsed.id === 'string' && parsed.id.length > 0 @@ -349,28 +601,16 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { return; } + if (isImageBlock(parsed)) { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] image requires user role` + ); + } + if (isToolResultBlock(parsed)) { - if (role !== 'user') { - throw new Error( - `messages[${messageIndex}].content[${blockIndex}] tool_result requires user role` - ); - } - if (typeof parsed.tool_use_id !== 'string' || parsed.tool_use_id.trim().length === 0) { - throw new Error( - `messages[${messageIndex}].content[${blockIndex}].tool_use_id must be a non-empty string` - ); - } - sawToolResult = true; - flushUserContent(translatedMessages, userParts); - translatedMessages.push({ - role: 'tool', - tool_call_id: parsed.tool_use_id, - content: toToolResultContent( - parsed.content, - `messages[${messageIndex}].content[${blockIndex}].content` - ), - }); - return; + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] tool_result requires user role` + ); } throw new Error( @@ -378,26 +618,72 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { ); }); - if (role === 'assistant') { - translatedMessages.push({ - role: 'assistant', - content: assistantTextParts.join('\n'), - tool_calls: toolCalls.length > 0 ? toolCalls : undefined, - }); + if (assistantTextParts.length === 0 && toolCalls.length === 0) { return; } - if (userParts.length > 0 || !sawToolResult) { - flushUserContent(translatedMessages, userParts); - } + pendingToolUseIds = + toolCalls.length > 0 ? new Set(toolCalls.map((toolCall) => toolCall.id)) : null; + hasPendingToolUseIds = toolCalls.length > 0; + + translatedMessages.push({ + role: 'assistant', + content: assistantTextParts.join('\n'), + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + }); }); + if (hasPendingToolUseIds) { + throw new Error('messages must provide tool_result blocks for the latest assistant tool_use'); + } + return translatedMessages; } +/** + * Coalesce consecutive messages of the same role. + * OpenAI/vLLM/Ollama/Mistral require strict user<->assistant alternation. + * Multiple consecutive tool messages are allowed (assistant -> tool* -> user). + * Ported from openclaude's coalescing pass. + */ +function coalesceMessages(messages: OpenAIMessage[]): OpenAIMessage[] { + const coalesced: OpenAIMessage[] = []; + + for (const msg of messages) { + const prev = coalesced[coalesced.length - 1]; + + if (prev && prev.role === msg.role && msg.role !== 'tool' && msg.role !== 'system') { + const prevContent = prev.content; + const curContent = msg.content; + + if (typeof prevContent === 'string' && typeof curContent === 'string') { + prev.content = prevContent + (prevContent && curContent ? '\n' : '') + curContent; + } else { + const toArray = ( + c: string | OpenAIContentPart[] | null | undefined + ): OpenAIContentPart[] => { + if (!c) return []; + if (typeof c === 'string') return c ? [{ type: 'text', text: c }] : []; + return c; + }; + prev.content = [...toArray(prevContent), ...toArray(curContent)]; + } + + if (msg.tool_calls?.length) { + prev.tool_calls = [...(prev.tool_calls ?? []), ...msg.tool_calls]; + } + } else { + coalesced.push({ ...msg }); + } + } + + return coalesced; +} + export class ProxyRequestTransformer { transform(raw: unknown): ProxyOpenAIRequest { const source = assertObject(raw || {}, 'request') as AnthropicProxyRequestShape; + const tools = transformTools(source.tools); const messages = transformMessages(source.messages); const system = source.system; const allMessages = @@ -414,14 +700,15 @@ export class ProxyRequestTransformer { ? source.model.trim() : undefined, stream: source.stream === true, - messages: allMessages, + messages: coalesceMessages(allMessages), max_tokens: asNumber(source.max_tokens), temperature: asNumber(source.temperature), top_p: asNumber(source.top_p), stop: asStringArray(source.stop_sequences), metadata: asMetadata(source.metadata), - tools: transformTools(source.tools), - ...mapThinkingToReasoning(source.thinking), + tools, + ...transformToolChoice(source.tool_choice, tools !== undefined), + ...mapThinkingToReasoning(source.thinking, source.output_config), }; } } diff --git a/tests/integration/proxy/messages-endpoint.test.ts b/tests/integration/proxy/messages-endpoint.test.ts index d6ce7469..0c3fe0d0 100644 --- a/tests/integration/proxy/messages-endpoint.test.ts +++ b/tests/integration/proxy/messages-endpoint.test.ts @@ -116,13 +116,63 @@ describe('openai proxy messages endpoint', () => { const parsedUpstream = upstreamBody as { messages?: Array<{ role: string; content: string }>; + tool_choice?: unknown; tools?: Array<{ type: string; function: { name: string } }>; }; expect(parsedUpstream.messages?.[0]).toEqual({ role: 'user', content: 'Find docs' }); + expect(parsedUpstream.tool_choice).toBe('auto'); expect(parsedUpstream.tools?.[0]?.type).toBe('function'); expect(parsedUpstream.tools?.[0]?.function.name).toBe('search'); }); + it('preserves tool schemas and forwards explicit tool_choice semantics upstream', async () => { + const response = await requestProxy({ + model: 'hf-model', + messages: [{ role: 'user', content: [{ type: 'text', text: 'Search docs' }] }], + tools: [ + { + name: 'search', + description: 'Search docs', + input_schema: { + type: 'object', + properties: { + q: { type: 'string', pattern: '^[a-z]+$' }, + }, + required: ['q'], + additionalProperties: true, + }, + }, + ], + tool_choice: { + type: 'tool', + name: 'search', + disable_parallel_tool_use: true, + }, + }); + + expect(response.status).toBe(200); + + const parsedUpstream = upstreamBody as { + tool_choice?: unknown; + parallel_tool_calls?: boolean; + tools?: Array<{ type: string; function: { parameters: Record } }>; + }; + + expect(parsedUpstream.tool_choice).toEqual({ + type: 'function', + function: { name: 'search' }, + }); + expect(parsedUpstream.parallel_tool_calls).toBe(false); + expect(parsedUpstream.tools?.[0]?.function.parameters).toEqual({ + type: 'object', + properties: { + q: { type: 'string', pattern: '^[a-z]+$' }, + }, + required: ['q'], + additionalProperties: true, + }); + }); + it('falls back to Anthropic JSON for non-streaming requests', async () => { const response = await requestProxy({ model: 'hf-model', @@ -155,6 +205,24 @@ describe('openai proxy messages endpoint', () => { expect(body.error?.message).toContain('Invalid JSON'); }); + it('returns invalid_request_error for orphan tool_result blocks', async () => { + const response = await requestProxy({ + model: 'hf-model', + messages: [ + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_orphan', content: 'orphan' }], + }, + ], + }); + + const body = (await response.json()) as { error?: { type?: string; message?: string } }; + + expect(response.status).toBe(400); + expect(body.error?.type).toBe('invalid_request_error'); + expect(body.error?.message).toContain('tool_result requires a preceding assistant tool_use'); + }); + it('rejects requests without the local proxy auth token', async () => { const response = await fetch(`http://127.0.0.1:${proxyPort}/v1/messages`, { method: 'POST', diff --git a/tests/integration/proxy/request-routing.test.ts b/tests/integration/proxy/request-routing.test.ts index d5a8ce0d..b9f7afe8 100644 --- a/tests/integration/proxy/request-routing.test.ts +++ b/tests/integration/proxy/request-routing.test.ts @@ -214,4 +214,75 @@ describe('openai proxy request routing', () => { expect(hits).toEqual(['thinker']); expect(bodies[0]?.body).toMatchObject({ model: 'deepseek-reasoner' }); }); + + it('routes adaptive thinking requests through the configured think scenario', async () => { + const primaryPort = await getPort(); + const thinkPort = await getPort(); + const hits: string[] = []; + const bodies: Array<{ label: string; body: unknown }> = []; + await startMockUpstream(primaryPort, 'primary', hits, bodies); + await startMockUpstream(thinkPort, 'thinker', hits, bodies); + + const primarySettings = writeSettings('hf', { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`, + ANTHROPIC_AUTH_TOKEN: 'hf_token', + ANTHROPIC_MODEL: 'hf-default', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + const thinkSettings = writeSettings('thinker', { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${thinkPort}`, + ANTHROPIC_AUTH_TOKEN: 'think_token', + ANTHROPIC_MODEL: 'deepseek-reasoner', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + + fs.writeFileSync( + path.join(tempDir, '.ccs', 'config.json'), + JSON.stringify( + { + profiles: { hf: primarySettings, thinker: thinkSettings }, + proxy: { + routing: { + think: 'thinker:deepseek-reasoner', + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + + const profile: OpenAICompatProfileConfig = { + profileName: 'hf', + settingsPath: primarySettings, + baseUrl: `http://127.0.0.1:${primaryPort}`, + apiKey: 'hf_token', + provider: 'generic-chat-completion-api', + model: 'hf-default', + }; + proxyServer = startOpenAICompatProxyServer({ + profile, + port: proxyPort, + authToken: 'test-proxy-token', + }); + + const response = await requestProxy({ + model: 'hf-default', + thinking: { type: 'adaptive' }, + output_config: { effort: 'max' }, + messages: [{ role: 'user', content: 'think adaptively' }], + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + content: [{ type: 'text', text: 'Reply from thinker' }], + }); + expect(hits).toEqual(['thinker']); + expect(bodies[0]?.body).toMatchObject({ + model: 'deepseek-reasoner', + reasoning_effort: 'high', + reasoning: { enabled: true, effort: 'high' }, + }); + }); }); diff --git a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts new file mode 100644 index 00000000..e9a05c18 --- /dev/null +++ b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from 'bun:test'; + +import { ProxyRequestTransformer } from '../../../../src/proxy/transformers/request-transformer'; + +describe('ProxyRequestTransformer regressions', () => { + it('drops assistant messages that only contain stripped thinking blocks', () => { + const result = new ProxyRequestTransformer().transform({ + messages: [ + { + role: 'assistant', + content: [ + { type: 'thinking', text: 'internal' }, + { type: 'redacted_thinking', text: 'hidden' }, + ], + }, + ], + }); + + expect(result.messages).toEqual([]); + }); + + it('maps adaptive thinking through output_config effort for OpenAI-compatible upstreams', () => { + const result = new ProxyRequestTransformer().transform({ + messages: [{ role: 'user', content: 'hello' }], + thinking: { type: 'adaptive' }, + output_config: { effort: 'max' }, + }); + + expect(result.reasoning_effort).toBe('high'); + expect(result.reasoning).toEqual({ enabled: true, effort: 'high' }); + }); + + it('rejects unsupported thinking types instead of silently dropping them', () => { + expect(() => + new ProxyRequestTransformer().transform({ + messages: [{ role: 'user', content: 'hello' }], + thinking: { type: 'typo' }, + }) + ).toThrow('thinking.type must be "enabled", "adaptive", or "disabled"'); + }); + + it('keeps Anthropic role validation for tool_use, image, and tool_result blocks', () => { + expect(() => + new ProxyRequestTransformer().transform({ + messages: [{ role: 'user', content: [{ type: 'tool_use', name: 'search', input: {} }] }], + }) + ).toThrow('tool_use requires assistant role'); + + expect(() => + new ProxyRequestTransformer().transform({ + messages: [ + { + role: 'assistant', + content: [ + { + type: 'image', + source: { type: 'url', url: 'https://example.com/image.png' }, + }, + ], + }, + ], + }) + ).toThrow('image requires user role'); + + expect(() => + new ProxyRequestTransformer().transform({ + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'nope' }], + }, + ], + }) + ).toThrow('tool_result requires user role'); + }); + + it('rejects orphaned, incomplete, or mixed-order tool_result blocks', () => { + expect(() => + new ProxyRequestTransformer().transform({ + messages: [ + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'orphan' }], + }, + ], + }) + ).toThrow('tool_result requires a preceding assistant tool_use'); + + expect(() => + new ProxyRequestTransformer().transform({ + messages: [ + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'docs' } }, + { type: 'tool_use', id: 'toolu_2', name: 'open', input: { url: 'https://example.com' } }, + ], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'partial' }], + }, + ], + }) + ).toThrow('must provide tool_result blocks for all pending tool_use ids'); + + expect(() => + new ProxyRequestTransformer().transform({ + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'vision', input: { detail: 'high' } }], + }, + { + role: 'user', + content: [ + { type: 'text', text: 'Here you go' }, + { type: 'tool_result', tool_use_id: 'toolu_1', content: 'result' }, + ], + }, + ], + }) + ).toThrow('tool_result blocks must come before other user content'); + + expect(() => + new ProxyRequestTransformer().transform({ + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'vision', input: { detail: 'high' } }], + }, + { + role: 'user', + content: 'plain follow-up', + }, + ], + }) + ).toThrow('must start with tool_result blocks for pending tool_use ids'); + }); + + it('rejects tool_result content that cannot be represented as OpenAI tool text', () => { + expect(() => + new ProxyRequestTransformer().transform({ + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'vision', input: { detail: 'high' } }], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_1', + content: [{ type: 'image', source: { type: 'url', url: 'https://example.com/error.png' } }], + }, + ], + }, + ], + }) + ).toThrow('type "image" is not supported in tool_result content'); + }); + + it('rejects unsupported assistant blocks instead of silently dropping them', () => { + expect(() => + new ProxyRequestTransformer().transform({ + messages: [ + { + role: 'assistant', + content: [{ type: 'server_tool_use', id: 'srv_1' }], + }, + ], + }) + ).toThrow('type "server_tool_use" is not supported'); + }); + + it('translates url images and tool_choice while coalescing repeated turns', () => { + const result = new ProxyRequestTransformer().transform({ + tool_choice: { + type: 'tool', + name: 'vision', + disable_parallel_tool_use: true, + }, + tools: [{ name: 'vision', description: 'Inspect image', input_schema: { type: 'object' } }], + messages: [ + { + role: 'user', + content: [{ type: 'image', source: { type: 'url', url: 'https://example.com/cat.png' } }], + }, + { role: 'user', content: [{ type: 'text', text: 'Describe it' }] }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Checking' }], + }, + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'vision', input: { detail: 'high' } }], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_1', + is_error: true, + content: [{ type: 'text', text: 'fetch failed' }], + }, + ], + }, + ], + }); + + expect(result.tool_choice).toEqual({ + type: 'function', + function: { name: 'vision' }, + }); + expect(result.parallel_tool_calls).toBe(false); + + expect(result.messages[0]).toEqual({ + role: 'user', + content: [ + { type: 'image_url', image_url: { url: 'https://example.com/cat.png' } }, + { type: 'text', text: 'Describe it' }, + ], + }); + expect(result.messages[1]).toEqual({ + role: 'assistant', + content: 'Checking', + tool_calls: [ + { + id: 'toolu_1', + type: 'function', + function: { + name: 'vision', + arguments: '{"detail":"high"}', + }, + }, + ], + }); + expect(result.messages[2]).toEqual({ + role: 'tool', + tool_call_id: 'toolu_1', + content: 'Error: fetch failed', + }); + }); + + it('defaults tools to auto tool_choice when none is specified', () => { + const result = new ProxyRequestTransformer().transform({ + messages: [{ role: 'user', content: 'hello' }], + tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }], + }); + + expect(result.tool_choice).toBe('auto'); + }); +});