mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-16 14:22:34 +00:00
fix: strip unsupported gemini and codex tool fields
This commit is contained in:
@@ -22,7 +22,9 @@ import {
|
|||||||
extractProviderFromPathname,
|
extractProviderFromPathname,
|
||||||
getDeniedModelIdReasonForProvider,
|
getDeniedModelIdReasonForProvider,
|
||||||
normalizeModelIdForRouting,
|
normalizeModelIdForRouting,
|
||||||
|
stripCodexEffortSuffix,
|
||||||
} from './model-id-normalizer';
|
} from './model-id-normalizer';
|
||||||
|
import { getModelMaxLevel } from './model-catalog';
|
||||||
import { getCcsDir } from '../utils/config-manager';
|
import { getCcsDir } from '../utils/config-manager';
|
||||||
|
|
||||||
export interface ToolSanitizationProxyConfig {
|
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);
|
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 {
|
export class ToolSanitizationProxy {
|
||||||
private server: http.Server | null = null;
|
private server: http.Server | null = null;
|
||||||
private port: number | null = null;
|
private port: number | null = null;
|
||||||
@@ -184,6 +270,7 @@ export class ToolSanitizationProxy {
|
|||||||
const requestPath = req.url || '/';
|
const requestPath = req.url || '/';
|
||||||
const upstreamBase = new URL(this.config.upstreamBaseUrl);
|
const upstreamBase = new URL(this.config.upstreamBaseUrl);
|
||||||
const fullUpstreamUrl = new URL(requestPath, upstreamBase);
|
const fullUpstreamUrl = new URL(requestPath, upstreamBase);
|
||||||
|
const providerFromPath = extractProviderFromPathname(fullUpstreamUrl.pathname);
|
||||||
|
|
||||||
this.log(`${method} ${requestPath} → ${fullUpstreamUrl.href}`);
|
this.log(`${method} ${requestPath} → ${fullUpstreamUrl.href}`);
|
||||||
|
|
||||||
@@ -214,7 +301,6 @@ export class ToolSanitizationProxy {
|
|||||||
// Normalize dotted Claude model IDs for provider-compatible routing.
|
// Normalize dotted Claude model IDs for provider-compatible routing.
|
||||||
let modifiedBody = parsed;
|
let modifiedBody = parsed;
|
||||||
if (isRecord(modifiedBody) && typeof modifiedBody.model === 'string') {
|
if (isRecord(modifiedBody) && typeof modifiedBody.model === 'string') {
|
||||||
const providerFromPath = extractProviderFromPathname(fullUpstreamUrl.pathname);
|
|
||||||
const deniedReason = getDeniedModelIdReasonForProvider(
|
const deniedReason = getDeniedModelIdReasonForProvider(
|
||||||
modifiedBody.model,
|
modifiedBody.model,
|
||||||
providerFromPath
|
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)
|
// 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 };
|
modifiedBody = { ...modifiedBody, tools: sanitizedTools };
|
||||||
|
|
||||||
// Log sanitization warnings
|
// 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 () => {
|
it('preserves other tool properties during sanitization', async () => {
|
||||||
const proxy = new ToolSanitizationProxy({
|
const proxy = new ToolSanitizationProxy({
|
||||||
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||||
@@ -341,7 +555,10 @@ describe('ToolSanitizationProxy Integration', () => {
|
|||||||
{
|
{
|
||||||
name: 'foo__bar__bar',
|
name: 'foo__bar__bar',
|
||||||
description: 'Test description',
|
description: 'Test description',
|
||||||
input_schema: { type: 'object', properties: { x: { type: 'string' } } },
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { x: { type: 'string', examples: ['demo'] } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user