Files
CLIProxyAPI/internal/runtime/executor/claude_executor_tokens.go
sususu aec70dfec4 fix(claude): gate CCH signing by upstream origin and validate fingerprint-profile
Claude Code 2.1.220 through 2.1.234 emit the cch attribution only for
firstParty on api.anthropic.com and for vertex; every other backend sends
the billing header unsigned. CPA had dropped its endpoint check, so an
opted-in API key signed a per-request hash on any gateway and could bust
that gateway's prompt cache.

- Restore the endpoint gate in claudeCCHSigningEnabled: a real Claude OAuth
  credential still signs on every upstream, because a downstream Claude Code
  pointed at CPA cannot produce that value itself, while a claude-code-cli
  API key signs only on api.anthropic.com or Vertex
- Drop the unused origin parameter from Claude fingerprint policy resolution
  and restore the original resolveClaudeWirePolicy signature; the wire profile
  follows the credential and only CCH follows the origin
- Add config.NormalizeClaudeFingerprintProfile / ValidateClaudeFingerprintProfile
  as the single source of truth for fingerprint-profile values
- Reject unknown fingerprint-profile values in the Management API, and warn
  once per distinct value at request time instead of on every resolution,
  which previously logged about four warnings per request for one typo
- Preserve unrecognized values through config sanitization so rewriting a
  config file never discards operator input
- Update config.example.yaml and tests for the origin-scoped CCH behavior
2026-08-18 18:06:40 +08:00

299 lines
13 KiB
Go

package executor
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
apiKey, baseURL := claudeCreds(auth)
if baseURL == "" {
baseURL = "https://api.anthropic.com"
}
// Only Anthropic's first-party origin has the measured native count_tokens
// contract. Every custom/third-party base URL keeps local estimation,
// regardless of whether the credential is OAuth or an API key.
if shouldUseClaudeUpstreamTokenCount(apiKey, baseURL) {
return e.countTokensUpstream(ctx, auth, req, opts)
}
baseModel := thinking.ParseSuffix(req.Model).ModelName
from := opts.SourceFormat
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
to := sdktranslator.FromString("claude")
// Use streaming translation to preserve function calling, except for claude.
stream := from != to
body := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream, helps.APIKeyModelIsCompat(req))
var errThinking error
body, errThinking = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
if errThinking != nil {
return cliproxyexecutor.Response{}, errThinking
}
if rebuildMidSystemMessageEnabled(e.cfg, auth) {
body = rebuildMidSystemMessagesToTopLevel(body)
}
body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel, helps.APIKeyModelIsCompat(req))
if errValidate := validateClaudeTokenCountRequest(body); errValidate != nil {
return cliproxyexecutor.Response{}, errValidate
}
// Custom API-key gateways without a native count_tokens contract continue to
// use the local estimator without injecting generation-only CLI instructions.
count, err := helps.CountClaudeInputTokens(body)
if err != nil {
return cliproxyexecutor.Response{}, fmt.Errorf("claude executor: token counting failed: %w", err)
}
usageJSON := []byte(fmt.Sprintf(`{"input_tokens":%d}`, count))
out := sdktranslator.TranslateTokenCount(ctx, to, responseFormat, count, usageJSON)
return cliproxyexecutor.Response{Payload: out}, nil
}
type claudeTokenCountValidationError struct {
statusErr
}
func (claudeTokenCountValidationError) IsRequestScoped() bool {
return true
}
func newClaudeTokenCountValidationError(message string) error {
return claudeTokenCountValidationError{statusErr{code: http.StatusBadRequest, msg: message}}
}
func validateClaudeTokenCountRequest(body []byte) error {
if !gjson.ValidBytes(body) {
return newClaudeTokenCountValidationError("invalid Claude token count request JSON")
}
root := gjson.ParseBytes(body)
if !root.IsObject() {
return newClaudeTokenCountValidationError("Claude token count request must be a JSON object")
}
messages := root.Get("messages")
if !messages.IsArray() || len(messages.Array()) == 0 {
return newClaudeTokenCountValidationError("Claude token count request messages must be a non-empty array")
}
for _, message := range messages.Array() {
if !message.IsObject() {
return newClaudeTokenCountValidationError("Claude token count request messages must contain objects")
}
role := message.Get("role").String()
if role != "user" && role != "assistant" {
return newClaudeTokenCountValidationError("Claude token count request message role must be user or assistant")
}
content := message.Get("content")
if content.Type == gjson.String {
continue
}
if !content.IsArray() {
return newClaudeTokenCountValidationError("Claude token count request message content must be a string or array")
}
for _, block := range content.Array() {
if !block.IsObject() || block.Get("type").Type != gjson.String || block.Get("type").String() == "" {
return newClaudeTokenCountValidationError("Claude token count request content blocks must be typed objects")
}
}
}
return nil
}
func shouldUseClaudeUpstreamTokenCount(apiKey, baseURL string) bool {
return strings.TrimSpace(apiKey) != "" && isAnthropicUpstreamBase(baseURL)
}
// countTokensUpstream preserves Anthropic's native token-counting contract.
func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName
upstreamModel := e.upstreamModel(baseModel)
apiKey, baseURL := claudeCreds(auth)
if baseURL == "" {
baseURL = "https://api.anthropic.com"
}
url := fmt.Sprintf("%s/v1/messages/count_tokens?beta=true", baseURL)
fp := resolveClaudeFingerprintPolicy(e.cfg, auth, apiKey)
from := opts.SourceFormat
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
to := sdktranslator.FromString("claude")
originalPayload := req.Payload
if len(opts.OriginalRequest) > 0 {
originalPayload = opts.OriginalRequest
}
incomingHeaders, claudeCodeDetection := detectIncomingClaudeCodeRequest(ctx, opts.Headers, originalPayload, true, e.cfg)
confirmedClaudeCode := claudeCodeDetection.Confirmed
claudeSessionID := ""
if fp.ProfileClaudeCodeCLI {
claudeSessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, originalPayload, req.Payload, confirmedClaudeCode, opts.Metadata, req.Metadata)
}
// Use streaming translation to preserve function calling, except for claude.
stream := from != to
body := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream, helps.APIKeyModelIsCompat(req))
body = helps.SetStringIfDifferent(body, "model", upstreamModel)
var errThinking error
body, errThinking = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
if errThinking != nil {
return cliproxyexecutor.Response{}, errThinking
}
if rebuildMidSystemMessageEnabled(e.cfg, auth) {
body = rebuildMidSystemMessagesToTopLevel(body)
}
directAnthropic := isAnthropicUpstreamBase(baseURL)
// Claude Code's count_tokens carries only model, messages and tools, so the
// full Messages cloaking must not run here for any origin. Apply the parts
// that still have to hold: relocate the caller's system prompt into messages
// so its tokens stay counted, and obfuscate sensitive words exactly like the
// Messages path. Kimi opt-in uses the same contract.
policy, settings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode)
cloaked := policy.Cloak
if cloaked {
if !settings.strictMode {
if errSystem := validateClaudeCallerSystemBlocks(gjson.GetBytes(body, "system")); errSystem != nil {
return cliproxyexecutor.Response{}, errSystem
}
}
body = relocateClaudeSystemPromptForCountTokens(body, settings.strictMode)
if len(settings.sensitiveWords) > 0 {
body = helps.ObfuscateSensitiveWords(body, helps.BuildSensitiveWordMatcher(settings.sensitiveWords))
}
}
// Keep count_tokens requests compatible with Anthropic cache-control constraints too.
body = enforceCacheControlLimit(body, 4)
body = normalizeCacheControlTTL(body)
// Extract betas from body and convert to header (for count_tokens too)
var extraBetas []string
extraBetas, body = extractAndRemoveBetas(body)
// Claude Code 2.1.220's beta.messages.countTokens() always appends this beta.
extraBetas = append(extraBetas, claudeTokenCountingBeta)
if fp.MCPAlias && cloaked {
mcpAliases := resolveClaudeMCPAliasOptions(ctx)
body, _ = prepareClaudeOAuthToolNamesForUpstream(body, mcpAliases)
}
body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel, helps.APIKeyModelIsCompat(req))
// Two different reasons converge on the same deletions, and they must stay
// separable.
//
// api.anthropic.com rejects these fields on count_tokens outright ("metadata:
// Extra inputs are not permitted"), so they have to go for every credential
// that lands there, opted in or not. That is upstream compatibility, not
// fingerprinting.
//
// Elsewhere (Kimi, delegated Anthropic Messages providers) the caller owns its
// body by default: a caller that deliberately sends context_management expects
// the token count to reflect it, so CPA must not silently rewrite the request.
// Only an explicit claude-code-cli profile aligns the shape, and then it aligns
// to the measured one: Claude Code 2.1.220 count_tokens carries exactly model,
// messages and tools, never a system block.
alignCLICountTokensShape := fp.ProfileClaudeCodeCLI
if directAnthropic || alignCLICountTokensShape {
body, _ = sjson.DeleteBytes(body, "metadata")
body, _ = sjson.DeleteBytes(body, "context_management")
body, _ = sjson.DeleteBytes(body, "diagnostics")
}
if alignCLICountTokensShape {
body = util.StripClaudeCodeAttributionSystem(body)
}
// Runs on the finished body: payload rules can rewrite model and messages
// long after translation, so an earlier check would not describe the request
// that is about to be sent.
if errMidSystem := validateClaudeMidSystemMessageModel(body, confirmedClaudeCode, directAnthropic); errMidSystem != nil {
return cliproxyexecutor.Response{}, errMidSystem
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return cliproxyexecutor.Response{}, err
}
if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, false, extraBetas, body, e.cfg, incomingHeaders, confirmedClaudeCode && !cloaked, claudeSessionID); errHeaders != nil {
return cliproxyexecutor.Response{}, errHeaders
}
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
authLabel = auth.Label
authType, authValue = auth.AccountInfo()
}
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
URL: url,
Method: http.MethodPost,
Headers: httpReq.Header.Clone(),
Body: body,
Provider: e.upstreamRequestLogProvider(),
AuthID: authID,
AuthLabel: authLabel,
AuthType: authType,
AuthValue: authValue,
})
httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0)
resp, err := doClaudeUpstreamRequest(httpClient, httpReq)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return cliproxyexecutor.Response{}, err
}
helps.RecordAPIResponseMetadata(ctx, e.cfg, resp.StatusCode, resp.Header.Clone())
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Decompress error responses — pass the Content-Encoding value (may be empty)
// and let decodeResponseBody handle both header-declared and magic-byte-detected
// compression. This keeps error-path behaviour consistent with the success path.
errBody, decErr := decodeResponseBody(resp.Body, claudeResponseContentEncoding(resp.Header))
if decErr != nil {
helps.RecordAPIResponseError(ctx, e.cfg, decErr)
msg := fmt.Sprintf("failed to decode error response body: %v", decErr)
helps.LogWithRequestID(ctx).Warn(msg)
return cliproxyexecutor.Response{}, classifyClaudeUpstreamError(resp.StatusCode, resp.Header, []byte(msg))
}
b, readErr := io.ReadAll(errBody)
if readErr != nil {
helps.RecordAPIResponseError(ctx, e.cfg, readErr)
msg := fmt.Sprintf("failed to read error response body: %v", readErr)
helps.LogWithRequestID(ctx).Warn(msg)
b = []byte(msg)
}
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
if errClose := errBody.Close(); errClose != nil {
log.Errorf("response body close error: %v", errClose)
}
return cliproxyexecutor.Response{}, classifyClaudeUpstreamError(resp.StatusCode, resp.Header, b)
}
decodedBody, err := decodeResponseBody(resp.Body, claudeResponseContentEncoding(resp.Header))
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
if errClose := resp.Body.Close(); errClose != nil {
log.Errorf("response body close error: %v", errClose)
}
return cliproxyexecutor.Response{}, err
}
defer func() {
if errClose := decodedBody.Close(); errClose != nil {
log.Errorf("response body close error: %v", errClose)
}
}()
data, err := io.ReadAll(decodedBody)
if err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, err)
return cliproxyexecutor.Response{}, err
}
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
count := gjson.GetBytes(data, "input_tokens").Int()
out := sdktranslator.TranslateTokenCount(ctx, to, responseFormat, count, data)
return cliproxyexecutor.Response{Payload: out, Headers: resp.Header.Clone()}, nil
}