Files
CLIProxyAPI/internal/runtime/executor/claude_executor_tokens.go
sususu 2228847e63 fix(claude): close the count_tokens cloaking gap
A cloaked direct-Anthropic count_tokens request skipped applyCloaking entirely
while still reporting cloaked=true, so two guarantees that hold on the Messages
path were silently dropped on this endpoint:

- configured sensitive words reached Anthropic verbatim, even though the same
  words are obfuscated on the Messages request
- a third-party caller's system prompt was forwarded in the system slot, which
  measured Claude Code 2.1.220 count_tokens traffic never carries

Skipping the full Messages cloaking is still correct here, because native
count_tokens sends only model, messages and tools and must not gain the Claude
Code system blocks. Apply the two parts that do have to hold instead: relocate
the caller's system prompt into messages with the same positional mapping the
Messages path uses, so its tokens stay counted without leaking it as a system
prompt, and obfuscate sensitive words. Strict mode keeps dropping caller
prompts, matching the Messages path.
2026-08-03 14:47:26 +08:00

299 lines
12 KiB
Go

package executor
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
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"
}
endpoint := fmt.Sprintf("%s/v1/messages/count_tokens?beta=true", baseURL)
// Claude Code uses the native endpoint for OAuth and first-party Anthropic
// credentials. Keep local counting for custom API-key gateways that may not
// implement count_tokens.
if apiKey != "" && claudeCCHSigningEnabled(apiKey, claudeCCHUpstreamAnthropic, endpoint) {
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.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream)
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)
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
}
// countTokensUpstream preserves native token counting for Claude-compatible
// providers that expose their own count_tokens endpoint.
func shouldFinalizeClaudeCountTokensCCH(cchSigning, directAnthropic bool) bool {
return cchSigning && !directAnthropic
}
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)
oauthToken := isClaudeOAuthToken(apiKey)
cchSigning := claudeCCHSigningEnabled(apiKey, claudeCCHUpstreamAnthropic, url)
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 oauthToken {
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.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream)
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)
var cloaked bool
if directAnthropic {
// Claude Code's count_tokens carries only model, messages and tools, so the
// full Messages cloaking must not run here. 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.
policy, settings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode)
cloaked = policy.Cloak
if cloaked {
body = relocateClaudeSystemPromptForCountTokens(body, settings.strictMode)
if len(settings.sensitiveWords) > 0 {
body = helps.ObfuscateSensitiveWords(body, helps.BuildSensitiveWordMatcher(settings.sensitiveWords))
}
}
} else {
var errCloaking error
body, cloaked, errCloaking = applyCloaking(
ctx,
e.cfg,
auth,
body,
apiKey,
confirmedClaudeCode,
cchSigning,
)
if errCloaking != nil {
return cliproxyexecutor.Response{}, errCloaking
}
}
// 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 oauthToken && cloaked {
mcpAliases := resolveClaudeMCPAliasOptions(ctx)
body, _ = prepareClaudeOAuthToolNamesForUpstream(body, mcpAliases)
}
body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel)
// Claude Code never sends metadata on count_tokens, and Anthropic rejects the
// field outright there ("metadata: Extra inputs are not permitted"). The
// Messages path still carries the credential identity; this endpoint must not.
if directAnthropic {
body, _ = sjson.DeleteBytes(body, "metadata")
body, _ = sjson.DeleteBytes(body, "context_management")
body, _ = sjson.DeleteBytes(body, "diagnostics")
}
if shouldFinalizeClaudeCountTokensCCH(cchSigning, directAnthropic) {
fallbackBilling := claudeCCHFallbackBillingHeader(ctx, e.cfg, body, claudeCodeDetection.Entrypoint)
var errCCH error
body, errCCH = finalizeAnthropicMessagesBodyCCH(body, fallbackBilling)
if errCCH != nil {
return cliproxyexecutor.Response{}, fmt.Errorf("finalize Claude CCH: %w", errCCH)
}
}
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{}, statusErr{code: resp.StatusCode, msg: 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{}, statusErr{code: resp.StatusCode, msg: string(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
}