Files
CLIProxyAPI/internal/runtime/executor/claude_executor_request.go
sususu f3e25ab2ba feat(claude): align OAuth wire identity and TLS with Claude Code 2.1.220
Detect confirmed CLI, sdk-cli and VSCode callers before mutation so native
software, system, tool, cache and beta shapes pass through, while unconfirmed
OAuth clients receive a coherent minimum CLI identity.

Persist each Claude OAuth credential's upstream account metadata and one stable
device ID, derive one stable session per agent conversation, and keep body and
header identity synchronized across Messages, streaming and count_tokens.

Alias every cloaked third-party custom tool through caller-stable opaque MCP
names and restore declarations, choices, history, references, non-stream
responses and SSE events without changing tool ownership.

Implement the Claude Code 2.1.220 CCH algorithm over the final serialized
request bytes, align currentDate and first-user cache layout, update the
official beta/header baseline, and use upstream count_tokens for OAuth and
first-party Anthropic credentials.

Match the 2.1.220 TLS ClientHello so the transport fingerprint agrees with the
identity the request now claims, and document the CLI defaults and automatic
OAuth signing / tool alias behaviour in config.example.yaml.
2026-08-03 14:47:26 +08:00

999 lines
32 KiB
Go

package executor
import (
"bufio"
"bytes"
"compress/flate"
"compress/gzip"
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/andybalholm/brotli"
"github.com/google/uuid"
"github.com/klauspost/compress/zstd"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"github.com/gin-gonic/gin"
)
const (
defaultClaudeCodeCLIBetas = "claude-code-20250219,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01"
claudeTokenCountingBeta = "token-counting-2024-11-01"
claudeFastModeBeta = "fast-mode-2026-02-01"
)
// extractAndRemoveBetas extracts the "betas" array from the body and removes it.
// Returns the extracted betas as a string slice and the modified body.
func extractAndRemoveBetas(body []byte) ([]string, []byte) {
betasResult := gjson.GetBytes(body, "betas")
if !betasResult.Exists() {
return nil, body
}
var betas []string
if betasResult.IsArray() {
for _, item := range betasResult.Array() {
if s := strings.TrimSpace(item.String()); s != "" {
betas = append(betas, s)
}
}
} else if s := strings.TrimSpace(betasResult.String()); s != "" {
betas = append(betas, s)
}
body, _ = sjson.DeleteBytes(body, "betas")
return betas, body
}
func appendClaudeFastModeBeta(body []byte, betas []string) []string {
speed := gjson.GetBytes(body, "speed")
if speed.Type != gjson.String || !strings.EqualFold(strings.TrimSpace(speed.String()), "fast") {
return betas
}
for _, beta := range betas {
if strings.TrimSpace(beta) == claudeFastModeBeta {
return betas
}
}
return append(betas, claudeFastModeBeta)
}
// disableThinkingIfToolChoiceForced checks if tool_choice forces tool use and disables thinking.
// Anthropic API does not allow thinking when tool_choice is set to "any" or a specific tool.
// See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations
func disableThinkingIfToolChoiceForced(body []byte) []byte {
toolChoiceType := gjson.GetBytes(body, "tool_choice.type").String()
// "auto" is allowed with thinking, but "any" or "tool" (specific tool) are not
if toolChoiceType == "any" || toolChoiceType == "tool" {
// Remove thinking configuration entirely to avoid API error
body, _ = sjson.DeleteBytes(body, "thinking")
// Adaptive thinking may also set output_config.effort; remove it to avoid
// leaking thinking controls when tool_choice forces tool use.
body, _ = sjson.DeleteBytes(body, "output_config.effort")
if oc := gjson.GetBytes(body, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
body, _ = sjson.DeleteBytes(body, "output_config")
}
}
return body
}
// normalizeClaudeSamplingForUpstream keeps Anthropic message requests valid.
func normalizeClaudeSamplingForUpstream(body []byte) []byte {
body, _ = sjson.DeleteBytes(body, "temperature")
body, _ = sjson.DeleteBytes(body, "top_p")
thinkingType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String()))
switch thinkingType {
case "enabled", "adaptive", "auto":
body, _ = sjson.DeleteBytes(body, "top_p")
body, _ = sjson.DeleteBytes(body, "top_k")
}
return body
}
type compositeReadCloser struct {
io.Reader
closers []func() error
}
func (c *compositeReadCloser) Close() error {
var firstErr error
for i := range c.closers {
if c.closers[i] == nil {
continue
}
if err := c.closers[i](); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// peekableBody wraps a bufio.Reader around the original ReadCloser so that
// magic bytes can be inspected without consuming them from the stream.
type peekableBody struct {
*bufio.Reader
closer io.Closer
}
func (p *peekableBody) Close() error {
return p.closer.Close()
}
func decodeResponseBody(body io.ReadCloser, contentEncoding string) (io.ReadCloser, error) {
if body == nil {
return nil, fmt.Errorf("response body is nil")
}
if contentEncoding == "" {
// No Content-Encoding header. Attempt best-effort magic-byte detection to
// handle misbehaving upstreams that compress without setting the header.
// Only gzip (1f 8b) and zstd (28 b5 2f fd) have reliable magic sequences;
// br and deflate have none and are left as-is.
// The bufio wrapper preserves unread bytes so callers always see the full
// stream regardless of whether decompression was applied.
pb := &peekableBody{Reader: bufio.NewReader(body), closer: body}
magic, peekErr := pb.Peek(4)
if peekErr == nil || (peekErr == io.EOF && len(magic) >= 2) {
switch {
case len(magic) >= 2 && magic[0] == 0x1f && magic[1] == 0x8b:
gzipReader, gzErr := gzip.NewReader(pb)
if gzErr != nil {
_ = pb.Close()
return nil, fmt.Errorf("magic-byte gzip: failed to create reader: %w", gzErr)
}
return &compositeReadCloser{
Reader: gzipReader,
closers: []func() error{
gzipReader.Close,
pb.Close,
},
}, nil
case len(magic) >= 4 && magic[0] == 0x28 && magic[1] == 0xb5 && magic[2] == 0x2f && magic[3] == 0xfd:
decoder, zdErr := zstd.NewReader(pb)
if zdErr != nil {
_ = pb.Close()
return nil, fmt.Errorf("magic-byte zstd: failed to create reader: %w", zdErr)
}
return &compositeReadCloser{
Reader: decoder,
closers: []func() error{
func() error { decoder.Close(); return nil },
pb.Close,
},
}, nil
}
}
return pb, nil
}
encodings := strings.Split(contentEncoding, ",")
for _, raw := range encodings {
encoding := strings.TrimSpace(strings.ToLower(raw))
switch encoding {
case "", "identity":
continue
case "gzip":
gzipReader, err := gzip.NewReader(body)
if err != nil {
_ = body.Close()
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
}
return &compositeReadCloser{
Reader: gzipReader,
closers: []func() error{
gzipReader.Close,
func() error { return body.Close() },
},
}, nil
case "deflate":
deflateReader := flate.NewReader(body)
return &compositeReadCloser{
Reader: deflateReader,
closers: []func() error{
deflateReader.Close,
func() error { return body.Close() },
},
}, nil
case "br":
return &compositeReadCloser{
Reader: brotli.NewReader(body),
closers: []func() error{
func() error { return body.Close() },
},
}, nil
case "zstd":
decoder, err := zstd.NewReader(body)
if err != nil {
_ = body.Close()
return nil, fmt.Errorf("failed to create zstd reader: %w", err)
}
return &compositeReadCloser{
Reader: decoder,
closers: []func() error{
func() error { decoder.Close(); return nil },
func() error { return body.Close() },
},
}, nil
default:
continue
}
}
return body, nil
}
func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, stream bool, extraBetas []string, cfg *config.Config, incomingHeaders http.Header, confirmedClaudeCode bool, sessionIDs ...string) error {
if r == nil {
return nil
}
hdrDefault := func(cfgVal, fallback string) string {
if cfgVal != "" {
return cfgVal
}
return fallback
}
var hd config.ClaudeHeaderDefaults
if cfg != nil {
hd = cfg.ClaudeHeaderDefaults
}
hasAPIKeyAttr := auth != nil && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["api_key"]) != ""
oauthToken := isClaudeOAuthToken(apiKey) || !hasAPIKeyAttr
useAPIKey := !oauthToken
isAnthropicBase := r.URL != nil && strings.EqualFold(r.URL.Scheme, "https") && strings.EqualFold(r.URL.Host, "api.anthropic.com")
if isAnthropicBase && useAPIKey {
r.Header.Del("Authorization")
r.Header.Set("x-api-key", apiKey)
} else {
r.Header.Set("Authorization", "Bearer "+apiKey)
}
r.Header.Set("Content-Type", "application/json")
if incomingHeaders == nil {
if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
incomingHeaders = ginCtx.Request.Header
}
}
stabilizeDeviceProfile := helps.ClaudeDeviceProfileStabilizationEnabled(cfg)
var deviceProfile helps.ClaudeDeviceProfile
if stabilizeDeviceProfile && confirmedClaudeCode {
var errDeviceProfile error
deviceProfile, errDeviceProfile = helps.ResolveClaudeDeviceProfileRequired(r.Context(), auth, apiKey, incomingHeaders, cfg)
if errDeviceProfile != nil {
return errDeviceProfile
}
}
baseBetas := defaultClaudeCodeCLIBetas
incomingBetas := strings.TrimSpace(strings.Join(incomingHeaders.Values("Anthropic-Beta"), ","))
if confirmedClaudeCode && incomingBetas != "" {
baseBetas = incomingBetas
}
existingSet := make(map[string]bool)
for _, beta := range strings.Split(baseBetas, ",") {
if beta = strings.TrimSpace(beta); beta != "" {
existingSet[beta] = true
}
}
appendBeta := func(beta string) {
beta = strings.TrimSpace(beta)
if beta == "" || existingSet[beta] {
return
}
baseBetas += "," + beta
existingSet[beta] = true
}
if !confirmedClaudeCode && incomingBetas != "" {
for _, beta := range strings.Split(incomingBetas, ",") {
appendBeta(beta)
}
}
if oauthToken {
appendBeta("oauth-2025-04-20")
}
for _, beta := range extraBetas {
appendBeta(beta)
}
r.Header.Set("Anthropic-Beta", baseBetas)
identityHeader := func(name, fallback string) {
if confirmedClaudeCode {
misc.EnsureHeader(r.Header, incomingHeaders, name, fallback)
return
}
r.Header.Set(name, fallback)
}
identityHeader("Anthropic-Version", "2023-06-01")
identityHeader("Anthropic-Dangerous-Direct-Browser-Access", "true")
identityHeader("X-App", "cli")
// Values below match Claude Code 2.1.220 / @anthropic-ai/sdk 0.94.0.
identityHeader("X-Stainless-Retry-Count", "0")
identityHeader("X-Stainless-Runtime", "node")
identityHeader("X-Stainless-Lang", "js")
identityHeader("X-Stainless-Timeout", hdrDefault(hd.Timeout, "600"))
// Selected-credential OAuth identity is an explicit native passthrough
// exception. Callers pass the same agent-conversation UUID written to
// metadata.user_id; legacy paths retain their previous cached fallback.
sessionID := ""
for _, candidate := range sessionIDs {
if candidate = strings.TrimSpace(candidate); candidate != "" {
sessionID = candidate
break
}
}
if sessionID != "" {
r.Header.Set("X-Claude-Code-Session-Id", sessionID)
} else {
var errSessionID error
sessionID, errSessionID = helps.CachedSessionIDRequired(r.Context(), apiKey)
if errSessionID != nil {
return errSessionID
}
identityHeader("X-Claude-Code-Session-Id", sessionID)
}
// Per-request UUID, matches Claude Code's x-client-request-id for first-party API.
if isAnthropicBase {
identityHeader("x-client-request-id", uuid.New().String())
}
r.Header.Set("Connection", "keep-alive")
if stream {
r.Header.Set("Accept", "text/event-stream")
// SSE streams must not be compressed: the downstream scanner reads
// line-delimited text and cannot parse compressed bytes. Using
// "identity" tells the upstream to send an uncompressed stream.
r.Header.Set("Accept-Encoding", "identity")
} else {
r.Header.Set("Accept", "application/json")
r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
}
// Confirmed Claude Code requests may contribute their real software profile.
// Unconfirmed clients always receive the CLI baseline instead of being
// allowed to populate or reuse another client's software profile.
if stabilizeDeviceProfile {
if confirmedClaudeCode {
helps.ApplyClaudeDeviceProfileHeaders(r, deviceProfile)
} else {
helps.ApplyClaudeDefaultDeviceProfileHeaders(r, cfg)
}
} else {
helps.ApplyClaudeLegacyDeviceHeaders(r, incomingHeaders, cfg, confirmedClaudeCode)
}
var attrs map[string]string
if auth != nil {
attrs = auth.Attributes
}
util.ApplyCustomHeadersFromAttrs(r, attrs)
// Re-enforce the SSE transport contract after custom headers. A custom Accept
// value can disable event negotiation, while compressed SSE breaks line parsing.
if stream {
r.Header.Set("Accept", "text/event-stream")
r.Header.Set("Accept-Encoding", "identity")
}
return nil
}
func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
if a == nil {
return "", ""
}
if a.Attributes != nil {
apiKey = a.Attributes["api_key"]
baseURL = a.Attributes["base_url"]
}
if apiKey == "" && a.Metadata != nil {
if v, ok := a.Metadata["access_token"].(string); ok {
apiKey = v
}
}
return
}
func rebuildMidSystemMessagesToTopLevel(payload []byte) []byte {
messages := gjson.GetBytes(payload, "messages")
if !messages.IsArray() {
return payload
}
var movedSystemParts []string
keptMessages := make([]string, 0, int(messages.Get("#").Int()))
messages.ForEach(func(_, message gjson.Result) bool {
if strings.EqualFold(strings.TrimSpace(message.Get("role").String()), "system") {
movedSystemParts = append(movedSystemParts, claudeSystemTextParts(message.Get("content"))...)
return true
}
keptMessages = append(keptMessages, message.Raw)
return true
})
if len(movedSystemParts) == 0 {
return payload
}
systemParts := claudeSystemTextParts(gjson.GetBytes(payload, "system"))
systemParts = append(systemParts, movedSystemParts...)
if len(systemParts) > 0 {
if updated, errSetSystem := sjson.SetRawBytes(payload, "system", rawJSONArray(systemParts)); errSetSystem == nil {
payload = updated
}
}
if updated, errSetMessages := sjson.SetRawBytes(payload, "messages", rawJSONArray(keptMessages)); errSetMessages == nil {
payload = updated
}
return payload
}
func claudeSystemTextParts(content gjson.Result) []string {
if !content.Exists() {
return nil
}
if content.Type == gjson.String {
text := content.String()
if strings.TrimSpace(text) == "" {
return nil
}
block := []byte(`{"type":"text","text":""}`)
block, _ = sjson.SetBytes(block, "text", text)
return []string{string(block)}
}
if !content.IsArray() {
return nil
}
var parts []string
content.ForEach(func(_, item gjson.Result) bool {
if item.Type == gjson.String {
text := item.String()
if strings.TrimSpace(text) != "" {
block := []byte(`{"type":"text","text":""}`)
block, _ = sjson.SetBytes(block, "text", text)
parts = append(parts, string(block))
}
return true
}
if item.IsObject() && item.Get("type").String() == "text" && strings.TrimSpace(item.Get("text").String()) != "" {
parts = append(parts, item.Raw)
}
return true
})
return parts
}
func rawJSONArray(items []string) []byte {
if len(items) == 0 {
return []byte("[]")
}
var builder strings.Builder
builder.WriteByte('[')
for i, item := range items {
if i > 0 {
builder.WriteByte(',')
}
builder.WriteString(item)
}
builder.WriteByte(']')
return []byte(builder.String())
}
func isClaudeOAuthToken(apiKey string) bool {
return strings.Contains(apiKey, "sk-ant-oat")
}
type claudeMCPAliasOptions struct {
secret string
}
func resolveClaudeMCPAliasOptions(ctx context.Context) claudeMCPAliasOptions {
// Alias identity belongs to the downstream caller, not to the selected
// upstream credential. This keeps names stable across OAuth refresh and auth
// failover while giving one caller a shared virtual MCP server component.
secret := strings.TrimSpace(helps.APIKeyFromContext(ctx))
if secret == "" {
secret = "cpa-claude-mcp-default-caller"
}
return claudeMCPAliasOptions{secret: secret}
}
// prepareClaudeOAuthToolNamesForUpstream applies one request-local MCP symbol
// table across every Claude OAuth request path.
func prepareClaudeOAuthToolNamesForUpstream(body []byte, mcpAliases claudeMCPAliasOptions) ([]byte, map[string]string) {
return remapOAuthToolNamesWithOptions(body, mcpAliases)
}
func restoreClaudeOAuthToolNamesFromResponse(body []byte, reverseMap map[string]string) []byte {
return reverseRemapOAuthToolNames(body, reverseMap)
}
func restoreClaudeOAuthToolNamesFromStreamLine(line []byte, reverseMap map[string]string) []byte {
return reverseRemapOAuthToolNamesFromStreamLine(line, reverseMap)
}
// remapOAuthToolNames represents every declared third-party client tool as an
// opaque Claude Code MCP extension. Existing valid MCP names and explicit typed
// Anthropic tools remain unchanged.
//
// It operates on tools[].name, tool_choice.name, and all declared
// tool_use/tool_reference references in messages.
//
// The returned map is keyed on the upstream name and maps to the client-supplied
// original name. Callers MUST pass this map to the reverse
// functions so only aliases allocated for this request are restored on the
// response. A global reverse map would mix symbols from unrelated callers.
func remapOAuthToolNames(body []byte) ([]byte, map[string]string) {
return remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "cpa-claude-mcp-default-caller"})
}
func claudeMCPAliasRevealsOriginal(alias, original string) bool {
alias = strings.ToLower(alias)
for _, fragment := range strings.FieldsFunc(strings.ToLower(original), func(char rune) bool {
return !((char >= 'a' && char <= 'z') || (char >= '0' && char <= '9'))
}) {
if len(fragment) >= 4 && strings.Contains(alias, fragment) {
return true
}
}
return false
}
func remapOAuthToolNamesWithOptions(body []byte, mcpAliases claudeMCPAliasOptions) ([]byte, map[string]string) {
reverseMap := make(map[string]string)
recordRename := func(original, renamed string) {
// Preserve the first-seen original name if the same upstream name is
// produced from multiple call sites; they all map back identically.
if _, exists := reverseMap[renamed]; !exists {
reverseMap[renamed] = original
}
}
// Build one request-specific forward map from declarations. Every client
// tool, including typed custom declarations and names resembling Claude
// built-ins, gets an MCP alias. Historical references use this same map.
tools := gjson.GetBytes(body, "tools")
forwardMap := make(map[string]string)
protectedNames := make(map[string]bool)
reservedNames := helps.AugmentClaudeBuiltinToolRegistry(body, nil)
if tools.Exists() && tools.IsArray() {
tools.ForEach(func(_, tool gjson.Result) bool {
name := tool.Get("name").String()
if name != "" {
reservedNames[name] = true
}
if helps.IsClaudeServerToolType(tool.Get("type").String()) {
protectedNames[name] = true
}
return true
})
tools.ForEach(func(_, tool gjson.Result) bool {
if helps.IsClaudeServerToolType(tool.Get("type").String()) {
return true
}
name := tool.Get("name").String()
if name == "" || helps.IsClaudeMCPToolName(name) {
return true
}
if _, exists := forwardMap[name]; exists {
return true
}
for attempt := uint32(0); ; attempt++ {
alias := helps.ClaudeMCPToolAlias(mcpAliases.secret, name, attempt)
if reservedNames[alias] || claudeMCPAliasRevealsOriginal(alias, name) {
continue
}
forwardMap[name] = alias
reservedNames[alias] = true
break
}
return true
})
}
rewriteName := func(name string) (string, bool) {
if name == "" || protectedNames[name] || helps.IsClaudeMCPToolName(name) {
return name, false
}
if newName, ok := forwardMap[name]; ok && newName != name {
return newName, true
}
return name, false
}
// 1. Rewrite the tools array without rebuilding from a stale gjson snapshot.
toolsNeedRewrite := false
if tools.Exists() && tools.IsArray() {
tools.ForEach(func(_, tool gjson.Result) bool {
toolType := tool.Get("type").String()
if helps.IsClaudeServerToolType(toolType) {
return true
}
if strings.TrimSpace(toolType) != "" {
toolsNeedRewrite = true
return false
}
name := tool.Get("name").String()
_, toolsNeedRewrite = rewriteName(name)
return !toolsNeedRewrite
})
}
if toolsNeedRewrite {
var toolsJSON strings.Builder
toolsJSON.WriteByte('[')
toolCount := 0
tools.ForEach(func(_, tool gjson.Result) bool {
if helps.IsClaudeServerToolType(tool.Get("type").String()) {
if toolCount > 0 {
toolsJSON.WriteByte(',')
}
toolsJSON.WriteString(tool.Raw)
toolCount++
return true
}
name := tool.Get("name").String()
toolJSON := tool.Raw
if strings.TrimSpace(tool.Get("type").String()) != "" {
if updatedTool, errDelete := sjson.Delete(toolJSON, "type"); errDelete == nil {
toolJSON = updatedTool
}
}
if newName, renamed := rewriteName(name); renamed {
updatedTool, err := sjson.Set(toolJSON, "name", newName)
if err == nil {
toolJSON = updatedTool
recordRename(name, newName)
}
}
if toolCount > 0 {
toolsJSON.WriteByte(',')
}
toolsJSON.WriteString(toolJSON)
toolCount++
return true
})
toolsJSON.WriteByte(']')
body, _ = sjson.SetRawBytes(body, "tools", []byte(toolsJSON.String()))
}
// 2. Rename tool_choice if it references a declared client tool.
toolChoiceType := gjson.GetBytes(body, "tool_choice.type").String()
if toolChoiceType == "tool" {
tcName := gjson.GetBytes(body, "tool_choice.name").String()
if newName, renamed := rewriteName(tcName); renamed {
body, _ = sjson.SetBytes(body, "tool_choice.name", newName)
recordRename(tcName, newName)
}
}
// 3. Rename tool references in messages
messages := gjson.GetBytes(body, "messages")
if messages.Exists() && messages.IsArray() {
messages.ForEach(func(msgIndex, msg gjson.Result) bool {
content := msg.Get("content")
if !content.Exists() || !content.IsArray() {
return true
}
content.ForEach(func(contentIndex, part gjson.Result) bool {
partType := part.Get("type").String()
switch partType {
case "tool_use":
name := part.Get("name").String()
if newName, renamed := rewriteName(name); renamed {
path := fmt.Sprintf("messages.%d.content.%d.name", msgIndex.Int(), contentIndex.Int())
body, _ = sjson.SetBytes(body, path, newName)
recordRename(name, newName)
}
case "tool_reference":
toolName := part.Get("tool_name").String()
if newName, renamed := rewriteName(toolName); renamed {
path := fmt.Sprintf("messages.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int())
body, _ = sjson.SetBytes(body, path, newName)
recordRename(toolName, newName)
}
case "tool_result":
// Handle nested tool_reference blocks inside tool_result.content[]
toolID := part.Get("tool_use_id").String()
_ = toolID // tool_use_id stays as-is
nestedContent := part.Get("content")
if nestedContent.Exists() && nestedContent.IsArray() {
nestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool {
if nestedPart.Get("type").String() == "tool_reference" {
nestedToolName := nestedPart.Get("tool_name").String()
if newName, renamed := rewriteName(nestedToolName); renamed {
nestedPath := fmt.Sprintf("messages.%d.content.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int(), nestedIndex.Int())
body, _ = sjson.SetBytes(body, nestedPath, newName)
recordRename(nestedToolName, newName)
}
}
return true
})
}
}
return true
})
return true
})
}
return body, reverseMap
}
// reverseRemapOAuthToolNames reverses the tool name mapping for non-stream responses
// using the per-request map produced by remapOAuthToolNames. Names the client sent
// that were NOT forward-renamed are passed through unchanged.
func reverseRemapOAuthToolNames(body []byte, reverseMap map[string]string) []byte {
if len(reverseMap) == 0 {
return body
}
content := gjson.GetBytes(body, "content")
if !content.Exists() || !content.IsArray() {
return body
}
content.ForEach(func(index, part gjson.Result) bool {
partType := part.Get("type").String()
switch partType {
case "tool_use":
name := part.Get("name").String()
if origName, ok := reverseMap[name]; ok {
path := fmt.Sprintf("content.%d.name", index.Int())
body, _ = sjson.SetBytes(body, path, origName)
}
case "tool_reference":
toolName := part.Get("tool_name").String()
if origName, ok := reverseMap[toolName]; ok {
path := fmt.Sprintf("content.%d.tool_name", index.Int())
body, _ = sjson.SetBytes(body, path, origName)
}
case "tool_result":
nestedContent := part.Get("content")
if nestedContent.Exists() && nestedContent.IsArray() {
nestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool {
if nestedPart.Get("type").String() != "tool_reference" {
return true
}
toolName := nestedPart.Get("tool_name").String()
if origName, ok := reverseMap[toolName]; ok {
path := fmt.Sprintf("content.%d.content.%d.tool_name", index.Int(), nestedIndex.Int())
body, _ = sjson.SetBytes(body, path, origName)
}
return true
})
}
}
return true
})
return body
}
// reverseRemapOAuthToolNamesFromStreamLine reverses the tool name mapping for SSE
// stream lines, using the per-request reverseMap produced by remapOAuthToolNames.
func reverseRemapOAuthToolNamesFromStreamLine(line []byte, reverseMap map[string]string) []byte {
if len(reverseMap) == 0 {
return line
}
payload := helps.JSONPayload(line)
if len(payload) == 0 || !gjson.ValidBytes(payload) {
return line
}
contentBlock := gjson.GetBytes(payload, "content_block")
if !contentBlock.Exists() {
return line
}
blockType := contentBlock.Get("type").String()
var updated []byte
var err error
switch blockType {
case "tool_use":
name := contentBlock.Get("name").String()
if origName, ok := reverseMap[name]; ok {
updated, err = sjson.SetBytes(payload, "content_block.name", origName)
if err != nil {
return line
}
} else {
return line
}
case "tool_reference":
toolName := contentBlock.Get("tool_name").String()
if origName, ok := reverseMap[toolName]; ok {
updated, err = sjson.SetBytes(payload, "content_block.tool_name", origName)
if err != nil {
return line
}
} else {
return line
}
default:
return line
}
trimmed := bytes.TrimSpace(line)
if bytes.HasPrefix(trimmed, []byte("data:")) {
return append([]byte("data: "), updated...)
}
return updated
}
func applyClaudeToolPrefix(body []byte, prefix string) []byte {
if prefix == "" {
return body
}
// Collect built-in tool names from the authoritative fallback seed list and
// augment it with any typed built-ins present in the current request body.
builtinTools := helps.AugmentClaudeBuiltinToolRegistry(body, nil)
if tools := gjson.GetBytes(body, "tools"); tools.Exists() && tools.IsArray() {
tools.ForEach(func(index, tool gjson.Result) bool {
// Skip built-in tools (web_search, code_execution, etc.) which have
// a "type" field and require their name to remain unchanged.
if tool.Get("type").Exists() && tool.Get("type").String() != "" {
if n := tool.Get("name").String(); n != "" {
builtinTools[n] = true
}
return true
}
name := tool.Get("name").String()
if name == "" || strings.HasPrefix(name, prefix) || helps.IsClaudeMCPToolName(name) {
return true
}
path := fmt.Sprintf("tools.%d.name", index.Int())
body, _ = sjson.SetBytes(body, path, prefix+name)
return true
})
}
if gjson.GetBytes(body, "tool_choice.type").String() == "tool" {
name := gjson.GetBytes(body, "tool_choice.name").String()
if name != "" && !strings.HasPrefix(name, prefix) && !builtinTools[name] && !helps.IsClaudeMCPToolName(name) {
body, _ = sjson.SetBytes(body, "tool_choice.name", prefix+name)
}
}
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
messages.ForEach(func(msgIndex, msg gjson.Result) bool {
content := msg.Get("content")
if !content.Exists() || !content.IsArray() {
return true
}
content.ForEach(func(contentIndex, part gjson.Result) bool {
partType := part.Get("type").String()
switch partType {
case "tool_use":
name := part.Get("name").String()
if name == "" || strings.HasPrefix(name, prefix) || builtinTools[name] || helps.IsClaudeMCPToolName(name) {
return true
}
path := fmt.Sprintf("messages.%d.content.%d.name", msgIndex.Int(), contentIndex.Int())
body, _ = sjson.SetBytes(body, path, prefix+name)
case "tool_reference":
toolName := part.Get("tool_name").String()
if toolName == "" || strings.HasPrefix(toolName, prefix) || builtinTools[toolName] || helps.IsClaudeMCPToolName(toolName) {
return true
}
path := fmt.Sprintf("messages.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int())
body, _ = sjson.SetBytes(body, path, prefix+toolName)
case "tool_result":
// Handle nested tool_reference blocks inside tool_result.content[]
nestedContent := part.Get("content")
if nestedContent.Exists() && nestedContent.IsArray() {
nestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool {
if nestedPart.Get("type").String() == "tool_reference" {
nestedToolName := nestedPart.Get("tool_name").String()
if nestedToolName != "" && !strings.HasPrefix(nestedToolName, prefix) && !builtinTools[nestedToolName] && !helps.IsClaudeMCPToolName(nestedToolName) {
nestedPath := fmt.Sprintf("messages.%d.content.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int(), nestedIndex.Int())
body, _ = sjson.SetBytes(body, nestedPath, prefix+nestedToolName)
}
}
return true
})
}
}
return true
})
return true
})
}
return body
}
func stripClaudeToolPrefixFromResponse(body []byte, prefix string) []byte {
if prefix == "" {
return body
}
content := gjson.GetBytes(body, "content")
if !content.Exists() || !content.IsArray() {
return body
}
content.ForEach(func(index, part gjson.Result) bool {
partType := part.Get("type").String()
switch partType {
case "tool_use":
name := part.Get("name").String()
if !strings.HasPrefix(name, prefix) {
return true
}
path := fmt.Sprintf("content.%d.name", index.Int())
body, _ = sjson.SetBytes(body, path, strings.TrimPrefix(name, prefix))
case "tool_reference":
toolName := part.Get("tool_name").String()
if !strings.HasPrefix(toolName, prefix) {
return true
}
path := fmt.Sprintf("content.%d.tool_name", index.Int())
body, _ = sjson.SetBytes(body, path, strings.TrimPrefix(toolName, prefix))
case "tool_result":
// Handle nested tool_reference blocks inside tool_result.content[]
nestedContent := part.Get("content")
if nestedContent.Exists() && nestedContent.IsArray() {
nestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool {
if nestedPart.Get("type").String() == "tool_reference" {
nestedToolName := nestedPart.Get("tool_name").String()
if strings.HasPrefix(nestedToolName, prefix) {
nestedPath := fmt.Sprintf("content.%d.content.%d.tool_name", index.Int(), nestedIndex.Int())
body, _ = sjson.SetBytes(body, nestedPath, strings.TrimPrefix(nestedToolName, prefix))
}
}
return true
})
}
}
return true
})
return body
}
func stripClaudeToolPrefixFromStreamLine(line []byte, prefix string) []byte {
if prefix == "" {
return line
}
payload := helps.JSONPayload(line)
if len(payload) == 0 || !gjson.ValidBytes(payload) {
return line
}
contentBlock := gjson.GetBytes(payload, "content_block")
if !contentBlock.Exists() {
return line
}
blockType := contentBlock.Get("type").String()
var updated []byte
var err error
switch blockType {
case "tool_use":
name := contentBlock.Get("name").String()
if !strings.HasPrefix(name, prefix) {
return line
}
updated, err = sjson.SetBytes(payload, "content_block.name", strings.TrimPrefix(name, prefix))
if err != nil {
return line
}
case "tool_reference":
toolName := contentBlock.Get("tool_name").String()
if !strings.HasPrefix(toolName, prefix) {
return line
}
updated, err = sjson.SetBytes(payload, "content_block.tool_name", strings.TrimPrefix(toolName, prefix))
if err != nil {
return line
}
default:
return line
}
trimmed := bytes.TrimSpace(line)
if bytes.HasPrefix(trimmed, []byte("data:")) {
return append([]byte("data: "), updated...)
}
return updated
}