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; +}