From 3eca8d9c0c8fd38761142e1cbff4d2fa3f3a4a89 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 31 Jul 2026 12:30:29 +0300 Subject: [PATCH] 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_ and decoded in interception before registry lookup. --- src/lib/skills/injection.ts | 32 +++++++++++++++++++++++++++++--- src/lib/skills/interception.ts | 14 +++++++++----- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/lib/skills/injection.ts b/src/lib/skills/injection.ts index c5a63e2077..69a0e8748c 100644 --- a/src/lib/skills/injection.ts +++ b/src/lib/skills/injection.ts @@ -25,11 +25,37 @@ interface GeminiTool { parameters: Record; } +// 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, }; diff --git a/src/lib/skills/interception.ts b/src/lib/skills/interception.ts index 79b33a8323..c32745ef4f 100644 --- a/src/lib/skills/interception.ts +++ b/src/lib/skills/interception.ts @@ -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): Record