diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index 2c9e31c1..2ef0a572 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -22,7 +22,9 @@ import { extractProviderFromPathname, getDeniedModelIdReasonForProvider, normalizeModelIdForRouting, + stripCodexEffortSuffix, } from './model-id-normalizer'; +import { getModelMaxLevel } from './model-catalog'; import { getCcsDir } from '../utils/config-manager'; export interface ToolSanitizationProxyConfig { @@ -44,6 +46,90 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +const GEMINI_UNSUPPORTED_TOOL_FIELDS = new Set([ + 'strict', + 'input_examples', + 'type', + 'cache_control', + 'defer_loading', +]); + +const CODEX_UNSUPPORTED_TOOL_FIELDS = new Set(['cache_control', 'defer_loading']); + +function isKnownCodexModelId(model: string | undefined): boolean { + const normalizedModel = model?.trim().toLowerCase(); + if (!normalizedModel) { + return false; + } + + return getModelMaxLevel('codex', stripCodexEffortSuffix(normalizedModel)) !== undefined; +} + +function getUnsupportedToolFields( + providerFromPath: string | null, + model: string | undefined +): ReadonlySet | null { + const normalizedProvider = providerFromPath?.trim().toLowerCase() ?? null; + const normalizedModel = model?.trim().toLowerCase(); + + if ( + normalizedProvider === 'gemini' || + normalizedProvider === 'gemini-cli' || + (normalizedProvider === null && normalizedModel?.startsWith('gemini-')) + ) { + return GEMINI_UNSUPPORTED_TOOL_FIELDS; + } + + if ( + normalizedProvider === 'codex' || + (normalizedProvider === null && isKnownCodexModelId(model)) + ) { + return CODEX_UNSUPPORTED_TOOL_FIELDS; + } + + return null; +} + +function stripUnsupportedToolFields( + tools: Tool[], + unsupportedFields: ReadonlySet +): { + tools: Tool[]; + removedByTool: Array<{ name: string; removed: string[] }>; + totalRemoved: number; +} { + const removedByTool: Array<{ name: string; removed: string[] }> = []; + let totalRemoved = 0; + + const sanitizedTools = tools.map((tool) => { + const sanitizedTool = { ...tool }; + const removed: string[] = []; + + for (const field of unsupportedFields) { + if (field in sanitizedTool) { + delete sanitizedTool[field]; + removed.push(field); + } + } + + if (removed.length > 0) { + removedByTool.push({ + name: tool.name, + removed, + }); + totalRemoved += removed.length; + } + + return sanitizedTool; + }); + + return { + tools: sanitizedTools, + removedByTool, + totalRemoved, + }; +} + export class ToolSanitizationProxy { private server: http.Server | null = null; private port: number | null = null; @@ -184,6 +270,7 @@ export class ToolSanitizationProxy { const requestPath = req.url || '/'; const upstreamBase = new URL(this.config.upstreamBaseUrl); const fullUpstreamUrl = new URL(requestPath, upstreamBase); + const providerFromPath = extractProviderFromPathname(fullUpstreamUrl.pathname); this.log(`${method} ${requestPath} → ${fullUpstreamUrl.href}`); @@ -214,7 +301,6 @@ export class ToolSanitizationProxy { // Normalize dotted Claude model IDs for provider-compatible routing. let modifiedBody = parsed; if (isRecord(modifiedBody) && typeof modifiedBody.model === 'string') { - const providerFromPath = extractProviderFromPathname(fullUpstreamUrl.pathname); const deniedReason = getDeniedModelIdReasonForProvider( modifiedBody.model, providerFromPath @@ -253,8 +339,32 @@ export class ToolSanitizationProxy { ); } + let rewrittenTools = schemaResult.tools as Tool[]; + const unsupportedToolFields = + isRecord(modifiedBody) && typeof modifiedBody.model === 'string' + ? getUnsupportedToolFields(providerFromPath, modifiedBody.model) + : getUnsupportedToolFields(providerFromPath, undefined); + + if (unsupportedToolFields) { + const fieldResult = stripUnsupportedToolFields(rewrittenTools, unsupportedToolFields); + + if (fieldResult.totalRemoved > 0) { + for (const entry of fieldResult.removedByTool) { + this.writeLog( + 'warn', + `[tool-sanitization-proxy] Tool fields stripped for "${entry.name}" (${providerFromPath ?? 'model-routed'}): ${entry.removed.join(', ')}` + ); + } + this.log( + `Stripped ${fieldResult.totalRemoved} unsupported top-level tool field(s) across ${fieldResult.removedByTool.length} tool(s)` + ); + } + + rewrittenTools = fieldResult.tools; + } + // Step 2: Sanitize tool names (truncate to 64 chars for Gemini) - const sanitizedTools = mapper.registerTools(schemaResult.tools as Tool[]); + const sanitizedTools = mapper.registerTools(rewrittenTools); modifiedBody = { ...modifiedBody, tools: sanitizedTools }; // Log sanitization warnings diff --git a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts index 7f591232..8bb9a66a 100644 --- a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts +++ b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts @@ -326,6 +326,220 @@ describe('ToolSanitizationProxy Integration', () => { } }); + it('strips Gemini-unsupported top-level tool fields while keeping schema sanitization', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/api/provider/gemini/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gemini-2.5-pro', + tools: [ + { + name: 'tool__with__duplicate__duplicate', + description: 'Test description', + strict: true, + input_examples: [{ command: 'ls -la' }], + type: 'custom', + cache_control: { type: 'ephemeral' }, + defer_loading: true, + input_schema: { + type: 'object', + properties: { + command: { + type: 'string', + examples: ['ls -la'], + }, + }, + }, + }, + ], + }), + }); + + const sentTools = (lastRequest!.body as Record).tools as Array< + Record + >; + expect(sentTools[0].name).toBe('tool__with__duplicate'); + expect(sentTools[0].description).toBe('Test description'); + expect(sentTools[0].strict).toBeUndefined(); + expect(sentTools[0].input_examples).toBeUndefined(); + expect(sentTools[0].type).toBeUndefined(); + expect(sentTools[0].cache_control).toBeUndefined(); + expect(sentTools[0].defer_loading).toBeUndefined(); + expect(sentTools[0].input_schema).toEqual({ + type: 'object', + properties: { + command: { + type: 'string', + }, + }, + }); + } finally { + proxy.stop(); + } + }); + + it('strips Codex-unsupported top-level tool fields before forwarding', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-5.3-codex', + tools: [ + { + name: 'codex_tool', + description: 'Codex test', + cache_control: { type: 'ephemeral' }, + defer_loading: true, + input_schema: { + type: 'object', + properties: { + prompt: { + type: 'string', + examples: ['fix the failing test'], + }, + }, + }, + }, + ], + }), + }); + + const sentTools = (lastRequest!.body as Record).tools as Array< + Record + >; + expect(sentTools[0].name).toBe('codex_tool'); + expect(sentTools[0].description).toBe('Codex test'); + expect(sentTools[0].cache_control).toBeUndefined(); + expect(sentTools[0].defer_loading).toBeUndefined(); + expect(sentTools[0].input_schema).toEqual({ + type: 'object', + properties: { + prompt: { + type: 'string', + }, + }, + }); + } finally { + proxy.stop(); + } + }); + + it('strips Codex-unsupported top-level tool fields on root model-routed requests', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-5.3-codex-xhigh', + tools: [ + { + name: 'root_codex_tool', + description: 'Root-routed Codex test', + cache_control: { type: 'ephemeral' }, + defer_loading: true, + input_schema: { + type: 'object', + properties: { + prompt: { + type: 'string', + examples: ['fix the failing test'], + }, + }, + }, + }, + ], + }), + }); + + const sentTools = (lastRequest!.body as Record).tools as Array< + Record + >; + expect(sentTools[0].name).toBe('root_codex_tool'); + expect(sentTools[0].description).toBe('Root-routed Codex test'); + expect(sentTools[0].cache_control).toBeUndefined(); + expect(sentTools[0].defer_loading).toBeUndefined(); + expect(sentTools[0].input_schema).toEqual({ + type: 'object', + properties: { + prompt: { + type: 'string', + }, + }, + }); + } finally { + proxy.stop(); + } + }); + + it('preserves top-level tool fields for non-target root routes', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'claude-sonnet-4-6', + tools: [ + { + name: 'claude_tool__duplicate__duplicate', + description: 'Anthropic passthrough test', + cache_control: { type: 'ephemeral' }, + defer_loading: true, + input_schema: { + type: 'object', + properties: { + prompt: { + type: 'string', + examples: ['keep these top-level fields'], + }, + }, + }, + }, + ], + }), + }); + + const sentTools = (lastRequest!.body as Record).tools as Array< + Record + >; + expect(sentTools[0].name).toBe('claude_tool__duplicate'); + expect(sentTools[0].description).toBe('Anthropic passthrough test'); + expect(sentTools[0].cache_control).toEqual({ type: 'ephemeral' }); + expect(sentTools[0].defer_loading).toBe(true); + expect(sentTools[0].input_schema).toEqual({ + type: 'object', + properties: { + prompt: { + type: 'string', + }, + }, + }); + } finally { + proxy.stop(); + } + }); + it('preserves other tool properties during sanitization', async () => { const proxy = new ToolSanitizationProxy({ upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, @@ -341,7 +555,10 @@ describe('ToolSanitizationProxy Integration', () => { { name: 'foo__bar__bar', description: 'Test description', - input_schema: { type: 'object', properties: { x: { type: 'string' } } }, + input_schema: { + type: 'object', + properties: { x: { type: 'string', examples: ['demo'] } }, + }, }, ], }),