mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 14:39:26 +08:00
Derive cache-diagnosis from the final Messages body so cloaked OAuth requests reproduce Claude Code's exact diagnostics trailer instead of receiving an Anthropic 400.
1652 lines
56 KiB
Go
1652 lines
56 KiB
Go
package executor
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"compress/flate"
|
|
"compress/gzip"
|
|
"compress/zlib"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/andybalholm/brotli"
|
|
"github.com/google/uuid"
|
|
"github.com/klauspost/compress/zstd"
|
|
claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude"
|
|
"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"
|
|
claudeCacheDiagnosisBeta = "cache-diagnosis-2026-04-07"
|
|
)
|
|
|
|
// 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 against api.anthropic.com with isolated 2.1.220 profiles on both
|
|
// API-key and OAuth paths. A 2026-08-03 A/B capture with two distinct OAuth
|
|
// accounts confirmed the current tool beta and OAuth trailer below.
|
|
// 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 with tools
|
|
// 11 effort-2025-11-24
|
|
// 12 server-side-fallback-2026-06-01
|
|
// 13 fallback-credit-2026-06-01
|
|
// 14 fast-mode-2026-02-01 speed:fast requests only
|
|
// 15 extended-cache-ttl-2025-04-11 OAuth credentials only
|
|
// 16 cache-diagnosis-2026-04-07 requests with diagnostics only
|
|
//
|
|
// 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)+7)
|
|
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)
|
|
if oauthToken && !requested[claudeFallbackCreditBeta] {
|
|
betas = append(betas, claudeFallbackCreditBeta)
|
|
}
|
|
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)
|
|
}
|
|
if diagnostics := gjson.GetBytes(body, "diagnostics"); diagnostics.IsObject() {
|
|
betas = append(betas, claudeCacheDiagnosisBeta)
|
|
}
|
|
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,
|
|
}
|
|
|
|
func claudeCountTokensBetasForCredential(oauthToken bool) string {
|
|
betas := make([]string, 0, len(claudeCountTokensBetas)+1)
|
|
betas = append(betas, claudeCodeBeta)
|
|
if oauthToken {
|
|
betas = append(betas, claudeOAuthBeta)
|
|
}
|
|
betas = append(betas, claudeCountTokensBetas[1:]...)
|
|
return strings.Join(betas, ",")
|
|
}
|
|
|
|
func withClaudeCountTokensOAuthBeta(betas string) string {
|
|
parts := make([]string, 0, len(claudeCountTokensBetas)+1)
|
|
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] {
|
|
return strings.Join(parts, ",")
|
|
}
|
|
insertAt := 0
|
|
if len(parts) > 0 && parts[0] == claudeCodeBeta {
|
|
insertAt = 1
|
|
}
|
|
parts = append(parts, "")
|
|
copy(parts[insertAt+1:], parts[insertAt:])
|
|
parts[insertAt] = claudeOAuthBeta
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
// withClaudeOAuthCredentialBetas restores the credential-scoped betas that
|
|
// describe the selected upstream OAuth account rather than caller capability.
|
|
//
|
|
// 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, ",")
|
|
}
|
|
|
|
// claudeEntitlementError marks an upstream refusal that is a property of the
|
|
// request shape combined with the account's entitlements, not of the credential's
|
|
// health. The auth manager must neither rotate nor cool down on these.
|
|
type claudeEntitlementError struct {
|
|
statusErr
|
|
}
|
|
|
|
func (claudeEntitlementError) IsRequestScoped() bool {
|
|
return true
|
|
}
|
|
|
|
// classifyClaudeUpstreamError promotes upstream refusals that no other credential
|
|
// can satisfy into request-scoped errors.
|
|
//
|
|
// Anthropic answers a fast-mode request from an account without the matching
|
|
// usage credits with 429 rate_limit_error "Usage credits are required for fast
|
|
// mode". The generic pipeline reads 429 as quota exhaustion: it marks the
|
|
// credential Quota.Exceeded, applies an exponential cooldown and rotates to the
|
|
// next one, which returns the same 429. A single speed:"fast" request would walk
|
|
// the whole Claude pool and cool down every credential, all of which remain
|
|
// perfectly healthy for ordinary traffic. The refusal belongs to the request.
|
|
func classifyClaudeUpstreamError(statusCode int, body []byte) error {
|
|
err := statusErr{code: statusCode, msg: string(body)}
|
|
if statusCode == http.StatusTooManyRequests && claudeBodyIndicatesFastModeCredits(body) {
|
|
return claudeEntitlementError{err}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// claudeBodyIndicatesFastModeCredits matches Anthropic's fast-mode entitlement
|
|
// refusal without matching a genuine rate limit, which never mentions fast mode.
|
|
func claudeBodyIndicatesFastModeCredits(body []byte) bool {
|
|
message := strings.ToLower(gjson.GetBytes(body, "error.message").String())
|
|
if message == "" {
|
|
message = strings.ToLower(string(body))
|
|
}
|
|
return strings.Contains(message, "fast mode") &&
|
|
(strings.Contains(message, "usage credits") || strings.Contains(message, "credits are required"))
|
|
}
|
|
|
|
// 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 helps.IsAnthropicUpstreamURL(u)
|
|
}
|
|
|
|
// 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 claudeResponseContentEncoding(header http.Header) string {
|
|
return strings.Join(header.Values("Content-Encoding"), ",")
|
|
}
|
|
|
|
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, ",")
|
|
reader := io.Reader(body)
|
|
decoderClosers := make([]func() error, 0, len(encodings))
|
|
cleanup := func() {
|
|
for i := len(decoderClosers) - 1; i >= 0; i-- {
|
|
_ = decoderClosers[i]()
|
|
}
|
|
_ = body.Close()
|
|
}
|
|
for index := len(encodings) - 1; index >= 0; index-- {
|
|
encoding := strings.TrimSpace(strings.ToLower(encodings[index]))
|
|
switch encoding {
|
|
case "", "identity":
|
|
continue
|
|
case "gzip":
|
|
gzipReader, errGzip := gzip.NewReader(reader)
|
|
if errGzip != nil {
|
|
cleanup()
|
|
return nil, fmt.Errorf("failed to create gzip reader: %w", errGzip)
|
|
}
|
|
reader = gzipReader
|
|
decoderClosers = append(decoderClosers, gzipReader.Close)
|
|
case "deflate":
|
|
deflateReader, errDeflate := newClaudeDeflateReader(reader)
|
|
if errDeflate != nil {
|
|
cleanup()
|
|
return nil, errDeflate
|
|
}
|
|
reader = deflateReader
|
|
decoderClosers = append(decoderClosers, deflateReader.Close)
|
|
case "br":
|
|
reader = brotli.NewReader(reader)
|
|
case "zstd":
|
|
decoder, errZstd := zstd.NewReader(reader)
|
|
if errZstd != nil {
|
|
cleanup()
|
|
return nil, fmt.Errorf("failed to create zstd reader: %w", errZstd)
|
|
}
|
|
reader = decoder
|
|
decoderClosers = append(decoderClosers, func() error {
|
|
decoder.Close()
|
|
return nil
|
|
})
|
|
default:
|
|
cleanup()
|
|
return nil, fmt.Errorf("unsupported content encoding %q", encoding)
|
|
}
|
|
}
|
|
if len(decoderClosers) == 0 && reader == body {
|
|
return body, nil
|
|
}
|
|
closers := make([]func() error, 0, len(decoderClosers)+1)
|
|
for index := len(decoderClosers) - 1; index >= 0; index-- {
|
|
closers = append(closers, decoderClosers[index])
|
|
}
|
|
closers = append(closers, body.Close)
|
|
return &compositeReadCloser{Reader: reader, closers: closers}, nil
|
|
}
|
|
|
|
func newClaudeDeflateReader(reader io.Reader) (io.ReadCloser, error) {
|
|
buffered := bufio.NewReader(reader)
|
|
header, errPeek := buffered.Peek(2)
|
|
if errPeek == nil && isZlibHeader(header) {
|
|
zlibReader, errZlib := zlib.NewReader(buffered)
|
|
if errZlib != nil {
|
|
return nil, fmt.Errorf("failed to create zlib deflate reader: %w", errZlib)
|
|
}
|
|
return zlibReader, nil
|
|
}
|
|
return flate.NewReader(buffered), nil
|
|
}
|
|
|
|
func isZlibHeader(header []byte) bool {
|
|
if len(header) < 2 {
|
|
return false
|
|
}
|
|
cmf, flg := header[0], header[1]
|
|
return cmf&0x0f == 8 && cmf>>4 <= 7 && (uint16(cmf)<<8|uint16(flg))%31 == 0
|
|
}
|
|
|
|
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 = claudeCountTokensBetasForCredential(oauthToken)
|
|
}
|
|
if confirmedClaudeCode && incomingBetas != "" {
|
|
baseBetas = incomingBetas
|
|
if oauthToken {
|
|
if countTokens {
|
|
baseBetas = withClaudeCountTokensOAuthBeta(baseBetas)
|
|
} else {
|
|
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)
|
|
}
|
|
}
|
|
// 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
|
|
}
|
|
|
|
// doClaudeUpstreamRequest is the single send boundary for every Claude upstream
|
|
// call. Folding the wire-casing pass in here makes it structurally impossible
|
|
// for one of the three request paths to drift away from the others, which is
|
|
// exactly how the streaming and non-streaming beta sets diverged before.
|
|
func doClaudeUpstreamRequest(client *http.Client, req *http.Request) (*http.Response, error) {
|
|
applyClaudeWireHeaderCasing(req)
|
|
return client.Do(req)
|
|
}
|
|
|
|
// claudeWireHeaderCasing maps Go's canonical header name to the exact casing
|
|
// Claude Code 2.1.220 puts on the wire. Only the names that differ are listed;
|
|
// the other twelve already survive canonicalisation unchanged.
|
|
var claudeWireHeaderCasing = map[string]string{
|
|
"X-Stainless-Os": "X-Stainless-OS",
|
|
"Anthropic-Beta": "anthropic-beta",
|
|
"Anthropic-Version": "anthropic-version",
|
|
"X-App": "x-app",
|
|
"X-Client-Request-Id": "x-client-request-id",
|
|
|
|
"Anthropic-Dangerous-Direct-Browser-Access": "anthropic-dangerous-direct-browser-access",
|
|
}
|
|
|
|
// applyClaudeWireHeaderCasing restores the header name casing of the real client.
|
|
//
|
|
// CPA negotiates ALPN http/1.1 with Anthropic, so header names reach the server
|
|
// verbatim rather than lowercased by HPACK, which makes casing observable. Go
|
|
// canonicalises every name passed through Header.Set, turning the client's
|
|
// anthropic-beta and x-app into Anthropic-Beta and X-App. Writing the map keys
|
|
// directly is the only way to keep the original casing.
|
|
//
|
|
// This also fixes ordering for free: Go sorts header names bytewise when it
|
|
// serialises them, and the real client's order is exactly that same bytewise
|
|
// sort, so correct casing reproduces the correct order. Host, User-Agent and
|
|
// Content-Length remain misplaced because Go writes them ahead of the sorted
|
|
// block; that needs transport-level surgery and is out of scope here.
|
|
//
|
|
// Call this immediately before handing the request to the client and nowhere
|
|
// else. The rewritten keys are unreachable through Header.Get, which
|
|
// canonicalises its argument, so running it any earlier would silently hide
|
|
// these headers from the rest of the pipeline.
|
|
func applyClaudeWireHeaderCasing(r *http.Request) {
|
|
if r == nil || r.Header == nil || !isAnthropicUpstreamURL(r.URL) {
|
|
return
|
|
}
|
|
for canonical, wire := range claudeWireHeaderCasing {
|
|
values, ok := r.Header[canonical]
|
|
if !ok {
|
|
continue
|
|
}
|
|
delete(r.Header, canonical)
|
|
r.Header[wire] = values
|
|
}
|
|
}
|
|
|
|
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 == "" {
|
|
apiKey = claudeauth.ReadMetadataString(&a.Metadata, "access_token")
|
|
}
|
|
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"})
|
|
}
|
|
|
|
type claudeRawJSONEdit struct {
|
|
start int
|
|
end int
|
|
replacement string
|
|
}
|
|
|
|
func remapOAuthToolNamesWithOptions(body []byte, mcpAliases claudeMCPAliasOptions) ([]byte, map[string]string) {
|
|
remapped, reverseMap, ok := remapOAuthToolNamesWithBatchedEdits(body, mcpAliases)
|
|
if ok {
|
|
return remapped, reverseMap
|
|
}
|
|
return remapOAuthToolNamesWithOptionsLegacy(body, mcpAliases)
|
|
}
|
|
|
|
// remapOAuthToolNamesWithBatchedEdits records offsets from the original JSON
|
|
// and applies every rename in one copy. Repeated sjson.SetBytes calls copy most
|
|
// of the request for every historical tool reference, turning this path into
|
|
// O(body size * reference count) allocation growth.
|
|
func remapOAuthToolNamesWithBatchedEdits(body []byte, mcpAliases claudeMCPAliasOptions) ([]byte, map[string]string, bool) {
|
|
if !gjson.ValidBytes(body) {
|
|
return nil, nil, false
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
edits := make([]claudeRawJSONEdit, 0, len(forwardMap)+1)
|
|
appendRawEdit := func(result gjson.Result, replacement string) bool {
|
|
start := result.Index
|
|
end := start + len(result.Raw)
|
|
if result.Raw == "" || start < 0 || end < start || end > len(body) || !bytes.Equal(body[start:end], []byte(result.Raw)) {
|
|
return false
|
|
}
|
|
edits = append(edits, claudeRawJSONEdit{start: start, end: end, replacement: replacement})
|
|
return true
|
|
}
|
|
appendStringEdit := func(result gjson.Result, replacement string) bool {
|
|
// ClaudeMCPToolAlias only emits [A-Za-z0-9_-], so adding quotes is
|
|
// byte-identical to sjson's encoding without another allocation.
|
|
return appendRawEdit(result, `"`+replacement+`"`)
|
|
}
|
|
|
|
// 1. Rebuild typed custom tools exactly as before, but replace the original
|
|
// tools array only after all offsets have been collected.
|
|
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(']')
|
|
if !appendRawEdit(tools, toolsJSON.String()) {
|
|
return nil, nil, false
|
|
}
|
|
}
|
|
|
|
// 2. Rename tool_choice if it references a declared client tool.
|
|
toolChoice := gjson.GetBytes(body, "tool_choice")
|
|
if toolChoice.Get("type").String() == "tool" {
|
|
nameResult := toolChoice.Get("name")
|
|
tcName := nameResult.String()
|
|
if newName, renamed := rewriteName(tcName); renamed {
|
|
if !appendStringEdit(nameResult, newName) {
|
|
return nil, nil, false
|
|
}
|
|
recordRename(tcName, newName)
|
|
}
|
|
}
|
|
|
|
// 3. Rename tool references in messages while every Result.Index still
|
|
// points into the original request bytes.
|
|
messages := gjson.GetBytes(body, "messages")
|
|
validOffsets := true
|
|
if messages.Exists() && messages.IsArray() {
|
|
messages.ForEach(func(_, msg gjson.Result) bool {
|
|
content := msg.Get("content")
|
|
if !content.Exists() || !content.IsArray() {
|
|
return true
|
|
}
|
|
content.ForEach(func(_, part gjson.Result) bool {
|
|
switch part.Get("type").String() {
|
|
case "tool_use":
|
|
nameResult := part.Get("name")
|
|
name := nameResult.String()
|
|
if newName, renamed := rewriteName(name); renamed {
|
|
if !appendStringEdit(nameResult, newName) {
|
|
validOffsets = false
|
|
return false
|
|
}
|
|
recordRename(name, newName)
|
|
}
|
|
case "tool_reference":
|
|
nameResult := part.Get("tool_name")
|
|
toolName := nameResult.String()
|
|
if newName, renamed := rewriteName(toolName); renamed {
|
|
if !appendStringEdit(nameResult, newName) {
|
|
validOffsets = false
|
|
return false
|
|
}
|
|
recordRename(toolName, newName)
|
|
}
|
|
case "tool_result":
|
|
nestedContent := part.Get("content")
|
|
if nestedContent.Exists() && nestedContent.IsArray() {
|
|
nestedContent.ForEach(func(_, nestedPart gjson.Result) bool {
|
|
if nestedPart.Get("type").String() != "tool_reference" {
|
|
return true
|
|
}
|
|
nameResult := nestedPart.Get("tool_name")
|
|
nestedToolName := nameResult.String()
|
|
if newName, renamed := rewriteName(nestedToolName); renamed {
|
|
if !appendStringEdit(nameResult, newName) {
|
|
validOffsets = false
|
|
return false
|
|
}
|
|
recordRename(nestedToolName, newName)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
return validOffsets
|
|
})
|
|
return validOffsets
|
|
})
|
|
}
|
|
if !validOffsets {
|
|
return nil, nil, false
|
|
}
|
|
|
|
remapped, ok := applyClaudeRawJSONEdits(body, edits)
|
|
if !ok {
|
|
return nil, nil, false
|
|
}
|
|
return remapped, reverseMap, true
|
|
}
|
|
|
|
func applyClaudeRawJSONEdits(body []byte, edits []claudeRawJSONEdit) ([]byte, bool) {
|
|
if len(edits) == 0 {
|
|
return body, true
|
|
}
|
|
sort.Slice(edits, func(i, j int) bool {
|
|
return edits[i].start < edits[j].start
|
|
})
|
|
|
|
finalSize := len(body)
|
|
cursor := 0
|
|
for _, edit := range edits {
|
|
if edit.start < cursor || edit.start < 0 || edit.end < edit.start || edit.end > len(body) {
|
|
return nil, false
|
|
}
|
|
finalSize += len(edit.replacement) - (edit.end - edit.start)
|
|
if finalSize < 0 {
|
|
return nil, false
|
|
}
|
|
cursor = edit.end
|
|
}
|
|
|
|
out := make([]byte, 0, finalSize)
|
|
cursor = 0
|
|
for _, edit := range edits {
|
|
out = append(out, body[cursor:edit.start]...)
|
|
out = append(out, edit.replacement...)
|
|
cursor = edit.end
|
|
}
|
|
out = append(out, body[cursor:]...)
|
|
return out, true
|
|
}
|
|
|
|
// remapOAuthToolNamesWithOptionsLegacy is the byte-for-byte compatibility
|
|
// fallback for malformed JSON or an unexpected GJSON offset. Keep it available
|
|
// as a differential-test oracle for the batched implementation.
|
|
func remapOAuthToolNamesWithOptionsLegacy(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
|
|
}
|