Skip to content

fix(amp): restore response tool casing from request - #3595

Merged
luispater merged 1 commit into
router-for-me:devfrom
Progress-infinitely:fix/anthropic-tool-name-reverse-map
May 28, 2026
Merged

luispater merged 1 commit into
router-for-me:devfrom
Progress-infinitely:fix/anthropic-tool-name-reverse-map

Conversation

@Progress-infinitely

Copy link
Copy Markdown
Contributor

Fixes #3592

Summary

Claude Code tool dispatch is case-sensitive and expects response tool_use.name values to match the tool names it advertised, e.g. Glob rather than glob. When routing Claude Code through the Amp provider alias path (/api/provider/anthropic/v1/messages), a Codex-backed/local provider can stream back a lowercase glob tool call, which Claude Code rejects with:

No such tool available: glob

A global glob -> Glob normalization would be unsafe because Amp intentionally preserves lowercase glob for Amp CLI compatibility (TestNormalizeAmpToolNames_GlobPreserved, introduced by fix(amp): preserve lowercase glob tool name).

This PR makes Amp response tool-name normalization request-aware and collision-aware.

Fix

ResponseRewriter now records the exact tool-name casing advertised by the current request from:

  • tools[].name
  • tool_choice.name

During response rewriting, it uses that request-local casing before falling back to the existing Amp canonical map.

Behavior:

  • Claude Code request advertises Glob -> response glob is rewritten to Glob
  • Lowercase-native request advertises glob -> response remains glob
  • Ambiguous request advertises both Glob and glob -> that lowercased key is treated as conflicted and is not force-remapped
  • No request tool context -> existing Amp default behavior is preserved, including lowercase glob

The fallback handler now constructs the response rewriter with the sanitized request body on local-provider and model-mapping paths, so streaming and non-streaming responses can use the per-request tool-name map.

Tests

Added/kept coverage for both the reported bug and compatibility boundaries:

  1. TestNormalizeAmpToolNames_GlobPreserved — default Amp behavior still keeps lowercase glob
  2. TestNormalizeAmpToolNames_RequestToolCasing_NonStreaming — request-local Glob casing fixes non-streaming tool_use.name
  3. TestNormalizeAmpToolNames_RequestToolCasing_Streaming — request-local Glob casing fixes streaming content_block.name
  4. TestResponseRewriter_RequestToolCasingFromBody — verifies the rewriter derives casing from tools[].name
  5. TestResponseRewriter_LowercaseNativeRequestPreserved — lowercase-native client tools are not TitleCased
  6. TestCollectRequestToolNames_CollisionIgnored — conflicting Glob/glob and Bash/bash keys are not blindly remapped
  7. TestResponseRewriter_RequestToolCasingFromBody_Streaming — direct streaming rewriter coverage
  8. TestFallbackHandler_RequestToolCasing_RewritesStreamingResponse — covers FallbackHandler.WrapHandler -> NewResponseRewriterForRequest -> SSE rewrite

Validation:

/usr/local/go/bin/go test ./internal/api/modules/amp
/usr/local/go/bin/go test ./...
/usr/local/go/bin/go build -o /tmp/cli-proxy-api-toolname-fix ./cmd/server
git diff --check

All pass.

Evidence

The issue was confirmed via packet capture:

  • Incoming request to CLIProxyAPI advertised tools[].name = "Glob"
  • Outgoing SSE from CLIProxyAPI returned content_block.name = "glob"
  • Claude Code failed with No such tool available: glob

The fix is scoped to the Amp response rewriter used by provider alias local/model-mapping paths and avoids changing the global lowercase-glob default required by Amp CLI.

Copilot AI review requested due to automatic review settings May 28, 2026 09:38
@github-actions
github-actions Bot changed the base branch from main to dev May 28, 2026 09:39
@github-actions

Copy link
Copy Markdown

This pull request targeted main.

The base branch has been automatically changed to dev.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces request-specific tool name casing normalization in the ResponseRewriter. By extracting tool names directly from the request body, the rewriter can now preserve the client's original casing (e.g., restoring glob to Glob) in both streaming and non-streaming responses. Feedback on this PR highlights an efficiency improvement in collectRequestToolNames to parse the JSON payload once using gjson.ParseBytes instead of calling gjson.GetBytes multiple times, which avoids redundant parsing overhead on large request bodies.

Comment on lines +144 to +175
func collectRequestToolNames(data []byte) map[string]string {
names := map[string]string{}
conflicts := map[string]bool{}
record := func(name string) {
if name == "" {
return
}
key := strings.ToLower(name)
if conflicts[key] {
return
}
if existing, exists := names[key]; exists {
if existing != name {
names[key] = ""
conflicts[key] = true
}
return
}
names[key] = name
}

for _, tool := range gjson.GetBytes(data, "tools").Array() {
record(tool.Get("name").String())
}
if gjson.GetBytes(data, "tool_choice.type").String() == "tool" {
record(gjson.GetBytes(data, "tool_choice.name").String())
}
if len(names) == 0 {
return nil
}
return names
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Efficiency Improvement: Avoid parsing JSON multiple times

Using gjson.GetBytes multiple times on the same data byte slice causes gjson to parse the JSON payload from scratch for each call. For large request bodies, this can lead to significant performance overhead.

By parsing the JSON once using gjson.ParseBytes(data) and querying the resulting gjson.Result object, we can avoid redundant parsing and improve performance.

func collectRequestToolNames(data []byte) map[string]string {
	if len(data) == 0 {
		return nil
	}
	parsed := gjson.ParseBytes(data)
	names := map[string]string{}
	conflicts := map[string]bool{}
	record := func(name string) {
		if name == "" {
			return
		}
		key := strings.ToLower(name)
		if conflicts[key] {
			return
		}
		if existing, exists := names[key]; exists {
			if existing != name {
				names[key] = ""
				conflicts[key] = true
			}
			return
		}
		names[key] = name
	}

	for _, tool := range parsed.Get("tools").Array() {
		record(tool.Get("name").String())
	}
	if parsed.Get("tool_choice.type").String() == "tool" {
		record(parsed.Get("tool_choice.name").String())
	}
	if len(names) == 0 {
		return nil
	}
	return names
}

@Progress-infinitely

Copy link
Copy Markdown
Contributor Author

Addressed Gemini Code Assist's suggestion by parsing the request JSON once with gjson.ParseBytes in collectRequestToolNames. Re-ran go test ./internal/api/modules/amp, go test ./..., go build ./cmd/server, and git diff --check successfully.

@Progress-infinitely
Progress-infinitely force-pushed the fix/anthropic-tool-name-reverse-map branch from bd6820f to 01a7cc4 Compare May 28, 2026 10:03
@luispater
luispater merged commit f28258d into router-for-me:dev May 28, 2026
3 checks passed
jc01rho pushed a commit to jc01rho/CLIProxyAPIPlus that referenced this pull request May 29, 2026
Merge upstream/main with 6 commits:
- fix(logging): log errors during file-backed source cleanup
- feat(logging): add HomeAppLogForwarder for application log forwarding
- fix(amp): restore response tool casing from request
- feat(models): add Claude Opus 4.8 model to registry
- Merge pull request router-for-me#3595
- Update AGENTS.md files
ccat3z added a commit to ccat3z/CLIProxyAPI that referenced this pull request Jun 22, 2026
kogekiplay pushed a commit to kogekiplay/CLIProxyAPI that referenced this pull request Jun 27, 2026
…thropic-tool-name-reverse-map

fix(amp): restore response tool casing from request
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.

Claude Code tool calls fail because Anthropic response tool_use.name is lowercased (Glob -> glob)

2 participants