Skip to content

fix(claude): restore canonical tool names so Claude Code accepts tool calls - #11085

Merged
diegosouzapw merged 35 commits into
diegosouzapw:release/v3.8.50from
linhdmn:fix/claude-code-tool-name-casing
Aug 22, 2026
Merged

diegosouzapw merged 35 commits into
diegosouzapw:release/v3.8.50from
linhdmn:fix/claude-code-tool-name-casing

Conversation

@linhdmn

@linhdmn linhdmn commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ base-red inherited: #9985 — Quality Gates / fast-path unit jobs fail identically on unrelated PRs against release/v3.8.50; failures are not from this branch (see live-validation comment below).

Problem

Claude Code clients routed through OmniRoute to OpenAI-compatible providers lose native tool calls entirely. Live reproduction (2026-08-22, opencode-go/ox-alpha-free upstream, /v1/messages with 143 declared tools):

  1. The model emits a tool call for the Bash tool, but the name reaches the client lowercased as bash.
  2. Claude Code validates tool names case-sensitively and rejects it:
<tool_use_error>Error: No such tool available: bash</tool_use_error>

Every retry fails the same way — Bash/Read/Write/CronCreate stay dead for the whole session while MCP tools (which happen to be lowercase already) keep working.

Call logs show two distinct leaks:

  • Non-streaming conversion never restored names. When an upstream answers a stream:true request with an application/json body, OmniRoute converts via translateNonStreamingResponse, whose convertOpenAINonStreamingToClaude copied function.name verbatim into the Claude tool_use block. The streaming path already called restoreClaudeToolName; the non-streaming path did not.
  • Identity echoes pinned wrong casing. Some upstream gateways echo the very lowercase name they emitted back through the _toolNameMap alias channel (croncreate → croncreate). restoreClaudeToolName returned that identity match before considering canonical casing, and TOOL_RENAME_MAP was missing the cron-era tools (TaskCreate, TaskUpdate, TaskList, TaskGet), so even the canonical upgrade had nothing to upgrade to.

Fix

  • open-sse/handlers/responseTranslator.ts: pass the request's toolNameMap into convertOpenAINonStreamingToClaude and restore each emitted tool name via restoreClaudeToolName, matching the streaming translator.
  • open-sse/services/claudeCodeToolRemapper.ts:
    • Known Claude Code tools are upgraded to canonical PascalCase even when a map entry matches identically (bash → Bash, croncreate → CronCreate, …).
    • Added missing cron-era entries to TOOL_RENAME_MAP: TaskCreate, TaskUpdate, TaskList, TaskGet.
    • Preference order documented; real request-side aliases still win over canonical casing (read → mcp__fs__read is preserved), and the #7926 no-map TitleCase→lowercase fallback is untouched.

Tests

New suite tests/unit/claude-code-tool-casing-identity-echo.test.ts (12 cases):

  • identity-echo / cloak-direction / mixed maps all upgrade echoed lowercase names (incl. live-repro bash → Bash and cron-era tools) in both restoreClaudeToolName and the streaming openaiToClaudeResponse
  • translateNonStreamingResponse upgrades bash → Bash with no map (live repro) and honours aliases/canonical order
  • regression guards: non-identity alias beats canonical, unknown custom names preserved verbatim, #7926 fallback unchanged

All green together with the existing suites: gemini-to-claude-tool-name-case-9008, claude-tool-name-casing-fix, claude-code-tool-remapper-flag-leak, provider-request-capture-toolname-map-4091, claude-server-tool-name-preserve, translator-resp-openai-to-claude, translator-openai-to-claude, claude-code-parity, xai-translators, 9568-gemini-tool-casing-mismatch (172 tests).

… calls

Claude Code rejects lowercase tool_use names with 'No such tool available:
bash', killing Bash/Read/CronCreate for the whole session. Two gaps let the
lowercase form through:

- translateNonStreamingResponse emitted tool_use.name verbatim when an
  upstream answered a stream:true /v1/messages request with a JSON body
  (openai -> claude non-streaming conversion never consulted
  restoreClaudeToolName).
- restoreClaudeToolName honoured identity echoes in _toolNameMap (map entry
  croncreate -> croncreate) over canonical casing, and lacked cron-era
  tools (TaskCreate/TaskUpdate/TaskList/TaskGet) in TOOL_RENAME_MAP.

restoreClaudeToolName now upgrades known Claude Code tools to canonical
PascalCase even through identity-echo maps, while real request-side aliases
(mcp__fs__read etc.) still win; the diegosouzapw#7926 no-map TitleCase->lowercase
fallback is untouched.
@linhdmn
linhdmn requested a review from diegosouzapw as a code owner August 22, 2026 03:37
linhdmn pushed a commit to linhdmn/omniroute-INITIAL_PASSWORD-fix that referenced this pull request Aug 22, 2026
linhdmn added 2 commits August 22, 2026 10:45
…uild

diegosouzapw#10939 added `import { getConnection } from "@/lib/db/connections"` to
the utilization route, but no such module exists in the repo — the Next
build fails with "Module not found: Can't resolve '@/lib/db/connections'".

Add the module following the existing db-helper pattern
(getDbInstance + prepared SELECT on provider_connections), returning the
email/name metadata the route maps into ConnectionMetaEntry. The route's
`conn?.displayName` read is replaced with a literal null: the
provider_connections table has no display-name column, and the Account
Split card already falls back through getAccountDisplayName
(displayName -> name -> email).
…e with no alias map

Live-tested against glm-5.2 via opencode-go on /v1/messages: the request
declared CronCreate/Bash, the gateway echoed them TitleCase verbatim, and
the claude-to-openai route ships no _toolNameMap — so restoreClaudeToolName
fell through to the diegosouzapw#7926 REVERSE_MAP fallback and downcased the echo to
croncreate/bash. Claude Code rejects those with 'No such tool available',
killing Bash/Read/CronCreate for the whole session.

Every restoreClaudeToolName caller converts toward a Claude-format client,
so blind TitleCase→lowercase downcasing has no legitimate consumer left:
legacy OpenCode/XML-style lowercase clients are protected by explicit
alias maps (request-side cloak), which this keeps honoring first.

- canonical echo is now terminal in restoreClaudeToolName (after the
  alias-map block, before the diegosouzapw#7926 fallbacks)
- tests updated from downcase expectations to canonical-preserve, each
  annotated with the live repro
@linhdmn

linhdmn commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Live-environment validation (Hard Rule #18) — extended fix in 52af7f5

The original commits fixed lowercase echoes through alias maps, but a live test against a real upstream exposed a remaining gap on the most common route: Claude Code → OpenAI-style upstream ships no _toolNameMap, so an upstream echoing the canonical TitleCase fell through to the #7926 REVERSE_MAP fallback and was downcased (CronCreate → croncreate) — exactly the 'No such tool available' failure this PR targets.

Live repro (before 52af7f5) — real glm-5.2-high via opencode-go, isolated DATA_DIR, port 20129:

POST /v1/messages {model: opencode-go/glm-5.2-high, tools:[Bash, CronCreate], tool_choice:{type:tool,name:CronCreate}}
→ HTTP 200, tool_use.name = croncreate   ❌ (Claude Code would reject)

After 52af7f5 (canonical echo terminal after the alias-map block):

Same request → HTTP 200, tool_use.name = CronCreate   ✅
Natural-choice variant (no tool_choice) → tool_use.name = CronCreate   ✅

TDD: failing tests added first (canonical echo stays canonical on no-map routes, declared-lowercase alias preservation), then the minimal fix. All suites green locally: identity-echo 14/14, tool-name sweep 97/97 + 89/89, translator suites 30/30 + 63/63, claude-code regression 73/73; lint clean; typecheck:core clean.

Legacy OpenCode/XML-style lowercase clients remain protected via explicit alias maps ({Read→read}), which restore honors before the canonical-echo terminal.

@linhdmn linhdmn closed this Aug 22, 2026
@linhdmn linhdmn reopened this Aug 22, 2026
hartmark and others added 18 commits August 22, 2026 14:09
…iegosouzapw#11060)

Inherited base-red at merge time (discriminated against the pure base tip, both reproduce WITHOUT this diff): getTokenLimit test + Vietnamese i18n key parity (new UI strings merged untranslated) + No new ESLint warnings gate. Merge integrity, Docs Gates, Vitest, Fast Production Build: green.
…iegosouzapw#11095)

Same inherited base-red set as diegosouzapw#11148 (getTokenLimit + vi parity + ESLint gate — all reproduce on the pure base tip). Merge integrity, Docs Gates, Vitest, Build: green.
…uzapw#11109)

Validated on the combined batch board over release/v3.8.50 tip d91238b: static gates clean (changelog-integrity, file-size, complexity 2619<=2774, cognitive 1178<=1223, dead-code 411<=416), typecheck:core clean, focused tests green (262 batch-touched tests pass; the only red is the pre-existing vi.json harImport key drift from diegosouzapw#11069, reproduced on the pure tip — not this batch).

Follow-up fix pushed to the branch pre-merge: the changelog fragment was prose, now a bullet (fragment gate). Thank you @maxmad64bis — the root-cause writeup (per-call finish_reason vs general terminal marker making the in-flight guard a no-op) is exactly right.
…Arguments (diegosouzapw#11043 followup) (diegosouzapw#11135)

Validated on the combined batch board over release/v3.8.50 tip d91238b: static gates clean, typecheck:core clean, focused tests green.

Test-only followup to diegosouzapw#11043 — 3 direct tests for splitConcatenatedToolCallArguments plus the index-normalization comment, exactly the two review nits. Thank you @maxmad64bis!
…items (diegosouzapw#11129)

Validated on the combined batch board over release/v3.8.50 tip d91238b: static gates clean, typecheck:core clean, focused tests green.

Live-confirmed 400 (missing summary on freshly-built Chat->Responses reasoning items); the two legacy assertions updated are contract propagation, each commented. Thank you @maxmad64bis!
…souzapw#11050) (diegosouzapw#11130)

Validated on the combined batch board over release/v3.8.50 tip d91238b: static gates clean, typecheck:core clean, focused tests green.

Closes the two diegosouzapw#11050 blockers: dispatcher tests now derive from WEBHOOK_EVENT_VALUES (no more TypeError on removed events) and vi.json carries a real translation. Thank you @maxmad64bis!
…erlay (diegosouzapw#11051/diegosouzapw#11049) (diegosouzapw#11133)

Validated on the combined batch board over release/v3.8.50 tip d91238b: static gates clean, typecheck:core clean, focused tests green.

Restores the models[0] dashboard default silently changed by diegosouzapw#11051, with narrow guards instead of a brittle full snapshot. Thank you @maxmad64bis!
…ouzapw#11102)

Validated on the combined batch board over release/v3.8.50 tip d91238b: static gates clean, typecheck:core clean, focused tests green.

Real suggestionCount replaces the conflated link count (deprecated alias kept), dashboard deep-link fixed. Thank you @maxmad64bis!
…t items (diegosouzapw#11110)

Validated on the combined batch board over release/v3.8.50 tip d91238b: static gates clean, typecheck:core clean, focused tests green.

Defaults summary and strips malformed ids on kept Responses input items — both 400s observed against live muse-spark traffic; the flipped legacy assertion is documented contract propagation. Thank you @maxmad64bis!
…ected keys (diegosouzapw#11101)

Validated on the combined batch board over release/v3.8.50 tip d91238b: static gates clean, typecheck:core clean, focused tests green.

Strict schema + {sanitized, rejected} DB boundary — silent validation degradation now answers 400 with the offending keys. Caller audit done: only the providers write path consumes the sanitizers. Thank you @maxmad64bis!
…iegosouzapw#11103)

Validated on the combined batch board over release/v3.8.50 tip d91238b: static gates clean, typecheck:core clean, focused tests green.

Config-audit survives restarts with bounded growth (migration 161 + OR IGNORE seed, retention wired into runAutoCleanup); route cabling deliberately out of scope. Thank you @maxmad64bis!
…ngs (diegosouzapw#11104)

Validated on the combined batch board over release/v3.8.50 tip d91238b: static gates clean, typecheck:core clean, focused tests green.

Operator-declared per-provider error rules via settings, consulted before the built-ins; the allowlist bypass is correct — declaring a rule is itself the opt-in, and no provider-specific rule is hardcoded. Thank you @maxmad64bis!
… Responses->Chat translation (diegosouzapw#11144)

Validated on the combined batch board over release/v3.8.50 tip d91238b: static gates clean, typecheck:core clean, focused tests green.

TDD red->green: parallel function_call items now get distinct stable index/id at .added time via a per-call Map, interleaved argument deltas no longer glue, dual item_id/output_index correlation. 120-test translator suite green. Thank you @maxmad64bis!
…nstead of a hardcoded/opt-out default (diegosouzapw#11116)

Validated on the combined batch board over release/v3.8.50 tip d91238b: static gates clean, typecheck:core clean, focused tests green.

Learned reasoning_effort caps mirror the merged learnedThinkingCaps mechanism: parse the upstream 4xx enum, clamp, retry once, consult proactively — covers custom openai-compatible connections the static registry can't. 22 new test cases + full regression list green. Fixes diegosouzapw#11111. Thank you @maxmad64bis!
diegosouzapw#11099) (diegosouzapw#11132)

Cherry-picked the value commit (ac09b6b) onto the current release tip, dropping the stale base-red sync commits that no longer apply. Focused test cline-model-format-11099 2/2 green; check:provider-consistency OK (267/348/0). Fixes diegosouzapw#11099 — the zai→z-ai namespace typo. Thank you @rqzbeh!
… i18n labels (diegosouzapw#11096) (diegosouzapw#11117)

Cherry-picked the value commit (0e91881) onto the current tip, dropping the stale base-red sync commits. Focused tests: pollinations-api-key-required 1/1 plus the whole optional-key suite 142/142 (two legacy assertions in provider-route-schemas flipped to the new key-required contract, commented with the PR). Fixes diegosouzapw#11096 — Pollinations answers 401 anonymously now. Thank you @rqzbeh!
rqzbeh and others added 12 commits August 22, 2026 16:01
…ouzapw#11070) (diegosouzapw#11079)

Cherry-picked the value commit (55da60f) onto the current tip, dropping the stale base-red sync commits. Focused tests: opencode-v2-config-11070 + merge-provider-guard + config-dir-single-source 12/12; mutation coverage gate no-drift (new test registered in tap.testFiles by the PR itself). Fixes diegosouzapw#11070 — setup-opencode now emits both V1 and V2 blocks. Thank you @rqzbeh!
…ction model lockout (diegosouzapw#11071) (diegosouzapw#11078)

Cherry-picked the value commit (2c9202e) onto the current tip, dropping the stale base-red sync commits. Focused tests: ollama-404-model-lockout 2/2 + the five sibling lockout suites (combo-provider-cooldown-sibling, 8247-model-unhealthy, vertex-passthrough, nvidia-410, account-fallback-service) 112/112; mutation coverage no-drift. Fixes diegosouzapw#11071 — local/self-hosted 404s now scope to model lockout per the resilience doctrine. Thank you @rqzbeh!
…vider (diegosouzapw#11091) (diegosouzapw#11122)

Cherry-picked both value commits (a1fa49a + a91f6e9, incl. the fail-open guard for unparseable hostnames) onto the current tip, dropping the stale base-red sync commits. Focused tests: is-local-provider-11091 + the isLocalProvider consumer suites (provider-validation-specialty, ollama-local-provider) 127/127; mutation coverage no-drift. Fixes diegosouzapw#11091 — RFC1918/CGNAT/link-local/mDNS hosts now classify as local via the existing isPrivateHost. Thank you @rqzbeh!
…gosouzapw#11100) (diegosouzapw#11125)

Cherry-picked the three value commits onto the current tip, dropping the stale base-red sync commits. Focused tests: search-blocked-providers-11100 + search-registry/searxng-loopback/chat-guard/x-search suites 65/65. Fixes diegosouzapw#11100 (endpoint half) — GET /v1/search now honors blockedProviders via getAllSearchProviders. Thank you @rqzbeh!
…8n parity, eslint gate (diegosouzapw#9985)

Discriminated against the pure base tip (all three reproduced without any PR diff): (1) getTokenLimit test aligned to the contract changes of diegosouzapw#8228/diegosouzapw#11034 with the bluesminds 200k pin kept as the original guard; (2) Vietnamese translations completed for harImport*/omni-webhooks (upstream already carried equivalent translations — conflict resolved to base); (3) eslint gate fixed by typing the dynamic core imports in capture-critical-db-state.test.ts (no-explicit-any). 41/41 tests green, typecheck clean, eslint exit 0.
…egosouzapw#11123)

Cherry-picked the author's cleaned single-commit head onto the current tip. One fix applied pre-merge: the removal migration collided at number 161 with today's config_audit_log migration (diegosouzapw#11103) — renumbered to 162_remove_hackclub_provider.sql. Focused tests: remove-hackclub-11118 + provider-metrics-deleted-provider green; check:provider-consistency OK (266/348/0). Provider removal requested by Hack Club maintainers (diegosouzapw#11118). Thank you @rqzbeh!
…roviders (diegosouzapw#11081)

Validated on a combined board over tip aa12873: focused tests local-rerank-logging + call-logs-row-filter green (7/7 across runs), typecheck:core clean. One pre-merge fix: widened waitForCallLogSaves 5s→15s — the call-log artifact writer's fsync latency exceeds 5s under a loaded host (pre-existing call-log-save-drain flakes identically on the pure tip), so the budget was load-flaky, not the code. Local rerank now logs call entries on success and error paths like embeddings/cloud rerank, captures full request/response payloads, attaches x-omniroute-* headers, and falls back /v1/rerank→/rerank on 404. Thank you @AndrianBalanescu!
…iegosouzapw#11082)

Validated on a combined board over tip aa12873 (incl. sibling diegosouzapw#11081): call-logs-row-filter 4/4 green, typecheck:core clean. The merged-row predicate fix closes a real gap — in-memory (in-flight/recently-completed) rows bypassed every filter except correlationId; rowMatchesFilter() now applies search/model/provider/account/apiKey/status/combo uniformly while DB rows stay idempotent. Thank you @AndrianBalanescu!
…11045)

Validated on the combined board over tip 80d931a: kimi suites green (executor-kimi-web, kimi-partner-aff-links, token-health-check-kimi 33 assertions), vitest providerPageHeaderKimiPartnerLink green, typecheck:core clean. Audited the diff: only kimi-web switches to the international www.kimi.ai (Connect-RPC base, website, auth hints); kimi-coding / kimi-coding-apikey affiliate links stay on kimi.com as intended — asserted by the updated tests. Owner approved merge without the VPS smoke. Thank you @MeRezaRezaei!
Validated on the combined board over tip 80d931a: quota-redis-store (incl. the KEY_PREFIX derivation test), local-redis-status and rate-limiter-redis-optional green, typecheck:core clean. One pre-merge fix pushed to the branch: docs/reference/ENVIRONMENT.md gained the REDIS_KEY_PREFIX row (env-doc-sync gate requires every .env.example var documented). Board note: the redis tests leave an ioredis retry handle open and hang the runner exit locally — assertions all pass; pre-existing pattern, not from this PR. Thank you @MeRezaRezaei!
Merged with the ENVIRONMENT.md hunk dropped: the tip already documents unset=unlimited for DEFAULT_RATE_LIMIT_PER_DAY via diegosouzapw#11022 (eba58cc), so the docs conflict resolved to the tip text. What lands is the .env.example comment correction, verified against src/shared/utils/apiKeyPolicy.ts::buildDefaultRateLimits — unset/empty → [] (unlimited), malformed → legacy 1000/day windows, explicit 0 → unlimited. Conflict resolution validated on the combined board (env-doc-sync gate green). Thank you @Prajeeth-12!
@diegosouzapw
diegosouzapw merged commit c19c73f into diegosouzapw:release/v3.8.50 Aug 22, 2026
4 of 7 checks passed
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
… calls (diegosouzapw#11085)

Merged after conflict resolution validated on the combined board (50/50 casing tests green, typecheck:core clean). Two pre-merge adjustments on the branch: (1) the utilization route conflict resolved to the tip shape — its asNullableString/displayName version is newer than the branch's; (2) dropped the newly-added src/lib/db/connections.ts, orphaned once the route kept the tip shape (tip already uses getProviderConnectionById) — nothing imported it. The casing fix itself lands intact: non-streaming OpenAI→Claude conversion now restores canonical tool names, identity echoes no longer pin lowercase, and TOOL_RENAME_MAP gained the Task* tools. Fixes the live-reproduced Claude Code 'No such tool available: bash' failures. Thank you @linhdmn — outstanding repro and root-cause writeup!
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants