mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 10:43:43 +03:00
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:
@@ -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",
|
||||
|
||||
46
open-sse/handlers/chatCore/openAICompatibleTools.ts
Normal file
46
open-sse/handlers/chatCore/openAICompatibleTools.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
49
tests/unit/openai-compatible-tools.test.ts
Normal file
49
tests/unit/openai-compatible-tools.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
23
tests/unit/request-tool-identity-dotted-alias.test.ts
Normal file
23
tests/unit/request-tool-identity-dotted-alias.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user