mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-06 08:02:22 +08:00
Claude Code builds Anthropic-Beta per request instead of sending a fixed list.
Captured from an isolated 2.1.220 profile pointed at api.anthropic.com through
a local proxy, over two rounds covering 11 model IDs and the [1m] variants:
constant claude-code, interleaved-thinking, redact-thinking,
thinking-token-count, context-management, prompt-caching-scope
tools advanced-tool-use-2025-11-20 only when tools are declared
model mid-conversation-system-2026-04-07 only on models that accept a
role=system turn
[1m] context-1m-2025-08-07, directly after claude-code-20250219 rather
than at the end
trailing effort-2025-11-24, then server-side-fallback-2026-06-01
claude-sonnet-5 emits mid-conversation-system-2026-04-07, so it accepts a
role=system turn and must not sit in the legacy reminder whitelist.
count_tokens does not reuse the inference fingerprint. Running /context in an
interactive session issues 37 identical calls, which made the endpoint
observable for the first time: four betas only, and 21 headers rather than 22
because X-Stainless-Timeout is absent. The profile is selected from the request
path so no call site has to thread another flag.
1215 lines
41 KiB
Go
1215 lines
41 KiB
Go
package executor
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"compress/flate"
|
|
"compress/gzip"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"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 (
|
|
claudeTokenCountingBeta = "token-counting-2024-11-01"
|
|
claudeFastModeBeta = "fast-mode-2026-02-01"
|
|
claudeOAuthBeta = "oauth-2025-04-20"
|
|
claudeCodeBeta = "claude-code-20250219"
|
|
claudeContext1MBeta = "context-1m-2025-08-07"
|
|
claudeMidConvSystemBeta = "mid-conversation-system-2026-04-07"
|
|
claudeAdvancedToolUseBeta = "advanced-tool-use-2025-11-20"
|
|
claudeEffortBeta = "effort-2025-11-24"
|
|
claudeServerSideFallbackBeta = "server-side-fallback-2026-06-01"
|
|
claudeFallbackCreditBeta = "fallback-credit-2026-06-01"
|
|
claudeStructuredOutputsBeta = "structured-outputs-2025-12-15"
|
|
claudeExtendedCacheTTLBeta = "extended-cache-ttl-2025-04-11"
|
|
)
|
|
|
|
// claudeCodeCLIConstantBetas are the betas Claude Code 2.1.220 sends on every
|
|
// /v1/messages request from the "cli" entrypoint, in wire order, excluding the
|
|
// leading claude-code-20250219.
|
|
//
|
|
// redact-thinking-2026-02-12 belongs here because cloaked requests always claim
|
|
// cc_entrypoint=cli; the "sdk-cli" entrypoint omits it.
|
|
var claudeCodeCLIConstantBetas = []string{
|
|
"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",
|
|
}
|
|
|
|
// claudeCodeTrailingBetas are caller-supplied betas that real Claude Code emits
|
|
// after effort-2025-11-24, in that relative order. They are forwarded when the
|
|
// caller asks for them and dropped otherwise.
|
|
var claudeCodeTrailingBetas = []string{
|
|
claudeServerSideFallbackBeta,
|
|
claudeFallbackCreditBeta,
|
|
claudeStructuredOutputsBeta,
|
|
}
|
|
|
|
// claudeCodeCLIBetas assembles the Anthropic-Beta baseline the way Claude Code
|
|
// 2.1.220 does: the list is per-request, not a fixed string. requested holds the
|
|
// betas the caller asked for, which decide the capability flags below.
|
|
//
|
|
// Verified 2026-08-01 against api.anthropic.com with isolated 2.1.220 profiles on
|
|
// both the API-key and OAuth paths, across 11 model IDs and the [1m] variants.
|
|
// The full observed order is:
|
|
//
|
|
// 1 claude-code-20250219
|
|
// 2 oauth-2025-04-20 OAuth credentials only
|
|
// 3 context-1m-2025-08-07 [1m] model variants only
|
|
// 4 interleaved-thinking-2025-05-14
|
|
// 5 redact-thinking-2026-02-12 cli entrypoint only
|
|
// 6 thinking-token-count-2026-05-13
|
|
// 7 context-management-2025-06-27
|
|
// 8 prompt-caching-scope-2026-01-05
|
|
// 9 mid-conversation-system-2026-04-07 models accepting a role=system turn
|
|
// 10 advanced-tool-use-2025-11-20 requests declaring tools
|
|
// 11 effort-2025-11-24
|
|
// 12 server-side-fallback-2026-06-01
|
|
// 13 fallback-credit-2026-06-01
|
|
// 14 extended-cache-ttl-2025-04-11 OAuth credentials only, always last
|
|
//
|
|
// fast-mode-2026-02-01 has no captured position; it is emitted just before the
|
|
// OAuth trailer so the one measured invariant, extended-cache-ttl last, holds.
|
|
//
|
|
// An empty body keeps the optimistic role=system default, matching the cloaking
|
|
// policy for unknown and future model IDs.
|
|
func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool) string {
|
|
betas := make([]string, 0, len(claudeCodeCLIConstantBetas)+len(claudeCodeTrailingBetas)+6)
|
|
betas = append(betas, claudeCodeBeta)
|
|
if oauthToken {
|
|
betas = append(betas, claudeOAuthBeta)
|
|
}
|
|
if requested[claudeContext1MBeta] {
|
|
betas = append(betas, claudeContext1MBeta)
|
|
}
|
|
betas = append(betas, claudeCodeCLIConstantBetas...)
|
|
if !claudeUsesLegacySystemReminder(body) {
|
|
betas = append(betas, claudeMidConvSystemBeta)
|
|
}
|
|
if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 {
|
|
betas = append(betas, claudeAdvancedToolUseBeta)
|
|
}
|
|
betas = append(betas, claudeEffortBeta)
|
|
for _, beta := range claudeCodeTrailingBetas {
|
|
if requested[beta] {
|
|
betas = append(betas, beta)
|
|
}
|
|
}
|
|
if claudeRequestUsesFastMode(body, requested) {
|
|
betas = append(betas, claudeFastModeBeta)
|
|
}
|
|
if oauthToken {
|
|
betas = append(betas, claudeExtendedCacheTTLBeta)
|
|
}
|
|
return strings.Join(betas, ",")
|
|
}
|
|
|
|
// claudeRequestUsesFastMode reports whether the request selects the fast service
|
|
// tier. Anthropic rejects the body's speed field with "Extra inputs are not
|
|
// permitted" unless fast-mode-2026-02-01 is declared, so the beta has to follow
|
|
// the body. Deriving it here rather than at the call sites is deliberate: the
|
|
// streaming and non-streaming paths previously disagreed and streaming silently
|
|
// dropped the beta, turning every fast request into a 400.
|
|
func claudeRequestUsesFastMode(body []byte, requested map[string]bool) bool {
|
|
if requested[claudeFastModeBeta] {
|
|
return true
|
|
}
|
|
speed := gjson.GetBytes(body, "speed")
|
|
return speed.Type == gjson.String && strings.EqualFold(strings.TrimSpace(speed.String()), "fast")
|
|
}
|
|
|
|
// claudeCountTokensBetas is the fixed profile Claude Code 2.1.220 sends to
|
|
// /v1/messages/count_tokens. It is far smaller than the inference baseline:
|
|
// redact-thinking, thinking-token-count, prompt-caching-scope, effort and every
|
|
// conditional beta are absent. Verified identical across 37 captured calls.
|
|
var claudeCountTokensBetas = []string{
|
|
claudeCodeBeta,
|
|
"interleaved-thinking-2025-05-14",
|
|
"context-management-2025-06-27",
|
|
claudeTokenCountingBeta,
|
|
}
|
|
|
|
// withClaudeOAuthCredentialBetas restores the two betas that describe the
|
|
// upstream credential rather than the caller's capabilities.
|
|
//
|
|
// A confirmed native client authenticates to CPA with whatever key the user
|
|
// configured and cannot know that CPA will select an OAuth credential upstream,
|
|
// so its header never carries the OAuth betas. Passing it through verbatim ships
|
|
// a Bearer request that declares neither oauth-2025-04-20 nor
|
|
// extended-cache-ttl-2025-04-11, which no real OAuth client ever does. Passthrough
|
|
// governs what the caller expressed; the credential is CPA's own choice and has to
|
|
// be described accurately.
|
|
//
|
|
// Betas already present are left exactly where the caller put them.
|
|
func withClaudeOAuthCredentialBetas(betas string) string {
|
|
parts := make([]string, 0, 16)
|
|
seen := make(map[string]bool)
|
|
for _, beta := range strings.Split(betas, ",") {
|
|
if beta = strings.TrimSpace(beta); beta != "" && !seen[beta] {
|
|
parts = append(parts, beta)
|
|
seen[beta] = true
|
|
}
|
|
}
|
|
if !seen[claudeOAuthBeta] {
|
|
// Captured position 2, directly after claude-code-20250219.
|
|
insertAt := 0
|
|
if len(parts) > 0 && parts[0] == claudeCodeBeta {
|
|
insertAt = 1
|
|
}
|
|
parts = append(parts, "")
|
|
copy(parts[insertAt+1:], parts[insertAt:])
|
|
parts[insertAt] = claudeOAuthBeta
|
|
}
|
|
if !seen[claudeExtendedCacheTTLBeta] {
|
|
parts = append(parts, claudeExtendedCacheTTLBeta)
|
|
}
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
// claudeRequestedBetas collects every beta the caller asked for, from the
|
|
// Anthropic-Beta header and from betas lifted out of the request body.
|
|
func claudeRequestedBetas(incomingBetas string, extraBetas []string) map[string]bool {
|
|
requested := make(map[string]bool)
|
|
for _, beta := range strings.Split(incomingBetas, ",") {
|
|
if beta = strings.TrimSpace(beta); beta != "" {
|
|
requested[beta] = true
|
|
}
|
|
}
|
|
for _, beta := range extraBetas {
|
|
if beta = strings.TrimSpace(beta); beta != "" {
|
|
requested[beta] = true
|
|
}
|
|
}
|
|
return requested
|
|
}
|
|
|
|
// isAnthropicUpstreamURL reports whether a resolved request targets Anthropic's
|
|
// first-party API.
|
|
//
|
|
// Every rule that reconstructs Claude Code's identity must key on this rather
|
|
// than on the cloaked flag. Kimi rewrites base_url to api.kimi.com and custom
|
|
// gateways set their own host, yet both delegate to ClaudeExecutor and are
|
|
// therefore cloaked; a cloak-keyed rule silently rewrites their traffic too.
|
|
func isAnthropicUpstreamURL(u *url.URL) bool {
|
|
return u != nil && strings.EqualFold(u.Scheme, "https") && strings.EqualFold(u.Host, "api.anthropic.com")
|
|
}
|
|
|
|
// isAnthropicUpstreamBase reports whether a configured base URL targets Anthropic's
|
|
// first-party API. Used before the outgoing request exists.
|
|
func isAnthropicUpstreamBase(baseURL string) bool {
|
|
parsed, err := url.Parse(strings.TrimSpace(baseURL))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return isAnthropicUpstreamURL(parsed)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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, body []byte, 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 := isAnthropicUpstreamURL(r.URL)
|
|
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
|
|
}
|
|
}
|
|
|
|
incomingBetas := strings.TrimSpace(strings.Join(incomingHeaders.Values("Anthropic-Beta"), ","))
|
|
countTokens := r.URL != nil && strings.HasSuffix(r.URL.Path, "/count_tokens")
|
|
baseBetas := claudeCodeCLIBetas(body, claudeRequestedBetas(incomingBetas, extraBetas), oauthToken)
|
|
if countTokens {
|
|
baseBetas = strings.Join(claudeCountTokensBetas, ",")
|
|
}
|
|
if confirmedClaudeCode && incomingBetas != "" {
|
|
baseBetas = incomingBetas
|
|
if oauthToken && !countTokens {
|
|
baseBetas = withClaudeOAuthCredentialBetas(baseBetas)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
// On direct Anthropic an unconfirmed caller's own betas are dropped: appending
|
|
// them to the official baseline produces a combination real Claude Code never
|
|
// sends, which defeats the identity the rest of this path reconstructs. Other
|
|
// Anthropic-compatible upstreams (Kimi, custom gateways) run no such check, so
|
|
// caller betas stay functional there. This matches the CCH signing gate, which
|
|
// is likewise limited to api.anthropic.com.
|
|
if !confirmedClaudeCode && incomingBetas != "" && !isAnthropicBase {
|
|
for _, beta := range strings.Split(incomingBetas, ",") {
|
|
appendBeta(beta)
|
|
}
|
|
}
|
|
// The OAuth betas have known positions on /v1/messages and are placed by
|
|
// claudeCodeCLIBetas. count_tokens was only captured over an API key, so its
|
|
// OAuth shape keeps the previous appended form until it can be measured.
|
|
if oauthToken && countTokens {
|
|
appendBeta(claudeOAuthBeta)
|
|
}
|
|
// Betas lifted out of the body follow the same policy as header-supplied ones.
|
|
// Known betas already reached the assembled baseline through the requested map,
|
|
// which places them at their captured positions; anything left over is unknown
|
|
// to Claude Code and Anthropic rejects it outright. Forwarding those verbatim
|
|
// here was letting the body bypass the gate the header path enforces.
|
|
if !isAnthropicBase {
|
|
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")
|
|
// Claude Code omits X-Stainless-Timeout on count_tokens; only a confirmed
|
|
// native client that sent one of its own keeps it there.
|
|
if !countTokens {
|
|
identityHeader("X-Stainless-Timeout", hdrDefault(hd.Timeout, "600"))
|
|
} else if confirmedClaudeCode {
|
|
if incomingTimeout := incomingHeaders.Get("X-Stainless-Timeout"); incomingTimeout != "" {
|
|
r.Header.Set("X-Stainless-Timeout", incomingTimeout)
|
|
}
|
|
}
|
|
// 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")
|
|
// Claude Code negotiates transport identically for streaming and non-streaming
|
|
// requests: Accept stays application/json and full compression is offered even
|
|
// when the body sets stream:true, because Anthropic selects SSE from the body
|
|
// rather than from Accept. Verified across every captured 2.1.220 stream.
|
|
// Forcing text/event-stream plus identity here would otherwise mark every
|
|
// streaming request, which is nearly all traffic. decodeResponseBody already
|
|
// wraps the success path, so a compressed SSE body is decoded transparently.
|
|
applyTransportNegotiation := func() {
|
|
if stream && !isAnthropicBase {
|
|
// Other Anthropic-compatible upstreams (Kimi, custom gateways) may select
|
|
// SSE from Accept and need not compress predictably, so they keep the
|
|
// conservative contract.
|
|
r.Header.Set("Accept", "text/event-stream")
|
|
r.Header.Set("Accept-Encoding", "identity")
|
|
return
|
|
}
|
|
r.Header.Set("Accept", "application/json")
|
|
r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
|
|
}
|
|
applyTransportNegotiation()
|
|
// 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)
|
|
// Custom credential headers are a configuration escape hatch for third-party
|
|
// gateways, so they keep the last word there. On api.anthropic.com they must
|
|
// not rewrite the reconstructed identity: an overridden Anthropic-Beta yields a
|
|
// combination real Claude Code never sends and the API rejects, and an
|
|
// overridden Accept-Encoding contradicts the negotiated transport. Both were
|
|
// reachable because this ran after the whole header set was assembled.
|
|
if isAnthropicBase {
|
|
r.Header.Set("Anthropic-Beta", baseBetas)
|
|
applyTransportNegotiation()
|
|
} else if stream {
|
|
// Elsewhere only streaming is protected, so an Accept override cannot
|
|
// silently disable event negotiation.
|
|
applyTransportNegotiation()
|
|
}
|
|
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 a
|
|
// semantic 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 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] {
|
|
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
|
|
}
|