diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index 4414c5d8c..372b432db 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -171,22 +171,9 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel, helps.APIKeyModelIsCompat(req)) if fp.ApplyCLIIdentity { - // ApplyCLIIdentity and ProfileClaudeCodeCLI are the same predicate, so - // claudeSessionID was already resolved above by ClaudeAgentSessionUUIDForRequest, - // which always returns a UUID. Do not add a second session source here: a - // per-apiKey cached ID would silently break agent-conversation continuity. - identitySeed := apiKey - if isKimiMessagesUpstream(auth, url) { - identitySeed = helps.ClaudeCLIAuthIdentitySeed(auth) - } - var identityAuth *cliproxyauth.Auth - identityAuth, err = helps.PrepareClaudeCLIFingerprintAuth(auth, identitySeed, fp.SynthesizeIdentity) + bodyForUpstream, err = applyClaudeCLIIdentity(bodyForUpstream, auth, apiKey, url, claudeSessionID, fp.SynthesizeIdentity) if err != nil { - return resp, fmt.Errorf("ensure Claude CLI fingerprint identity: %w", err) - } - bodyForUpstream, _, err = helps.ApplyClaudeCredentialMetadata(bodyForUpstream, identityAuth, claudeSessionID) - if err != nil { - return resp, fmt.Errorf("apply Claude credential metadata: %w", err) + return resp, err } } cchBilling := "" diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index dc9243116..865b3bdad 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -163,22 +163,9 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel, helps.APIKeyModelIsCompat(req)) if fp.ApplyCLIIdentity { - // ApplyCLIIdentity and ProfileClaudeCodeCLI are the same predicate, so - // claudeSessionID was already resolved above by ClaudeAgentSessionUUIDForRequest, - // which always returns a UUID. Do not add a second session source here: a - // per-apiKey cached ID would silently break agent-conversation continuity. - identitySeed := apiKey - if isKimiMessagesUpstream(auth, url) { - identitySeed = helps.ClaudeCLIAuthIdentitySeed(auth) - } - var identityAuth *cliproxyauth.Auth - identityAuth, err = helps.PrepareClaudeCLIFingerprintAuth(auth, identitySeed, fp.SynthesizeIdentity) + bodyForUpstream, err = applyClaudeCLIIdentity(bodyForUpstream, auth, apiKey, url, claudeSessionID, fp.SynthesizeIdentity) if err != nil { - return nil, fmt.Errorf("ensure Claude CLI fingerprint identity: %w", err) - } - bodyForUpstream, _, err = helps.ApplyClaudeCredentialMetadata(bodyForUpstream, identityAuth, claudeSessionID) - if err != nil { - return nil, fmt.Errorf("apply Claude credential metadata: %w", err) + return nil, err } } cchBilling := "" diff --git a/internal/runtime/executor/claude_fingerprint_policy.go b/internal/runtime/executor/claude_fingerprint_policy.go index 53c1bb953..73ed9860e 100644 --- a/internal/runtime/executor/claude_fingerprint_policy.go +++ b/internal/runtime/executor/claude_fingerprint_policy.go @@ -1,11 +1,13 @@ package executor import ( + "fmt" "strings" "sync" 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/runtime/executor/helps" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" ) @@ -109,3 +111,31 @@ func resolveClaudeFingerprintPolicy(cfg *config.Config, auth *cliproxyauth.Auth, OAuthCancellation: authIsOAuth, } } + +// applyClaudeCLIIdentity applies the Claude Code CLI credential identity to the +// upstream Messages body. It is the single implementation behind both the +// streaming and the non-streaming request paths; keep it that way. +// +// ApplyCLIIdentity and ProfileClaudeCodeCLI are the same predicate, so +// sessionID has already been resolved by ClaudeAgentSessionUUIDForRequest, +// which always returns a UUID. Do not add a second session source here: a +// per-apiKey cached ID would silently break agent-conversation continuity. +// +// API keys seed the synthesized identity from the key itself; delegated +// providers such as Kimi seed from the stable auth identity, so an access-token +// rotation does not rotate the device fingerprint. +func applyClaudeCLIIdentity(body []byte, auth *cliproxyauth.Auth, apiKey, upstreamURL, sessionID string, synthesize bool) ([]byte, error) { + identitySeed := apiKey + if isKimiMessagesUpstream(auth, upstreamURL) { + identitySeed = helps.ClaudeCLIAuthIdentitySeed(auth) + } + identityAuth, errIdentity := helps.PrepareClaudeCLIFingerprintAuth(auth, identitySeed, synthesize) + if errIdentity != nil { + return nil, fmt.Errorf("ensure Claude CLI fingerprint identity: %w", errIdentity) + } + updated, _, errApply := helps.ApplyClaudeCredentialMetadata(body, identityAuth, sessionID) + if errApply != nil { + return nil, fmt.Errorf("apply Claude credential metadata: %w", errApply) + } + return updated, nil +} diff --git a/internal/runtime/executor/claude_signing.go b/internal/runtime/executor/claude_signing.go index 6540eb27d..5642bcbe4 100644 --- a/internal/runtime/executor/claude_signing.go +++ b/internal/runtime/executor/claude_signing.go @@ -10,6 +10,7 @@ import ( xxHash64 "github.com/pierrec/xxHash/xxHash64" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "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" @@ -160,6 +161,18 @@ func isKimiMessagesUpstream(auth *cliproxyauth.Auth, endpoint string) bool { return isKimiAPIEndpoint(endpoint) } +// stripDefaultKimiClaudeCodeAttribution removes the Claude Code billing/CCH +// attribution block from a Kimi Messages body when the caller did not opt into +// the full CLI profile. Kimi treats the block as prompt text, so forwarding it +// unchanged would leak CPA's attribution into the model's context. Other system +// content is preserved. +func stripDefaultKimiClaudeCodeAttribution(auth *cliproxyauth.Auth, endpoint string, cliFingerprint bool, body []byte) []byte { + if cliFingerprint || !isKimiMessagesUpstream(auth, endpoint) { + return body + } + return util.StripClaudeCodeAttributionSystem(body) +} + // claudeCCHSigningEnabled applies CPA's CCH policy. // // Native gate, identical in Claude Code 2.1.220 through 2.1.234: diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index a9e96ddc9..e4424a702 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -50,13 +50,6 @@ func NewKimiExecutor(cfg *config.Config) *KimiExecutor { // Identifier returns the executor identifier. func (e *KimiExecutor) Identifier() string { return "kimi" } -func stripDefaultKimiClaudeCodeAttribution(auth *cliproxyauth.Auth, endpoint string, cliFingerprint bool, body []byte) []byte { - if cliFingerprint || !isKimiMessagesUpstream(auth, endpoint) { - return body - } - return util.StripClaudeCodeAttributionSystem(body) -} - // RequestToFormat reports the upstream request format used after auth selection. func (e *KimiExecutor) RequestToFormat(_ cliproxyexecutor.Request, opts cliproxyexecutor.Options) sdktranslator.Format { if opts.SourceFormat == sdktranslator.FormatClaude { diff --git a/internal/watcher/synthesizer/helpers.go b/internal/watcher/synthesizer/helpers.go index 3e6929c1c..98202e83f 100644 --- a/internal/watcher/synthesizer/helpers.go +++ b/internal/watcher/synthesizer/helpers.go @@ -120,8 +120,6 @@ func addRequestScopedErrorsToMetadata(rules []config.RequestScopedErrorRule, met metadata["request_scoped_errors"] = rules } -// addConfigHeadersToAttrs adds header configuration to auth attributes. -// Headers are prefixed with "header:" in the attributes map. func fingerprintProfileFromMetadata(metadata map[string]any) string { if metadata == nil { return "" @@ -152,6 +150,8 @@ func applyFingerprintProfileAttribute(auth *coreauth.Auth, metadata map[string]a auth.Attributes["fingerprint_profile"] = profile } +// addConfigHeadersToAttrs adds header configuration to auth attributes. +// Headers are prefixed with "header:" in the attributes map. func addConfigHeadersToAttrs(headers map[string]string, attrs map[string]string) { if len(headers) == 0 || attrs == nil { return