From 0220258a48445c92830a7f512e2bc914652449d1 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Tue, 10 Feb 2026 15:00:58 -0500 Subject: [PATCH 1/2] fix: sanitize tool IDs to match Anthropic's pattern Fixes #17. Anthropic's API requires tool_use.id to match the pattern ^[a-zA-Z0-9_-]+$ but some clients send IDs with invalid characters (like colons, periods, etc.) which cause 400 errors. Added sanitizeToolIds() function that: - Validates tool IDs against Anthropic's required pattern - Replaces invalid characters with underscores - Handles both OpenAI format (tool_calls[].id, tool_call_id) and content block formats (tool_use.id, tool_result.tool_use_id) --- src/proxy.ts | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/src/proxy.ts b/src/proxy.ts index 4fa4066..44c1b63 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -247,6 +247,129 @@ const ROLE_MAPPINGS: Record = { type ChatMessage = { role: string; content: string | unknown }; +/** + * Anthropic tool ID pattern: only alphanumeric, underscore, and hyphen allowed. + * Error: "messages.X.content.Y.tool_use.id: String should match pattern '^[a-zA-Z0-9_-]+$'" + */ +const VALID_TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; + +/** + * Sanitize a tool ID to match Anthropic's required pattern. + * Replaces invalid characters with underscores. + */ +function sanitizeToolId(id: string | undefined): string | undefined { + if (!id || typeof id !== "string") return id; + if (VALID_TOOL_ID_PATTERN.test(id)) return id; + + // Replace invalid characters with underscores + return id.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +/** + * Type for messages with tool calls (OpenAI format). + */ +type MessageWithTools = ChatMessage & { + tool_calls?: Array<{ id?: string; type?: string; function?: unknown }>; + tool_call_id?: string; +}; + +/** + * Type for content blocks that may contain tool IDs (Anthropic format in OpenAI wrapper). + */ +type ContentBlock = { + type?: string; + id?: string; + tool_use_id?: string; + [key: string]: unknown; +}; + +/** + * Sanitize all tool IDs in messages to match Anthropic's pattern. + * Handles both OpenAI format (tool_calls, tool_call_id) and content block formats. + */ +function sanitizeToolIds(messages: ChatMessage[]): ChatMessage[] { + if (!messages || messages.length === 0) return messages; + + let hasChanges = false; + const sanitized = messages.map((msg) => { + const typedMsg = msg as MessageWithTools; + let msgChanged = false; + let newMsg = { ...msg } as MessageWithTools; + + // Sanitize tool_calls[].id in assistant messages + if (typedMsg.tool_calls && Array.isArray(typedMsg.tool_calls)) { + const newToolCalls = typedMsg.tool_calls.map((tc) => { + if (tc.id && typeof tc.id === "string") { + const sanitized = sanitizeToolId(tc.id); + if (sanitized !== tc.id) { + msgChanged = true; + return { ...tc, id: sanitized }; + } + } + return tc; + }); + if (msgChanged) { + newMsg = { ...newMsg, tool_calls: newToolCalls }; + } + } + + // Sanitize tool_call_id in tool messages + if (typedMsg.tool_call_id && typeof typedMsg.tool_call_id === "string") { + const sanitized = sanitizeToolId(typedMsg.tool_call_id); + if (sanitized !== typedMsg.tool_call_id) { + msgChanged = true; + newMsg = { ...newMsg, tool_call_id: sanitized }; + } + } + + // Sanitize content blocks if content is an array (Anthropic-style content) + if (Array.isArray(typedMsg.content)) { + const newContent = (typedMsg.content as ContentBlock[]).map((block) => { + if (!block || typeof block !== "object") return block; + + let blockChanged = false; + let newBlock = { ...block }; + + // tool_use blocks have "id" + if (block.type === "tool_use" && block.id && typeof block.id === "string") { + const sanitized = sanitizeToolId(block.id); + if (sanitized !== block.id) { + blockChanged = true; + newBlock = { ...newBlock, id: sanitized }; + } + } + + // tool_result blocks have "tool_use_id" + if (block.type === "tool_result" && block.tool_use_id && typeof block.tool_use_id === "string") { + const sanitized = sanitizeToolId(block.tool_use_id); + if (sanitized !== block.tool_use_id) { + blockChanged = true; + newBlock = { ...newBlock, tool_use_id: sanitized }; + } + } + + if (blockChanged) { + msgChanged = true; + return newBlock; + } + return block; + }); + + if (msgChanged) { + newMsg = { ...newMsg, content: newContent }; + } + } + + if (msgChanged) { + hasChanges = true; + return newMsg; + } + return msg; + }); + + return hasChanges ? sanitized : messages; +} + /** * Normalize message roles to standard OpenAI format. * Converts non-standard roles (e.g., "developer") to valid ones. @@ -827,6 +950,11 @@ async function tryModelRequest( parsed.messages = normalizeMessageRoles(parsed.messages as ChatMessage[]); } + // Sanitize tool IDs to match Anthropic's pattern (alphanumeric, underscore, hyphen only) + if (Array.isArray(parsed.messages)) { + parsed.messages = sanitizeToolIds(parsed.messages as ChatMessage[]); + } + // Normalize messages for Google models (first non-system message must be "user") if (isGoogleModel(modelId) && Array.isArray(parsed.messages)) { parsed.messages = normalizeMessagesForGoogle(parsed.messages as ChatMessage[]); From a1b8e04142add76511f7c972ea2365ac89683196 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Tue, 10 Feb 2026 15:26:39 -0500 Subject: [PATCH 2/2] test: add e2e test for tool ID sanitization (issue #17) --- package.json | 3 +- test/e2e-tool-id-sanitization.ts | 289 +++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 test/e2e-tool-id-sanitization.ts diff --git a/package.json b/package.json index c1db60a..ef422c1 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "test:resilience:stability:full": "DURATION_MINUTES=240 npx tsx test/resilience-stability.ts", "test:resilience:lifecycle": "npx tsx test/resilience-lifecycle.ts", "test:resilience:quick": "npm run test:resilience:errors && npm run test:resilience:lifecycle", - "test:resilience:full": "npm run test:resilience:errors && npm run test:resilience:lifecycle && npm run test:resilience:stability:full" + "test:resilience:full": "npm run test:resilience:errors && npm run test:resilience:lifecycle && npm run test:resilience:stability:full", + "test:e2e:tool-ids": "npx tsx test/e2e-tool-id-sanitization.ts" }, "keywords": [ "llm", diff --git a/test/e2e-tool-id-sanitization.ts b/test/e2e-tool-id-sanitization.ts new file mode 100644 index 0000000..2caaa1e --- /dev/null +++ b/test/e2e-tool-id-sanitization.ts @@ -0,0 +1,289 @@ +/** + * End-to-End Test: Tool ID Sanitization (Issue #17) + * + * Tests that tool IDs with invalid characters are properly sanitized + * before being forwarded to upstream APIs (especially Anthropic which + * requires pattern ^[a-zA-Z0-9_-]+$). + * + * Run: npx tsx test/e2e-tool-id-sanitization.ts + */ + +import { startProxy } from "../dist/index.js"; + +const TEST_WALLET_KEY = + process.env.BLOCKRUN_WALLET_KEY || + "0xd786859744b4a2a9a6dd99139785d9f9d5631c7d0c3b3bfdf1b7108dd8a6e5b8"; + +const TEST_PORT = 8498; + +interface TestResult { + name: string; + passed: boolean; + error?: string; +} + +const results: TestResult[] = []; + +async function runTest( + name: string, + testFn: () => Promise +): Promise { + try { + await testFn(); + results.push({ name, passed: true }); + console.log(` ✓ ${name}`); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + results.push({ name, passed: false, error }); + console.log(` ✗ ${name}`); + console.log(` Error: ${error}`); + } +} + +async function main() { + console.log("\n╔════════════════════════════════════════════════════════════════╗"); + console.log("║ E2E Test: Tool ID Sanitization (Issue #17) ║"); + console.log("╚════════════════════════════════════════════════════════════════╝\n"); + + let proxy: Awaited> | undefined; + + try { + // Start proxy + console.log("Starting proxy..."); + proxy = await startProxy({ + walletKey: TEST_WALLET_KEY, + port: TEST_PORT, + onReady: (port) => console.log(`Proxy ready on port ${port}`), + onError: (err) => console.error("Proxy error:", err.message), + }); + + console.log(`Wallet: ${proxy.walletAddress}`); + const balance = await proxy.balanceMonitor.checkBalance(); + console.log(`Balance: $${balance.balanceUSD}\n`); + + if (balance.isEmpty) { + console.log("⚠ Wallet is empty, skipping paid tests"); + return; + } + + // Invalid tool IDs that would fail Anthropic's pattern validation + const INVALID_TOOL_IDS = [ + "call:with:colons", + "call.with.dots", + "call/with/slashes", + "call@with@at", + "call with spaces", + "call#with#hash", + ]; + + console.log("═══ Test Suite: Invalid Tool ID Handling ═══\n"); + + for (const invalidId of INVALID_TOOL_IDS) { + await runTest(`Tool ID "${invalidId}" should be sanitized`, async () => { + const messages = [ + { role: "user", content: "What is 2+2?" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: invalidId, + type: "function", + function: { + name: "calculator", + arguments: JSON.stringify({ a: 2, b: 2 }), + }, + }, + ], + }, + { + role: "tool", + tool_call_id: invalidId, + content: "4", + }, + { role: "user", content: "Thanks, what was the result?" }, + ]; + + const response = await fetch( + `http://127.0.0.1:${TEST_PORT}/v1/chat/completions`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "auto", + messages, + max_tokens: 20, + stream: false, + }), + } + ); + + const text = await response.text(); + + // Check for the specific Anthropic pattern error + if ( + text.includes("tool_use.id") && + text.includes("pattern") && + text.includes("should match") + ) { + throw new Error( + `Tool ID pattern error not fixed! Response: ${text.slice(0, 200)}` + ); + } + + if (response.status !== 200) { + // Check if it's a different error (not the pattern error) + if (response.status === 400) { + const parsed = JSON.parse(text); + const errorMsg = parsed.error?.message || parsed.error || text; + // If it's a pattern error, fail + if (errorMsg.includes("pattern") && errorMsg.includes("tool_use")) { + throw new Error(`Pattern validation failed: ${errorMsg}`); + } + // Other 400 errors might be OK (e.g., model-specific issues) + console.log(` ⚠ Got 400 but not pattern error: ${errorMsg.slice(0, 100)}`); + } else if (response.status !== 200) { + throw new Error(`Unexpected status ${response.status}: ${text.slice(0, 200)}`); + } + } + }); + } + + // Test with content block format (Anthropic-style) + console.log("\n═══ Test: Content Block Format ═══\n"); + + await runTest("Anthropic-style content blocks with invalid IDs", async () => { + const messages = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu:01:invalid/id", + name: "test_tool", + input: { query: "test" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu:01:invalid/id", + content: "result", + }, + ], + }, + { role: "user", content: "What happened?" }, + ]; + + const response = await fetch( + `http://127.0.0.1:${TEST_PORT}/v1/chat/completions`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "auto", + messages, + max_tokens: 20, + stream: false, + }), + } + ); + + const text = await response.text(); + + if ( + text.includes("tool_use.id") && + text.includes("pattern") && + text.includes("should match") + ) { + throw new Error(`Content block tool ID not sanitized: ${text.slice(0, 200)}`); + } + + // 200 or non-pattern 400 is acceptable + if (response.status !== 200 && response.status !== 400) { + throw new Error(`Unexpected status ${response.status}`); + } + }); + + // Test with valid IDs (should pass through unchanged) + console.log("\n═══ Test: Valid Tool IDs (Passthrough) ═══\n"); + + await runTest("Valid tool IDs should work unchanged", async () => { + const validId = "call_valid_id_123-abc"; + const messages = [ + { role: "user", content: "Add 1+1" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: validId, + type: "function", + function: { + name: "add", + arguments: JSON.stringify({ a: 1, b: 1 }), + }, + }, + ], + }, + { + role: "tool", + tool_call_id: validId, + content: "2", + }, + { role: "user", content: "Result?" }, + ]; + + const response = await fetch( + `http://127.0.0.1:${TEST_PORT}/v1/chat/completions`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "auto", + messages, + max_tokens: 20, + stream: false, + }), + } + ); + + if (response.status !== 200) { + const text = await response.text(); + throw new Error(`Expected 200, got ${response.status}: ${text.slice(0, 200)}`); + } + }); + + } finally { + if (proxy) { + console.log("\nClosing proxy..."); + await proxy.close(); + } + } + + // Summary + console.log("\n═══════════════════════════════════════════════════════════════"); + const passed = results.filter((r) => r.passed).length; + const failed = results.filter((r) => !r.passed).length; + console.log(`Total: ${results.length} tests`); + console.log(`✓ Passed: ${passed}`); + console.log(`✗ Failed: ${failed}`); + console.log("═══════════════════════════════════════════════════════════════\n"); + + if (failed > 0) { + console.log("Failed tests:"); + results + .filter((r) => !r.passed) + .forEach((r) => console.log(` - ${r.name}: ${r.error}`)); + process.exit(1); + } +} + +main().catch((err) => { + console.error("Test failed:", err); + process.exit(1); +});