fix(translator): preserve Responses custom tools for OpenAI-compatible providers (#10114)

* fix(translator): preserve Responses custom tools

* fix(translator): preserve Responses namespace tools

* fix(translator): restore dotted namespace tool aliases

---------

Co-authored-by: mtb-ninja <mtb-ninja@users.noreply.github.com>
This commit is contained in:
mtb-ninja
2026-08-12 21:59:37 -06:00
committed by GitHub
parent b9319d05aa
commit 3b41e795fc
5 changed files with 166 additions and 33 deletions

View File

@@ -1,6 +1,7 @@
import { extractRequestToolIdentityMap } from "./chatCore/requestToolIdentity.ts";
import { injectMemoryAndSkills } from "./chatCore/memorySkillsInjection.ts";
import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts";
import { normalizeOpenAICompatibleTools } from "./chatCore/openAICompatibleTools.ts";
import { buildFailureUsageRecord } from "./chatCore/failureUsage.ts";
import { estimateFinalInputTokens } from "./chatCore/contextEstimation.ts";
import { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts";
@@ -2162,26 +2163,12 @@ export async function handleChatCore({
// - tools without a name AND without .function → dropped (unconvertible)
// This must happen before translateRequest, which validates and throws on unknown types.
if (provider?.startsWith("openai-compatible-") && Array.isArray(translatedBody.tools)) {
const before = (translatedBody.tools as unknown[]).length;
translatedBody.tools = (translatedBody.tools as Record<string, unknown>[])
.filter((t) => !t.type || t.type === "function" || !!t.function || !!t.name)
.map((t) => {
if (!t.type || t.type === "function" || t.function) return t;
// Named non-function tool: normalise to function format so the translator
// does not throw on the unknown type.
return {
type: "function",
function: {
name: t.name,
...(t.description === undefined ? {} : { description: t.description }),
...(t.parameters !== undefined || t.input_schema !== undefined
? { parameters: t.parameters ?? t.input_schema ?? {} }
: {}),
...(t.strict === undefined ? {} : { strict: t.strict }),
},
};
});
const dropped = before - (translatedBody.tools as unknown[]).length;
const normalized = normalizeOpenAICompatibleTools(
translatedBody.tools as Record<string, unknown>[],
sourceFormat
);
translatedBody.tools = normalized.tools;
const { dropped } = normalized;
if (dropped > 0) {
log?.debug?.(
"TOOLS",

View File

@@ -0,0 +1,46 @@
import { FORMATS } from "../../translator/formats.ts";
type Tool = Record<string, unknown>;
export function normalizeOpenAICompatibleTools(
tools: Tool[],
sourceFormat: string
): { tools: Tool[]; dropped: number } {
// The Responses translator has dedicated handling for custom, namespace,
// tool_search, local_shell, and hosted tool types. Normalizing any of them
// here destroys information before that format-aware conversion can run.
if (sourceFormat === FORMATS.OPENAI_RESPONSES) {
return { tools, dropped: 0 };
}
const before = tools.length;
const normalized = tools
.filter((tool) =>
!tool.type || tool.type === "function" || !!tool.function || !!tool.name
)
.map((tool) => {
// Responses custom tools carry free-form input. Preserve their native shape so
// the Responses translator can produce the required { input: string } schema.
if (
!tool.type ||
tool.type === "function" ||
tool.function
) {
return tool;
}
return {
type: "function",
function: {
name: tool.name,
...(tool.description === undefined ? {} : { description: tool.description }),
...(tool.parameters !== undefined || tool.input_schema !== undefined
? { parameters: tool.parameters ?? tool.input_schema ?? {} }
: {}),
...(tool.strict === undefined ? {} : { strict: tool.strict }),
},
};
});
return { tools: normalized, dropped: before - normalized.length };
}

View File

@@ -1,17 +1,45 @@
/** * Resolve a flattened Chat function name back to the identity declared by the * request's Responses namespace tool. The request path supplies this map on * the response translation state; this resolver intentionally never parses a name. */ export function resolveRequestToolIdentity(
identityMap: unknown,
toolName: string
) {
if (!toolName || !identityMap) return null;
const identity =
identityMap instanceof Map
? identityMap.get(toolName)
: typeof identityMap === "object" && !Array.isArray(identityMap)
? (identityMap as Record<string, unknown>)[toolName]
: undefined;
if (!identity || typeof identity !== "object" || Array.isArray(identity)) return null;
const { namespace, name } = identity as Record<string, unknown>;
type RequestToolIdentity = { namespace: string; name: string };
function asRequestToolIdentity(value: unknown): RequestToolIdentity | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const { namespace, name } = value as Record<string, unknown>;
return typeof namespace === "string" && namespace && typeof name === "string" && name
? { namespace, name }
: null;
}
/**
* Resolve a flattened Chat function name back to the identity declared by the
* request's Responses namespace tool. The request path supplies this map on
* the response translation state.
*
* Some model-specific tool parsers render the registered `namespace__leaf`
* wire name as `namespace.leaf`. Accept that spelling only when it matches an
* identity already present in the request ledger; never infer a namespace by
* splitting an otherwise unknown tool name.
*/
export function resolveRequestToolIdentity(identityMap: unknown, toolName: string) {
if (!toolName) return null;
const direct =
identityMap instanceof Map
? identityMap.get(toolName)
: identityMap && typeof identityMap === "object" && !Array.isArray(identityMap)
? (identityMap as Record<string, unknown>)[toolName]
: undefined;
const directIdentity = asRequestToolIdentity(direct);
if (directIdentity) return directIdentity;
const candidates =
identityMap instanceof Map
? identityMap.values()
: identityMap && typeof identityMap === "object" && !Array.isArray(identityMap)
? Object.values(identityMap as Record<string, unknown>)
: [];
for (const candidate of candidates) {
const identity = asRequestToolIdentity(candidate);
if (identity && `${identity.namespace}.${identity.name}` === toolName) return identity;
}
return null;
}

View File

@@ -0,0 +1,49 @@
import test from "node:test";
import assert from "node:assert/strict";
import { normalizeOpenAICompatibleTools } from "../../open-sse/handlers/chatCore/openAICompatibleTools.ts";
import { FORMATS } from "../../open-sse/translator/formats.ts";
test("preserves all Responses tool types for the Responses translator", () => {
const customTool = {
type: "custom",
name: "exec",
description: "Run a shell command",
};
const namespaceTool = {
type: "namespace",
name: "functions",
tools: [customTool],
};
const localShellTool = { type: "local_shell" };
const result = normalizeOpenAICompatibleTools(
[customTool, namespaceTool, localShellTool],
FORMATS.OPENAI_RESPONSES
);
assert.deepEqual(result, {
tools: [customTool, namespaceTool, localShellTool],
dropped: 0,
});
});
test("normalizes named non-function tools for other source formats", () => {
const result = normalizeOpenAICompatibleTools(
[{ type: "custom", name: "exec", input_schema: { type: "object" } }],
FORMATS.OPENAI
);
assert.deepEqual(result, {
tools: [
{
type: "function",
function: {
name: "exec",
parameters: { type: "object" },
},
},
],
dropped: 0,
});
});

View File

@@ -0,0 +1,23 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveRequestToolIdentity } from "../../open-sse/translator/response/openai-responses/requestToolIdentity.ts";
test("restores a dotted namespace alias from the request identity ledger", () => {
const identity = { namespace: "exec", name: "exec_command" };
const identityMap = new Map([["exec__exec_command", identity]]);
assert.deepEqual(resolveRequestToolIdentity(identityMap, "exec__exec_command"), identity);
assert.deepEqual(resolveRequestToolIdentity(identityMap, "exec.exec_command"), identity);
assert.equal(resolveRequestToolIdentity(identityMap, "other.exec_command"), null);
});
test("prefers an exact map entry over dotted-alias fallback", () => {
const exact = { namespace: "literal", name: "tool" };
const identityMap = new Map([
["exec__exec_command", { namespace: "exec", name: "exec_command" }],
["exec.exec_command", exact],
]);
assert.deepEqual(resolveRequestToolIdentity(identityMap, "exec.exec_command"), exact);
});