fix: strip unsupported gemini and codex tool fields

This commit is contained in:
Tam Nhu Tran
2026-03-07 09:54:02 +07:00
parent 209b088bed
commit 8cfd86f1d1
2 changed files with 330 additions and 3 deletions
+112 -2
View File
@@ -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<string, unknown> {
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<string> | 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<string>
): {
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
@@ -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<string, unknown>).tools as Array<
Record<string, unknown>
>;
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<string, unknown>).tools as Array<
Record<string, unknown>
>;
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<string, unknown>).tools as Array<
Record<string, unknown>
>;
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<string, unknown>).tools as Array<
Record<string, unknown>
>;
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'] } },
},
},
],
}),