src/server-side-tools.js
Rama master · réplica de lectura del repositorio original.
/**
* Tool types this proxy cannot execute, shared by the OpenAI-family surfaces.
*
* A tool is "server-executed" when its execution lives on the server side, not
* the client's: file_search, computer_use_preview, mcp, code_interpreter,
* image_generation, local_shell, shell and the hosted apply_patch. Hosted search
* (web_search / web_search_preview) is the same contract — the caller is asking
* THIS SERVER to run the search. This proxy has no executor for any of them, so
* /v1/responses and /v1/chat/completions refuse them up front with an actionable
* 400 instead of running a turn whose tool cannot exist.
*
* Refusing is the point. /v1/responses used to drop the three oldest types in
* silence and every later one through its `unknown tool type` catch-all; the
* chat surface dropped ALL of them silently — every consumer filters on
* `type === 'function'` (the text preamble in tool-emulation.js and the native
* #10 ToolDef encoder in devin-connect.js). Either way the client declared a
* server-executed tool, received a normal answer, and never learned the tool did
* not exist (evidence/DataAuditTools/findings.md F3).
*
* The one ambiguity is apply_patch: `{type:'apply_patch'}` is the hosted tool and
* is refused, while `{type:'function'|'custom', name:'apply_patch'}` is the
* client-run tool codex clients declare and passes through untouched. Only
* `tool.type` decides.
*
* Both surfaces decide with these predicates so the set cannot drift apart.
*/
const UNBRIDGED_SERVER_SIDE_TYPES = new Set([
'file_search',
'computer_use_preview',
'mcp',
'code_interpreter',
'image_generation',
'local_shell',
'shell',
'apply_patch',
]);
// Server-executed tools are a VERSIONED family too: OpenAI ships dated snapshots
// (`code_interpreter_2025_03_11`) next to the unversioned aliases, and every
// spelling asks THIS SERVER to execute the tool. An exact-match set sent each
// new snapshot to the catch-all, which warned and dropped it — a 200 with the
// tool gone. Strip a trailing `_YYYY_MM_DD` and re-test the set: that covers
// every current and future snapshot of a member, and a type that is not a
// member either way still falls to the lenient warn+drop path.
const SERVER_SIDE_VERSION_SUFFIX = /_\d{4}_\d{2}_\d{2}$/;
export function isServerSideToolType(type) {
if (typeof type !== 'string') return false;
if (UNBRIDGED_SERVER_SIDE_TYPES.has(type)) return true;
return UNBRIDGED_SERVER_SIDE_TYPES.has(type.replace(SERVER_SIDE_VERSION_SUFFIX, ''));
}
const HOSTED_SEARCH_TYPES = new Set(['web_search', 'web_search_preview']);
// Hosted search is a VERSIONED tool family: OpenAI ships dated snapshots of the
// same hosted tool (`web_search_preview_2025_03_11`, `web_search_2025_08_26`)
// next to the unversioned aliases. Every spelling asks THIS SERVER to execute
// the search, so the gate matches the family. An exact-match list forwards the
// next snapshot to the model and then drops it — the silent non-execution this
// rejection exists to prevent.
const HOSTED_SEARCH_VERSIONED_TYPE = /^web_search(?:_preview)?_\d{4}_\d{2}_\d{2}$/;
export function isHostedSearchType(type) {
return typeof type === 'string'
&& (HOSTED_SEARCH_TYPES.has(type) || HOSTED_SEARCH_VERSIONED_TYPE.test(type));
}
/**
* /v1/responses refusal for a hosted search tool. Kept separate from the
* server-executed error because the Responses dialect has its own code
* (`unsupported_hosted_tool`) and clients branch on it.
*/
export function hostedSearchUnsupported(param) {
const err = new Error(
'Hosted web_search is not supported by this proxy: it does not execute server-side searches. '
+ 'Remove the hosted web_search/web_search_preview tool (including its versioned aliases) and, '
+ 'if the client will run the search itself, declare a normal function tool named "web_search".',
);
err.param = param;
err.code = 'unsupported_hosted_tool';
return err;
}
/**
* /v1/responses refusal for a server-executed tool.
*/
export function serverSideToolUnsupported(type, param) {
const err = new Error(
`Server-executed tool "${type}" is not supported by this proxy: it requires an executor that runs on `
+ 'this server, and this proxy has none. Remove it and, if the client will run the tool itself, declare '
+ `a normal function tool instead. Rejected server-side types: ${[...UNBRIDGED_SERVER_SIDE_TYPES].sort().join(', ')}.`,
);
err.param = param;
err.code = 'unsupported_server_side_tool';
return err;
}
/**
* /v1/chat/completions refusal. The chat dialect carries ONE code for both
* families (`unsupported_server_side_tool`) because chat clients written against
* the 2025 surface branch on that family; the message still names the concrete
* type and the workaround.
*/
export function chatServerSideToolUnsupported(type, param = 'tools') {
const detail = isHostedSearchType(type)
? `Hosted tool "${type}" is not supported by this proxy: it does not execute server-side web searches. `
+ 'Remove it and, if the client will run the search itself, declare a normal function tool named "web_search".'
: `Server-executed tool "${type}" is not supported by this proxy: it requires an executor that runs on `
+ 'this server, and this proxy has none. Remove it and, if the client will run the tool itself, declare '
+ 'a normal function tool instead.';
const err = new Error(
`${detail} Rejected types: ${[...UNBRIDGED_SERVER_SIDE_TYPES].sort().join(', ')}, `
+ 'plus hosted web_search/web_search_preview (versioned snapshots included).',
);
err.param = param;
err.code = 'unsupported_server_side_tool';
return err;
}
// A namespace may carry its children under any of these keys. The gates walk
// EVERY array-valued one: a namespace with `tools: []` and `children: [mcp]`
// used to walk the empty first key and miss the server-executed child entirely,
// returning a 200 with that tool gone and not even a warning.
// (responses.js flattenResponseTool still picks a single key when forwarding;
// that selection is unchanged — this walk only decides what must be refused.)
const NAMESPACE_CHILD_KEYS = ['tools', 'children', 'functions', 'items'];
/**
* Visit every tool in a list, recursing into namespaces. Shared by the
* hosted-search and server-executed gates so both cover the same positions.
*/
export function forEachTool(tools, visit) {
for (const tool of tools || []) {
if (!tool || typeof tool !== 'object') continue;
visit(tool);
if (tool.type === 'namespace') {
for (const key of NAMESPACE_CHILD_KEYS) {
if (Array.isArray(tool[key])) forEachTool(tool[key], visit);
}
}
}
}
/**
* First tool type in `tools` (namespaces included) that this proxy cannot
* execute, or null. The chat admission gate reports one 400 for the request.
*/
export function firstUnsupportedServerToolType(tools) {
let found = null;
forEachTool(tools, (tool) => {
if (found) return;
if (isHostedSearchType(tool.type) || isServerSideToolType(tool.type)) found = tool.type;
});
return found;
}
// ─── Gemini native server-side tools ────────────────────────────────────────
//
// Gemini declares server-executed tools as BARE KEYS on a tool object
// (`{googleSearch:{}}`, `{codeExecution:{}}`, `{urlContext:{}}`) rather than as
// a `type` string. The translator used to count them and drop them with a log
// line, so a spec-conforming Gemini client received a normal answer for a turn
// whose search/execution never happened — the same silent non-execution that
// the OpenAI-family gates above exist to prevent (Q4 G06). They are refused
// here, through the same `unsupported_server_side_tool` code, so every surface
// reports the same typed error.
//
// `googleSearchRetrieval` is the pre-2.0 alias for googleSearch; both spellings
// ask this server to run the search. googleMaps / fileSearch / computerUse /
// mcpServers are also server-executed, but no client in the repo sends them and
// the audit scoped this gate to the three documented tools; leave the rest to
// the catch-all rather than widening the refusal without a measured caller.
const GEMINI_SERVER_SIDE_TOOL_KEYS = ['googleSearch', 'googleSearchRetrieval', 'codeExecution', 'urlContext'];
// Server-executed tools deliberately OUTSIDE the refusal set: googleMaps /
// fileSearch / computerUse / mcpServers also run on Google's side, but no
// client in the repo sends them and the audit scoped the 400 to the four
// documented keys above. They are still dropped before translation, so the
// handler warns once per request rather than letting a spec-shaped tool
// vanish with no trace.
const GEMINI_UNLISTED_SERVER_SIDE_TOOL_KEYS = ['googleMaps', 'fileSearch', 'computerUse', 'mcpServers'];
/** First Gemini server-executed tool key declared in `tools`, or null. */
export function firstUnsupportedGeminiServerTool(tools) {
for (const tool of tools || []) {
if (!tool || typeof tool !== 'object') continue;
for (const key of GEMINI_SERVER_SIDE_TOOL_KEYS) {
// Presence, not truthiness: {googleSearch:false} or {googleSearch:null}
// is still a declaration of a tool this proxy cannot execute — the key
// being there at all is what asks this server to run it.
if (Object.hasOwn(tool, key)) return key;
}
}
return null;
}
/** Unlisted Gemini server-side tool keys declared in `tools`, deduplicated. */
export function droppedGeminiServerToolNames(tools) {
const found = new Set();
for (const tool of tools || []) {
if (!tool || typeof tool !== 'object') continue;
for (const key of GEMINI_UNLISTED_SERVER_SIDE_TOOL_KEYS) {
if (Object.hasOwn(tool, key)) found.add(key);
}
}
return [...found];
}
/**
* Gemini-surface refusal for a server-executed tool. The Gemini error envelope
* is built by `geminiError`; this only carries the typed code + the message so
* the handler can map it without inventing a second vocabulary.
*/
export function geminiServerSideToolUnsupported(name, param = 'tools') {
const err = new Error(
`Gemini server-side tool "${name}" is not supported by this proxy: it requires an executor that runs on `
+ 'this server, and this proxy has none. Remove it and, if the client will run the tool itself, declare '
+ `a normal functionDeclaration instead. Rejected tools: ${GEMINI_SERVER_SIDE_TOOL_KEYS.join(', ')}.`,
);
err.param = param;
err.code = 'unsupported_server_side_tool';
err.status = 400;
return err;
}