From 2672e35362938b054710de681760bd24c3c39e19 Mon Sep 17 00:00:00 2001 From: Grandis SYF Date: Sat, 18 Apr 2026 13:49:20 +0700 Subject: [PATCH 1/5] feat(proxy): enhance Anthropic-to-OpenAI message transformation and schema sanitization --- src/proxy/transformers/request-transformer.ts | 329 +++++++++++++----- src/utils/schema-sanitizer.ts | 284 +++++++++++++++ 2 files changed, 525 insertions(+), 88 deletions(-) create mode 100644 src/utils/schema-sanitizer.ts diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts index 6b294dac..da996ea8 100644 --- a/src/proxy/transformers/request-transformer.ts +++ b/src/proxy/transformers/request-transformer.ts @@ -1,5 +1,7 @@ +import { normalizeSchemaForOpenAI } from '../../utils/schema-sanitizer'; + interface AnthropicThinking { - type?: 'enabled' | 'disabled' | string; + type?: 'enabled' | 'disabled' | 'adaptive' | string; budget_tokens?: number; } @@ -14,6 +16,7 @@ interface AnthropicImageBlock { type?: string; media_type?: string; data?: string; + url?: string; }; } @@ -28,6 +31,7 @@ interface AnthropicToolResultBlock { type: 'tool_result'; tool_use_id?: string; content?: unknown; + is_error?: boolean; } type AnthropicContentBlock = @@ -42,6 +46,10 @@ interface AnthropicMessage { content?: string | AnthropicContentBlock[]; } +interface AnthropicOutputConfig { + effort?: 'low' | 'medium' | 'high' | 'max' | string; +} + interface AnthropicProxyRequestShape { model?: unknown; system?: unknown; @@ -54,6 +62,7 @@ interface AnthropicProxyRequestShape { tools?: unknown; stream?: unknown; thinking?: AnthropicThinking; + output_config?: AnthropicOutputConfig; } interface OpenAITextPart { @@ -108,7 +117,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 +176,63 @@ 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 +): string | OpenAIContentPart[] { 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: OpenAIContentPart[] = []; + for (const block of content) { + if (block?.type === 'text' && typeof block.text === 'string') { + parts.push({ type: 'text', text: block.text }); + continue; + } + + if (block?.type === 'image') { + const source = block.source; + if (source?.type === 'url' && source.url) { + parts.push({ type: 'image_url', image_url: { url: source.url } }); + } else if (source?.type === 'base64' && source.media_type && source.data) { + parts.push({ + type: 'image_url', + image_url: { url: `data:${source.media_type};base64,${source.data}` }, + }); + } + continue; + } + + if (typeof block?.text === 'string') { + parts.push({ type: 'text', text: block.text }); + } + } + + if (parts.length === 0) return ''; + if (parts.length === 1 && parts[0].type === 'text') { + const text = (parts[0] as OpenAITextPart).text; + return isError ? `Error: ${text}` : text; + } + if (isError && parts[0]?.type === 'text') { + parts[0] = { ...parts[0], text: `Error: ${(parts[0] as OpenAITextPart).text}` }; + } else if (isError) { + parts.unshift({ type: 'text', text: 'Error:' }); + } + + return parts; } function createFallbackToolId(messageIndex: number, blockIndex: number): string { @@ -187,16 +241,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 +299,46 @@ 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: normalizeSchemaForOpenAI(rawSchema), + }, + }; + }); return tools.length > 0 ? tools : undefined; } 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"'); + return {}; } const effort = @@ -274,6 +355,29 @@ 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'); @@ -298,10 +402,65 @@ 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; + + 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') { + const text = typeof parsed.text === 'string' ? parsed.text : ''; + userParts.push({ type: 'text', text }); + return; + } + + if (isImageBlock(parsed)) { + userParts.push(toImagePart(parsed, `messages[${messageIndex}].content[${blockIndex}]`)); + return; + } + + if (isToolResultBlock(parsed)) { + 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: convertToolResultContent(parsed.content, parsed.is_error === true), + }); + return; + } + + if (isToolUseBlock(parsed)) { + return; + } + + throw new Error( + `messages[${messageIndex}].content[${blockIndex}].type "${String(parsed.type)}" is not supported` + ); + }); + + if (userParts.length > 0 || !sawToolResult) { + flushUserContent(translatedMessages, userParts); + } + return; + } + + // Assistant role const assistantTextParts: string[] = []; const toolCalls: NonNullable = []; - let sawToolResult = false; content.forEach((block, blockIndex) => { const parsed = assertObject( @@ -309,32 +468,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,52 +493,61 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { return; } - 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` - ), - }); + if (isImageBlock(parsed) || isToolResultBlock(parsed)) { return; } - - throw new Error( - `messages[${messageIndex}].content[${blockIndex}].type "${String(parsed.type)}" is not supported` - ); }); - if (role === 'assistant') { - translatedMessages.push({ - role: 'assistant', - content: assistantTextParts.join('\n'), - tool_calls: toolCalls.length > 0 ? toolCalls : undefined, - }); - return; - } - - if (userParts.length > 0 || !sawToolResult) { - flushUserContent(translatedMessages, userParts); - } + translatedMessages.push({ + role: 'assistant', + content: assistantTextParts.join('\n'), + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + }); }); 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; @@ -414,14 +567,14 @@ 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), + ...mapThinkingToReasoning(source.thinking, source.output_config), }; } } diff --git a/src/utils/schema-sanitizer.ts b/src/utils/schema-sanitizer.ts new file mode 100644 index 00000000..5a2d798e --- /dev/null +++ b/src/utils/schema-sanitizer.ts @@ -0,0 +1,284 @@ +/** + * Schema Sanitizer + * + * Strips JSON Schema keywords that OpenAI-compatible providers reject, + * cleans enum/const values, and normalizes type fields. + */ + +const OPENAI_INCOMPATIBLE_SCHEMA_KEYWORDS = new Set([ + '$comment', + '$schema', + 'default', + 'else', + 'examples', + 'format', + 'if', + 'maxLength', + 'maximum', + 'minLength', + 'minimum', + 'multipleOf', + 'pattern', + 'patternProperties', + 'propertyNames', + 'then', + 'unevaluatedProperties', +]); + +function isSchemaRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function stripSchemaKeywords(schema: unknown, keywords: Set): unknown { + if (Array.isArray(schema)) { + return schema.map((item) => stripSchemaKeywords(item, keywords)); + } + + if (!isSchemaRecord(schema)) { + return schema; + } + + const result: Record = {}; + for (const [key, value] of Object.entries(schema)) { + if (key === 'properties' && isSchemaRecord(value)) { + const sanitizedProps: Record = {}; + for (const [propName, propSchema] of Object.entries(value)) { + sanitizedProps[propName] = stripSchemaKeywords(propSchema, keywords); + } + result[key] = sanitizedProps; + continue; + } + + if (keywords.has(key)) { + continue; + } + + result[key] = stripSchemaKeywords(value, keywords); + } + + return result; +} + +function deepEqualJsonValue(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (typeof a !== typeof b) return false; + + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((value, index) => deepEqualJsonValue(value, b[index])); + } + + if (isSchemaRecord(a) && isSchemaRecord(b)) { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + return ( + aKeys.length === bKeys.length && + aKeys.every((key) => key in b && deepEqualJsonValue(a[key], b[key])) + ); + } + + return false; +} + +function matchesJsonSchemaType(type: string, value: unknown): boolean { + switch (type) { + case 'string': + return typeof value === 'string'; + case 'number': + return typeof value === 'number' && Number.isFinite(value); + case 'integer': + return typeof value === 'number' && Number.isInteger(value); + case 'boolean': + return typeof value === 'boolean'; + case 'object': + return value !== null && typeof value === 'object' && !Array.isArray(value); + case 'array': + return Array.isArray(value); + case 'null': + return value === null; + default: + return true; + } +} + +function getJsonSchemaTypes(record: Record): string[] { + const raw = record.type; + if (typeof raw === 'string') { + return [raw]; + } + if (Array.isArray(raw)) { + return raw.filter((value): value is string => typeof value === 'string'); + } + return []; +} + +function schemaAllowsValue(schema: Record, value: unknown): boolean { + if (Array.isArray(schema.anyOf)) { + return schema.anyOf.some((item) => + schemaAllowsValue(sanitizeSchemaForOpenAICompat(item), value) + ); + } + + if (Array.isArray(schema.oneOf)) { + return ( + schema.oneOf.filter((item) => schemaAllowsValue(sanitizeSchemaForOpenAICompat(item), value)) + .length === 1 + ); + } + + if (Array.isArray(schema.allOf)) { + return schema.allOf.every((item) => + schemaAllowsValue(sanitizeSchemaForOpenAICompat(item), value) + ); + } + + if ('const' in schema && !deepEqualJsonValue(schema.const, value)) { + return false; + } + + if (Array.isArray(schema.enum)) { + if (!schema.enum.some((item) => deepEqualJsonValue(item, value))) { + return false; + } + } + + const types = getJsonSchemaTypes(schema); + if (types.length > 0 && !types.some((type) => matchesJsonSchemaType(type, value))) { + return false; + } + + return true; +} + +function sanitizeTypeField(record: Record): void { + const allowed = new Set(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null']); + + const raw = record.type; + if (typeof raw === 'string') { + if (!allowed.has(raw)) delete record.type; + return; + } + + if (!Array.isArray(raw)) return; + + const filtered = raw.filter( + (value, index): value is string => + typeof value === 'string' && allowed.has(value) && raw.indexOf(value) === index + ); + + if (filtered.length === 0) { + delete record.type; + } else if (filtered.length === 1) { + record.type = filtered[0]; + } else { + record.type = filtered; + } +} + +export function sanitizeSchemaForOpenAICompat(schema: unknown): Record { + const stripped = stripSchemaKeywords(schema, OPENAI_INCOMPATIBLE_SCHEMA_KEYWORDS); + if (!isSchemaRecord(stripped)) { + return {}; + } + + const record = { ...stripped }; + + sanitizeTypeField(record); + + if (isSchemaRecord(record.properties)) { + const sanitizedProps: Record = {}; + for (const [key, value] of Object.entries(record.properties)) { + sanitizedProps[key] = sanitizeSchemaForOpenAICompat(value); + } + record.properties = sanitizedProps; + } + + if ('items' in record) { + if (Array.isArray(record.items)) { + record.items = record.items.map((item) => sanitizeSchemaForOpenAICompat(item)); + } else { + record.items = sanitizeSchemaForOpenAICompat(record.items); + } + } + + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + if (Array.isArray(record[key])) { + record[key] = (record[key] as unknown[]).map((item) => sanitizeSchemaForOpenAICompat(item)); + } + } + + const properties = isSchemaRecord(record.properties) ? record.properties : undefined; + + if (Array.isArray(record.required) && properties) { + record.required = record.required.filter( + (value): value is string => typeof value === 'string' && value in properties + ); + } + + const schemaWithoutEnum = { ...record }; + delete schemaWithoutEnum.enum; + + if (Array.isArray(record.enum)) { + const filteredEnum = record.enum.filter((value) => schemaAllowsValue(schemaWithoutEnum, value)); + if (filteredEnum.length > 0) { + record.enum = filteredEnum; + } else { + delete record.enum; + } + } + + const schemaWithoutConst = { ...record }; + delete schemaWithoutConst.const; + if ('const' in record && !schemaAllowsValue(schemaWithoutConst, record.const)) { + delete record.const; + } + + return record; +} + +/** + * Normalize a tool parameter schema for OpenAI-compatible providers. + * Strips incompatible keywords and optionally enforces strict mode + * (additionalProperties: false, required = all property keys). + */ +export function normalizeSchemaForOpenAI( + schema: Record, + strict = true +): Record { + const record = sanitizeSchemaForOpenAICompat(schema); + + if (record.type === 'object' && record.properties) { + const properties = record.properties as Record>; + const existingRequired = Array.isArray(record.required) ? (record.required as string[]) : []; + + const normalizedProps: Record = {}; + for (const [key, value] of Object.entries(properties)) { + normalizedProps[key] = normalizeSchemaForOpenAI(value as Record, strict); + } + record.properties = normalizedProps; + + record.required = existingRequired.filter((k) => k in normalizedProps); + if (strict) { + record.additionalProperties = false; + } + } + + if ('items' in record) { + if (Array.isArray(record.items)) { + record.items = (record.items as unknown[]).map((item) => + normalizeSchemaForOpenAI(item as Record, strict) + ); + } else { + record.items = normalizeSchemaForOpenAI(record.items as Record, strict); + } + } + + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + if (key in record && Array.isArray(record[key])) { + record[key] = (record[key] as unknown[]).map((item) => + normalizeSchemaForOpenAI(item as Record, strict) + ); + } + } + + return record; +} From 32d6bfdda7881a42a376d0973c021f0b47eed7e2 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 18 Apr 2026 19:02:38 -0400 Subject: [PATCH 2/5] fix(proxy): restore strict Anthropic message validation --- src/proxy/transformers/request-transformer.ts | 22 ++- .../request-transformer-regressions.test.ts | 140 ++++++++++++++++++ 2 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 tests/unit/proxy/transformers/request-transformer-regressions.test.ts diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts index da996ea8..c4547601 100644 --- a/src/proxy/transformers/request-transformer.ts +++ b/src/proxy/transformers/request-transformer.ts @@ -338,7 +338,7 @@ function mapThinkingToReasoning( } if (thinking.type !== 'enabled') { - return {}; + throw new Error('thinking.type must be "enabled", "adaptive", or "disabled"'); } const effort = @@ -444,7 +444,9 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { } if (isToolUseBlock(parsed)) { - return; + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] tool_use requires assistant role` + ); } throw new Error( @@ -493,11 +495,23 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { return; } - if (isImageBlock(parsed) || isToolResultBlock(parsed)) { - return; + if (isImageBlock(parsed)) { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] image requires user role` + ); + } + + if (isToolResultBlock(parsed)) { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] tool_result requires user role` + ); } }); + if (assistantTextParts.length === 0 && toolCalls.length === 0) { + return; + } + translatedMessages.push({ role: 'assistant', content: assistantTextParts.join('\n'), 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..b4c232c8 --- /dev/null +++ b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts @@ -0,0 +1,140 @@ +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('translates url images and error tool results while coalescing repeated turns', () => { + const result = new ProxyRequestTransformer().transform({ + 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' }, + { type: 'image', source: { type: 'url', url: 'https://example.com/error.png' } }, + ], + }, + ], + }, + ], + }); + + 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: [ + { type: 'text', text: 'Error: fetch failed' }, + { type: 'image_url', image_url: { url: 'https://example.com/error.png' } }, + ], + }); + }); +}); From 22ab58b02e5dc34f152ecce048c1fbf7d615bdfb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 18 Apr 2026 19:02:55 -0400 Subject: [PATCH 3/5] fix(proxy): preserve valid enums during schema normalization --- src/utils/schema-sanitizer.ts | 1 + tests/unit/utils/schema-sanitizer.test.ts | 85 +++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 tests/unit/utils/schema-sanitizer.test.ts diff --git a/src/utils/schema-sanitizer.ts b/src/utils/schema-sanitizer.ts index 5a2d798e..0639f14e 100644 --- a/src/utils/schema-sanitizer.ts +++ b/src/utils/schema-sanitizer.ts @@ -216,6 +216,7 @@ export function sanitizeSchemaForOpenAICompat(schema: unknown): Record schemaAllowsValue(schemaWithoutEnum, value)); diff --git a/tests/unit/utils/schema-sanitizer.test.ts b/tests/unit/utils/schema-sanitizer.test.ts new file mode 100644 index 00000000..baf3290b --- /dev/null +++ b/tests/unit/utils/schema-sanitizer.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'bun:test'; + +import { normalizeSchemaForOpenAI } from '../../../src/utils/schema-sanitizer'; + +describe('normalizeSchemaForOpenAI', () => { + it('strips incompatible keywords and enforces strict object schemas', () => { + const result = normalizeSchemaForOpenAI({ + type: 'object', + properties: { + query: { type: 'string', pattern: '^[a-z]+$', minLength: 3 }, + limit: { type: 'integer', minimum: 1 }, + }, + required: ['query', 'missing'], + additionalProperties: true, + default: { query: 'docs' }, + }); + + expect(result).toEqual({ + type: 'object', + properties: { + query: { type: 'string' }, + limit: { type: 'integer' }, + }, + required: ['query'], + additionalProperties: false, + }); + }); + + it('drops enum and const values that no longer match the schema type', () => { + const result = normalizeSchemaForOpenAI({ + type: 'string', + enum: ['ok', 1, null], + const: 1, + }); + + expect(result).toEqual({ + type: 'string', + enum: ['ok'], + }); + }); + + it('normalizes nested combinators and arrays recursively', () => { + const result = normalizeSchemaForOpenAI({ + anyOf: [ + { + type: 'object', + properties: { + image: { + type: 'array', + items: { + type: 'object', + properties: { + url: { type: 'string', format: 'uri' }, + }, + }, + }, + }, + }, + ], + }); + + expect(result).toEqual({ + anyOf: [ + { + type: 'object', + properties: { + image: { + type: 'array', + items: { + type: 'object', + properties: { + url: { type: 'string' }, + }, + required: [], + additionalProperties: false, + }, + }, + }, + required: [], + additionalProperties: false, + }, + ], + }); + }); +}); From ebc92194bb9bf2810e10f64de1fa6e599c2fa156 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 18 Apr 2026 19:39:13 -0400 Subject: [PATCH 4/5] fix(proxy): harden Anthropic request transformation semantics - enforce strict tool_result ordering and pairing against assistant tool_use ids - reject tool_result image payloads that cannot map to OpenAI tool messages - preserve raw tool schemas on the /v1/messages proxy path instead of silently tightening them - forward Anthropic tool_choice semantics and cover adaptive routing plus upstream payload checks --- src/proxy/transformers/request-transformer.ts | 198 ++++++++++++++---- .../proxy/messages-endpoint.test.ts | 68 ++++++ .../integration/proxy/request-routing.test.ts | 71 +++++++ .../request-transformer-regressions.test.ts | 133 +++++++++++- 4 files changed, 422 insertions(+), 48 deletions(-) diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts index c4547601..c1ebd5d7 100644 --- a/src/proxy/transformers/request-transformer.ts +++ b/src/proxy/transformers/request-transformer.ts @@ -1,5 +1,3 @@ -import { normalizeSchemaForOpenAI } from '../../utils/schema-sanitizer'; - interface AnthropicThinking { type?: 'enabled' | 'disabled' | 'adaptive' | string; budget_tokens?: number; @@ -50,6 +48,12 @@ 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; @@ -60,6 +64,7 @@ interface AnthropicProxyRequestShape { stop_sequences?: unknown; metadata?: unknown; tools?: unknown; + tool_choice?: AnthropicToolChoice; stream?: unknown; thinking?: AnthropicThinking; output_config?: AnthropicOutputConfig; @@ -109,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; @@ -181,10 +197,7 @@ function flattenTextContent(content: unknown, label: string): string { * Handles strings, arrays with text/image blocks, and error prefixing. * Ported from openclaude's convertToolResultContent. */ -function convertToolResultContent( - content: unknown, - isError: boolean -): string | OpenAIContentPart[] { +function convertToolResultContent(content: unknown, isError: boolean, label: string): string { if (content === undefined) { return ''; } @@ -196,43 +209,32 @@ function convertToolResultContent( return isError ? `Error: ${text}` : text; } - const parts: OpenAIContentPart[] = []; - for (const block of content) { - if (block?.type === 'text' && typeof block.text === 'string') { - parts.push({ type: 'text', text: block.text }); + 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 (block?.type === 'image') { - const source = block.source; - if (source?.type === 'url' && source.url) { - parts.push({ type: 'image_url', image_url: { url: source.url } }); - } else if (source?.type === 'base64' && source.media_type && source.data) { - parts.push({ - type: 'image_url', - image_url: { url: `data:${source.media_type};base64,${source.data}` }, - }); - } + 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; } - if (typeof block?.text === 'string') { - parts.push({ type: 'text', text: block.text }); - } + throw new Error(`${label}[${index}].type "${String(parsed.type)}" is not supported`); } - if (parts.length === 0) return ''; - if (parts.length === 1 && parts[0].type === 'text') { - const text = (parts[0] as OpenAITextPart).text; - return isError ? `Error: ${text}` : text; + const text = parts.join('\n'); + if (!text) { + return isError ? 'Error:' : ''; } - if (isError && parts[0]?.type === 'text') { - parts[0] = { ...parts[0], text: `Error: ${(parts[0] as OpenAITextPart).text}` }; - } else if (isError) { - parts.unshift({ type: 'text', text: 'Error:' }); - } - - return parts; + return isError ? `Error: ${text}` : text; } function createFallbackToolId(messageIndex: number, blockIndex: number): string { @@ -310,7 +312,7 @@ function transformTools(value: unknown): ProxyOpenAIRequest['tools'] { function: { name: typeof entry.name === 'string' ? entry.name : 'tool', ...(typeof entry.description === 'string' ? { description: entry.description } : {}), - parameters: normalizeSchemaForOpenAI(rawSchema), + parameters: rawSchema, }, }; }); @@ -318,6 +320,45 @@ function transformTools(value: unknown): ProxyOpenAIRequest['tools'] { 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, outputConfig: AnthropicOutputConfig | undefined @@ -384,6 +425,8 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { } const translatedMessages: OpenAIMessage[] = []; + let pendingToolUseIds: Set | null = null; + let hasPendingToolUseIds = false; messagesValue.forEach((message, messageIndex) => { const parsedMessage = assertObject(message, `messages[${messageIndex}]`) as AnthropicMessage; @@ -392,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; } @@ -405,6 +459,7 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { if (role === 'user') { const userParts: OpenAIContentPart[] = []; let sawToolResult = false; + const resolvedToolUseIds = new Set(); content.forEach((block, blockIndex) => { const parsed = assertObject( @@ -417,28 +472,62 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { } 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; - flushUserContent(translatedMessages, userParts); + 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), + content: convertToolResultContent( + parsed.content, + parsed.is_error === true, + `messages[${messageIndex}].content[${blockIndex}].content` + ), }); return; } @@ -454,7 +543,24 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { ); }); - if (userParts.length > 0 || !sawToolResult) { + 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; @@ -506,12 +612,20 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { `messages[${messageIndex}].content[${blockIndex}] tool_result requires user role` ); } + + throw new Error( + `messages[${messageIndex}].content[${blockIndex}].type "${String(parsed.type)}" is not supported` + ); }); if (assistantTextParts.length === 0 && toolCalls.length === 0) { return; } + pendingToolUseIds = + toolCalls.length > 0 ? new Set(toolCalls.map((toolCall) => toolCall.id)) : null; + hasPendingToolUseIds = toolCalls.length > 0; + translatedMessages.push({ role: 'assistant', content: assistantTextParts.join('\n'), @@ -519,6 +633,10 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { }); }); + if (hasPendingToolUseIds) { + throw new Error('messages must provide tool_result blocks for the latest assistant tool_use'); + } + return translatedMessages; } @@ -565,6 +683,7 @@ function coalesceMessages(messages: OpenAIMessage[]): OpenAIMessage[] { 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 = @@ -587,7 +706,8 @@ export class ProxyRequestTransformer { top_p: asNumber(source.top_p), stop: asStringArray(source.stop_sequences), metadata: asMetadata(source.metadata), - tools: transformTools(source.tools), + 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 index b4c232c8..e9a05c18 100644 --- a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts +++ b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts @@ -74,8 +74,114 @@ describe('ProxyRequestTransformer regressions', () => { ).toThrow('tool_result requires user role'); }); - it('translates url images and error tool results while coalescing repeated turns', () => { + 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', @@ -97,16 +203,19 @@ describe('ProxyRequestTransformer regressions', () => { type: 'tool_result', tool_use_id: 'toolu_1', is_error: true, - content: [ - { type: 'text', text: 'fetch failed' }, - { type: 'image', source: { type: 'url', url: 'https://example.com/error.png' } }, - ], + 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: [ @@ -131,10 +240,16 @@ describe('ProxyRequestTransformer regressions', () => { expect(result.messages[2]).toEqual({ role: 'tool', tool_call_id: 'toolu_1', - content: [ - { type: 'text', text: 'Error: fetch failed' }, - { type: 'image_url', image_url: { url: 'https://example.com/error.png' } }, - ], + 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'); + }); }); From 25193187e098ffeee52f020389cad042e178f4f9 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 18 Apr 2026 20:05:22 -0400 Subject: [PATCH 5/5] chore(proxy): remove unused schema sanitizer draft --- src/utils/schema-sanitizer.ts | 285 ---------------------- tests/unit/utils/schema-sanitizer.test.ts | 85 ------- 2 files changed, 370 deletions(-) delete mode 100644 src/utils/schema-sanitizer.ts delete mode 100644 tests/unit/utils/schema-sanitizer.test.ts diff --git a/src/utils/schema-sanitizer.ts b/src/utils/schema-sanitizer.ts deleted file mode 100644 index 0639f14e..00000000 --- a/src/utils/schema-sanitizer.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * Schema Sanitizer - * - * Strips JSON Schema keywords that OpenAI-compatible providers reject, - * cleans enum/const values, and normalizes type fields. - */ - -const OPENAI_INCOMPATIBLE_SCHEMA_KEYWORDS = new Set([ - '$comment', - '$schema', - 'default', - 'else', - 'examples', - 'format', - 'if', - 'maxLength', - 'maximum', - 'minLength', - 'minimum', - 'multipleOf', - 'pattern', - 'patternProperties', - 'propertyNames', - 'then', - 'unevaluatedProperties', -]); - -function isSchemaRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function stripSchemaKeywords(schema: unknown, keywords: Set): unknown { - if (Array.isArray(schema)) { - return schema.map((item) => stripSchemaKeywords(item, keywords)); - } - - if (!isSchemaRecord(schema)) { - return schema; - } - - const result: Record = {}; - for (const [key, value] of Object.entries(schema)) { - if (key === 'properties' && isSchemaRecord(value)) { - const sanitizedProps: Record = {}; - for (const [propName, propSchema] of Object.entries(value)) { - sanitizedProps[propName] = stripSchemaKeywords(propSchema, keywords); - } - result[key] = sanitizedProps; - continue; - } - - if (keywords.has(key)) { - continue; - } - - result[key] = stripSchemaKeywords(value, keywords); - } - - return result; -} - -function deepEqualJsonValue(a: unknown, b: unknown): boolean { - if (Object.is(a, b)) return true; - if (typeof a !== typeof b) return false; - - if (Array.isArray(a) && Array.isArray(b)) { - return a.length === b.length && a.every((value, index) => deepEqualJsonValue(value, b[index])); - } - - if (isSchemaRecord(a) && isSchemaRecord(b)) { - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - return ( - aKeys.length === bKeys.length && - aKeys.every((key) => key in b && deepEqualJsonValue(a[key], b[key])) - ); - } - - return false; -} - -function matchesJsonSchemaType(type: string, value: unknown): boolean { - switch (type) { - case 'string': - return typeof value === 'string'; - case 'number': - return typeof value === 'number' && Number.isFinite(value); - case 'integer': - return typeof value === 'number' && Number.isInteger(value); - case 'boolean': - return typeof value === 'boolean'; - case 'object': - return value !== null && typeof value === 'object' && !Array.isArray(value); - case 'array': - return Array.isArray(value); - case 'null': - return value === null; - default: - return true; - } -} - -function getJsonSchemaTypes(record: Record): string[] { - const raw = record.type; - if (typeof raw === 'string') { - return [raw]; - } - if (Array.isArray(raw)) { - return raw.filter((value): value is string => typeof value === 'string'); - } - return []; -} - -function schemaAllowsValue(schema: Record, value: unknown): boolean { - if (Array.isArray(schema.anyOf)) { - return schema.anyOf.some((item) => - schemaAllowsValue(sanitizeSchemaForOpenAICompat(item), value) - ); - } - - if (Array.isArray(schema.oneOf)) { - return ( - schema.oneOf.filter((item) => schemaAllowsValue(sanitizeSchemaForOpenAICompat(item), value)) - .length === 1 - ); - } - - if (Array.isArray(schema.allOf)) { - return schema.allOf.every((item) => - schemaAllowsValue(sanitizeSchemaForOpenAICompat(item), value) - ); - } - - if ('const' in schema && !deepEqualJsonValue(schema.const, value)) { - return false; - } - - if (Array.isArray(schema.enum)) { - if (!schema.enum.some((item) => deepEqualJsonValue(item, value))) { - return false; - } - } - - const types = getJsonSchemaTypes(schema); - if (types.length > 0 && !types.some((type) => matchesJsonSchemaType(type, value))) { - return false; - } - - return true; -} - -function sanitizeTypeField(record: Record): void { - const allowed = new Set(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null']); - - const raw = record.type; - if (typeof raw === 'string') { - if (!allowed.has(raw)) delete record.type; - return; - } - - if (!Array.isArray(raw)) return; - - const filtered = raw.filter( - (value, index): value is string => - typeof value === 'string' && allowed.has(value) && raw.indexOf(value) === index - ); - - if (filtered.length === 0) { - delete record.type; - } else if (filtered.length === 1) { - record.type = filtered[0]; - } else { - record.type = filtered; - } -} - -export function sanitizeSchemaForOpenAICompat(schema: unknown): Record { - const stripped = stripSchemaKeywords(schema, OPENAI_INCOMPATIBLE_SCHEMA_KEYWORDS); - if (!isSchemaRecord(stripped)) { - return {}; - } - - const record = { ...stripped }; - - sanitizeTypeField(record); - - if (isSchemaRecord(record.properties)) { - const sanitizedProps: Record = {}; - for (const [key, value] of Object.entries(record.properties)) { - sanitizedProps[key] = sanitizeSchemaForOpenAICompat(value); - } - record.properties = sanitizedProps; - } - - if ('items' in record) { - if (Array.isArray(record.items)) { - record.items = record.items.map((item) => sanitizeSchemaForOpenAICompat(item)); - } else { - record.items = sanitizeSchemaForOpenAICompat(record.items); - } - } - - for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { - if (Array.isArray(record[key])) { - record[key] = (record[key] as unknown[]).map((item) => sanitizeSchemaForOpenAICompat(item)); - } - } - - const properties = isSchemaRecord(record.properties) ? record.properties : undefined; - - if (Array.isArray(record.required) && properties) { - record.required = record.required.filter( - (value): value is string => typeof value === 'string' && value in properties - ); - } - - const schemaWithoutEnum = { ...record }; - delete schemaWithoutEnum.enum; - delete schemaWithoutEnum.const; - - if (Array.isArray(record.enum)) { - const filteredEnum = record.enum.filter((value) => schemaAllowsValue(schemaWithoutEnum, value)); - if (filteredEnum.length > 0) { - record.enum = filteredEnum; - } else { - delete record.enum; - } - } - - const schemaWithoutConst = { ...record }; - delete schemaWithoutConst.const; - if ('const' in record && !schemaAllowsValue(schemaWithoutConst, record.const)) { - delete record.const; - } - - return record; -} - -/** - * Normalize a tool parameter schema for OpenAI-compatible providers. - * Strips incompatible keywords and optionally enforces strict mode - * (additionalProperties: false, required = all property keys). - */ -export function normalizeSchemaForOpenAI( - schema: Record, - strict = true -): Record { - const record = sanitizeSchemaForOpenAICompat(schema); - - if (record.type === 'object' && record.properties) { - const properties = record.properties as Record>; - const existingRequired = Array.isArray(record.required) ? (record.required as string[]) : []; - - const normalizedProps: Record = {}; - for (const [key, value] of Object.entries(properties)) { - normalizedProps[key] = normalizeSchemaForOpenAI(value as Record, strict); - } - record.properties = normalizedProps; - - record.required = existingRequired.filter((k) => k in normalizedProps); - if (strict) { - record.additionalProperties = false; - } - } - - if ('items' in record) { - if (Array.isArray(record.items)) { - record.items = (record.items as unknown[]).map((item) => - normalizeSchemaForOpenAI(item as Record, strict) - ); - } else { - record.items = normalizeSchemaForOpenAI(record.items as Record, strict); - } - } - - for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { - if (key in record && Array.isArray(record[key])) { - record[key] = (record[key] as unknown[]).map((item) => - normalizeSchemaForOpenAI(item as Record, strict) - ); - } - } - - return record; -} diff --git a/tests/unit/utils/schema-sanitizer.test.ts b/tests/unit/utils/schema-sanitizer.test.ts deleted file mode 100644 index baf3290b..00000000 --- a/tests/unit/utils/schema-sanitizer.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it } from 'bun:test'; - -import { normalizeSchemaForOpenAI } from '../../../src/utils/schema-sanitizer'; - -describe('normalizeSchemaForOpenAI', () => { - it('strips incompatible keywords and enforces strict object schemas', () => { - const result = normalizeSchemaForOpenAI({ - type: 'object', - properties: { - query: { type: 'string', pattern: '^[a-z]+$', minLength: 3 }, - limit: { type: 'integer', minimum: 1 }, - }, - required: ['query', 'missing'], - additionalProperties: true, - default: { query: 'docs' }, - }); - - expect(result).toEqual({ - type: 'object', - properties: { - query: { type: 'string' }, - limit: { type: 'integer' }, - }, - required: ['query'], - additionalProperties: false, - }); - }); - - it('drops enum and const values that no longer match the schema type', () => { - const result = normalizeSchemaForOpenAI({ - type: 'string', - enum: ['ok', 1, null], - const: 1, - }); - - expect(result).toEqual({ - type: 'string', - enum: ['ok'], - }); - }); - - it('normalizes nested combinators and arrays recursively', () => { - const result = normalizeSchemaForOpenAI({ - anyOf: [ - { - type: 'object', - properties: { - image: { - type: 'array', - items: { - type: 'object', - properties: { - url: { type: 'string', format: 'uri' }, - }, - }, - }, - }, - }, - ], - }); - - expect(result).toEqual({ - anyOf: [ - { - type: 'object', - properties: { - image: { - type: 'array', - items: { - type: 'object', - properties: { - url: { type: 'string' }, - }, - required: [], - additionalProperties: false, - }, - }, - }, - required: [], - additionalProperties: false, - }, - ], - }); - }); -});