From a381e9aa3bf9dd3f07894e1653755f78bf704c3a Mon Sep 17 00:00:00 2001 From: "R.D." Date: Wed, 1 Apr 2026 04:55:25 -0400 Subject: [PATCH] feat: pass tools through CC compatible bridge --- open-sse/services/claudeCodeCompatible.ts | 91 +++++++++++++++++++++- tests/unit/cc-compatible-provider.test.mjs | 56 ++++++++++++- 2 files changed, 145 insertions(+), 2 deletions(-) diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index d0d2cbde01..5ccf4828aa 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -130,12 +130,19 @@ export function buildClaudeCodeCompatibleRequest({ const resolvedSessionId = sessionId || randomUUID(); const effort = resolveClaudeCodeCompatibleEffort(sourceBody, normalizedBody, model); const maxTokens = resolveClaudeCodeCompatibleMaxTokens(sourceBody, normalizedBody); + const tools = buildClaudeCodeCompatibleTools(normalizedBody, sourceBody); + const toolChoice = + tools.length > 0 + ? buildClaudeCodeCompatibleToolChoice( + normalizedBody?.["tool_choice"] ?? sourceBody?.["tool_choice"] + ) + : undefined; return { model, messages, system, - tools: [], + tools, metadata: { user_id: JSON.stringify({ device_id: createHash("sha256") @@ -161,6 +168,7 @@ export function buildClaudeCodeCompatibleRequest({ output_config: { effort, }, + ...(toolChoice ? { tool_choice: toolChoice } : {}), ...(stream ? { stream: true } : {}), }; } @@ -313,6 +321,81 @@ function convertClaudeCodeCompatibleMessage(message: MessageLike | null | undefi }; } +function buildClaudeCodeCompatibleTools( + normalizedBody?: Record | null, + sourceBody?: Record | null +) { + const rawTools = Array.isArray(normalizedBody?.["tools"]) + ? normalizedBody?.["tools"] + : Array.isArray(sourceBody?.["tools"]) + ? sourceBody?.["tools"] + : []; + + return rawTools + .map((tool) => convertClaudeCodeCompatibleTool(tool)) + .filter((tool): tool is Record => !!tool); +} + +function convertClaudeCodeCompatibleTool(tool: unknown) { + const rawTool = readRecord(tool); + if (!rawTool) return null; + + const toolData = + rawTool.type === "function" ? readRecord(rawTool.function) || rawTool : rawTool; + + const name = toNonEmptyString(toolData.name); + if (!name) return null; + + const rawSchema = + readRecord(toolData.parameters) || + readRecord(toolData.input_schema) || { type: "object", properties: {}, required: [] }; + const inputSchema = + rawSchema.type === "object" && !readRecord(rawSchema.properties) + ? { ...rawSchema, properties: {} } + : rawSchema; + + const converted: Record = { + name, + description: toNonEmptyString(toolData.description) || "", + input_schema: inputSchema, + }; + + if (typeof toolData.defer_loading === "boolean") { + converted.defer_loading = toolData.defer_loading; + } + + return converted; +} + +function buildClaudeCodeCompatibleToolChoice(choice: unknown) { + if (!choice) return null; + + if (typeof choice === "string") { + if (choice === "required") return { type: "any" }; + return null; + } + + const rawChoice = readRecord(choice); + if (!rawChoice) return null; + + if (rawChoice.type === "tool") { + const name = toNonEmptyString(rawChoice.name); + return name ? { type: "tool", name } : null; + } + + if (rawChoice.type === "function") { + const functionName = + toNonEmptyString(readRecord(rawChoice.function)?.name) || toNonEmptyString(rawChoice.name); + return functionName ? { type: "tool", name: functionName } : null; + } + + if (rawChoice.type === "required" || rawChoice.type === "any") { + return { type: "any" }; + } + + return null; +} + function contentToText(content: unknown): string { if (typeof content === "string") { return content.trim(); @@ -381,6 +464,12 @@ function toNonEmptyString(value: unknown): string | null { return trimmed || null; } +function readRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + function readNestedString( source: Record | null | undefined, path: string[] diff --git a/tests/unit/cc-compatible-provider.test.mjs b/tests/unit/cc-compatible-provider.test.mjs index dd20e74f64..ea915d4279 100644 --- a/tests/unit/cc-compatible-provider.test.mjs +++ b/tests/unit/cc-compatible-provider.test.mjs @@ -54,8 +54,25 @@ test("buildClaudeCodeCompatibleRequest keeps order/text while mapping unsupporte const payload = buildClaudeCodeCompatibleRequest({ sourceBody: { reasoning_effort: "xhigh", + tool_choice: "required", }, normalizedBody: { + tools: [ + { + type: "function", + function: { + name: "lookup_weather", + description: "Fetch weather", + parameters: { + type: "object", + properties: { + city: { type: "string" }, + }, + required: ["city"], + }, + }, + }, + ], messages: [ { role: "system", content: "sys" }, { role: "user", content: [{ type: "text", text: "u1" }, { type: "image_url" }] }, @@ -85,7 +102,19 @@ test("buildClaudeCodeCompatibleRequest keeps order/text while mapping unsupporte assert.deepEqual(payload.messages.at(-1).content.at(-1).cache_control, { type: "ephemeral" }); assert.equal(payload.system.length, 4); assert.equal(payload.system.at(-1).text, "sys"); - assert.equal(payload.tools.length, 0); + assert.equal(payload.tools.length, 1); + assert.deepEqual(payload.tools[0], { + name: "lookup_weather", + description: "Fetch weather", + input_schema: { + type: "object", + properties: { + city: { type: "string" }, + }, + required: ["city"], + }, + }); + assert.deepEqual(payload.tool_choice, { type: "any" }); assert.equal(payload.context_management.edits[0].type, "clear_thinking_20251015"); assert.equal(JSON.parse(payload.metadata.user_id).session_id, "session-1"); }); @@ -103,6 +132,31 @@ test("buildClaudeCodeCompatibleRequest honors token priority fields", () => { }); assert.equal(payload.max_tokens, 321); + assert.deepEqual(payload.tools, []); + assert.equal(payload.tool_choice, undefined); +}); + +test("buildClaudeCodeCompatibleRequest omits auto tool_choice while preserving tools", () => { + const payload = buildClaudeCodeCompatibleRequest({ + sourceBody: { tool_choice: "auto" }, + normalizedBody: { + messages: [{ role: "user", content: "hi" }], + tools: [ + { + type: "function", + function: { + name: "ping", + parameters: { type: "object" }, + }, + }, + ], + }, + model: "claude-sonnet-4-6", + sessionId: "session-4", + }); + + assert.equal(payload.tools.length, 1); + assert.equal(payload.tool_choice, undefined); }); test("DefaultExecutor uses CC-compatible path and headers", () => {