fix(skills): encode tool names with @ and . for providers rejecting them

Skill tools were advertised as 'name@version' (e.g. test-fr2@1.0.0), but
DeepSeek/Groq/OpenAI reject function names not matching ^[a-zA-Z0-9_-]+$.
Names already valid are left untouched; invalid ones are reversibly encoded
as omr_skill_<base64url> and decoded in interception before registry lookup.
This commit is contained in:
Egor
2026-07-31 12:30:29 +03:00
parent aefab44503
commit 3eca8d9c0c
2 changed files with 38 additions and 8 deletions

View File

@@ -25,11 +25,37 @@ interface GeminiTool {
parameters: Record<string, unknown>;
}
// Provider tool/function names must match ^[a-zA-Z0-9_-]+$ (OpenAI, DeepSeek,
// Groq, etc.). Skill identifiers are name@version (and names may contain any
// characters), so encode identifiers that would violate the pattern into a
// reversible base64url form. decodeSkillToolName() must be applied on the way
// back in interception before resolving against the registry.
const SKILL_TOOL_NAME_PREFIX = "omr_skill_";
export function encodeSkillToolName(name: string, version: string): string {
const identifier = `${name}@${version}`;
if (/^[a-zA-Z0-9_-]+$/.test(identifier)) {
return identifier;
}
return `${SKILL_TOOL_NAME_PREFIX}${Buffer.from(identifier, "utf8").toString("base64url")}`;
}
export function decodeSkillToolName(toolName: string): string {
if (!toolName.startsWith(SKILL_TOOL_NAME_PREFIX)) {
return toolName;
}
try {
return Buffer.from(toolName.slice(SKILL_TOOL_NAME_PREFIX.length), "base64url").toString("utf8");
} catch {
return toolName;
}
}
function skillToOpenAI(skill: Skill): OpenAITool {
return {
type: "function",
function: {
name: `${skill.name}@${skill.version}`,
name: encodeSkillToolName(skill.name, skill.version),
description: skill.description,
parameters: skill.schema.input,
},
@@ -38,7 +64,7 @@ function skillToOpenAI(skill: Skill): OpenAITool {
function skillToClaude(skill: Skill): ClaudeTool {
return {
name: `${skill.name}@${skill.version}`,
name: encodeSkillToolName(skill.name, skill.version),
description: skill.description,
input_schema: skill.schema.input,
};
@@ -46,7 +72,7 @@ function skillToClaude(skill: Skill): ClaudeTool {
function skillToGemini(skill: Skill): GeminiTool {
return {
name: `${skill.name}@${skill.version}`,
name: encodeSkillToolName(skill.name, skill.version),
description: skill.description,
parameters: skill.schema.input,
};

View File

@@ -1,7 +1,7 @@
import { skillExecutor } from "./executor";
import { skillRegistry } from "./registry";
import { builtinSkills } from "./builtins";
import { detectProvider } from "./injection";
import { detectProvider, decodeSkillToolName } from "./injection";
import { OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME } from "@omniroute/open-sse/services/webSearchFallback.ts";
import { OMNIROUTE_WEB_FETCH_FALLBACK_TOOL_NAME } from "@omniroute/open-sse/services/webFetchInterception.ts";
import { logger } from "../../../open-sse/utils/logger.ts";
@@ -113,9 +113,10 @@ export async function interceptToolCalls(
};
}
const [name, version] = call.name.includes("@")
? call.name.split("@")
: [call.name, "latest"];
const decodedName = decodeSkillToolName(call.name);
const [name, version] = decodedName.includes("@")
? decodedName.split("@")
: [decodedName, "latest"];
const skillName = version === "latest" ? name : `${name}@${version}`;
@@ -226,7 +227,10 @@ function parseArguments(args: string | Record<string, unknown>): Record<string,
}
function isRegisteredCustomSkill(toolName: string, apiKeyId: string): boolean {
const [name, version] = toolName.includes("@") ? toolName.split("@", 2) : [toolName, undefined];
const decodedName = decodeSkillToolName(toolName);
const [name, version] = decodedName.includes("@")
? decodedName.split("@", 2)
: [decodedName, undefined];
const identifier = version ? `${name}@${version}` : name;
return skillRegistry.getSkill(identifier, apiKeyId) != null;
}