551 lines
16 KiB
Go
551 lines
16 KiB
Go
package executor
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
|
|
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"
|
|
)
|
|
|
|
const (
|
|
claudeCCHSeed uint64 = 0x4D659218E32A3268
|
|
claudeCCHLength = 5
|
|
claudeCCHZero = "00000"
|
|
)
|
|
|
|
type claudeCCHNormalizationEdit struct {
|
|
start int
|
|
end int
|
|
}
|
|
|
|
type claudeCCHJSONMember struct {
|
|
start int
|
|
end int
|
|
commaBefore int
|
|
commaAfter int
|
|
excluded bool
|
|
}
|
|
|
|
type claudeCCHJSONScanner struct {
|
|
body []byte
|
|
pos int
|
|
edits []claudeCCHNormalizationEdit
|
|
}
|
|
|
|
type claudeCCHUpstreamKind uint8
|
|
|
|
const (
|
|
claudeCCHUpstreamOther claudeCCHUpstreamKind = iota
|
|
claudeCCHUpstreamAnthropic
|
|
claudeCCHUpstreamVertex
|
|
)
|
|
|
|
func finalizeAnthropicMessagesBodyCCH(body []byte, fallbackBilling string) ([]byte, error) {
|
|
bodyWithPlaceholder, err := ensureClaudeBillingHeaderCCHPlaceholder(body, fallbackBilling)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return signAnthropicMessagesBody(bodyWithPlaceholder)
|
|
}
|
|
|
|
// claudeBodyNeedsBillingFallback reports whether a confirmed native helper request
|
|
// still needs CPA's billing-header fallback.
|
|
//
|
|
// The measured minimal helper carries no system field at all, which is exactly the
|
|
// native wire shape, so injecting a billing header there would be the deviation.
|
|
// Keying on "system is absent" rather than "no billing header present" means that
|
|
// if anything later in the pipeline (a payload rule, for instance) does attach a
|
|
// system prompt, the fallback comes back and the request cannot go upstream with a
|
|
// system block that native would never send unsigned.
|
|
func claudeBodyNeedsBillingFallback(body []byte) bool {
|
|
return gjson.GetBytes(body, "system").Exists()
|
|
}
|
|
|
|
func ensureClaudeBillingHeaderCCHPlaceholder(body []byte, fallbackBilling string) ([]byte, error) {
|
|
billing := gjson.GetBytes(body, "system.0.text")
|
|
if billing.Type != gjson.String || !strings.HasPrefix(billing.String(), "x-anthropic-billing-header:") {
|
|
if fallbackBilling == "" {
|
|
return body, nil
|
|
}
|
|
var errPrepend error
|
|
body, errPrepend = prependClaudeBillingSystemBlock(body, fallbackBilling)
|
|
if errPrepend != nil {
|
|
return nil, errPrepend
|
|
}
|
|
billing = gjson.GetBytes(body, "system.0.text")
|
|
}
|
|
if _, ok := claudeBillingCCHDigitsOffset(body); ok {
|
|
return body, nil
|
|
}
|
|
|
|
billingText := billing.String()
|
|
entrypoint := strings.Index(billingText, "cc_entrypoint=")
|
|
if entrypoint < 0 {
|
|
return body, nil
|
|
}
|
|
entrypointEnd := strings.IndexByte(billingText[entrypoint:], ';')
|
|
if entrypointEnd < 0 {
|
|
return body, nil
|
|
}
|
|
insertAt := entrypoint + entrypointEnd + 1
|
|
billingText = billingText[:insertAt] + " cch=00000;" + billingText[insertAt:]
|
|
updated, err := sjson.SetBytes(body, "system.0.text", billingText)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert Claude CCH placeholder: %w", err)
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
func prependClaudeBillingSystemBlock(body []byte, billingText string) ([]byte, error) {
|
|
billingBlock := []byte(buildTextBlock(billingText, nil))
|
|
system := gjson.GetBytes(body, "system")
|
|
var systemArray []byte
|
|
switch {
|
|
case system.Type == gjson.String:
|
|
originalBlock := []byte(buildTextBlock(system.String(), nil))
|
|
systemArray = make([]byte, 0, len(billingBlock)+len(originalBlock)+3)
|
|
systemArray = append(systemArray, '[')
|
|
systemArray = append(systemArray, billingBlock...)
|
|
systemArray = append(systemArray, ',')
|
|
systemArray = append(systemArray, originalBlock...)
|
|
systemArray = append(systemArray, ']')
|
|
case system.IsArray():
|
|
rawSystem := bytes.TrimSpace([]byte(system.Raw))
|
|
if bytes.Equal(rawSystem, []byte("[]")) {
|
|
systemArray = make([]byte, 0, len(billingBlock)+2)
|
|
systemArray = append(systemArray, '[')
|
|
systemArray = append(systemArray, billingBlock...)
|
|
systemArray = append(systemArray, ']')
|
|
} else {
|
|
systemArray = make([]byte, 0, len(billingBlock)+len(rawSystem)+1)
|
|
systemArray = append(systemArray, '[')
|
|
systemArray = append(systemArray, billingBlock...)
|
|
systemArray = append(systemArray, ',')
|
|
systemArray = append(systemArray, rawSystem[1:]...)
|
|
}
|
|
default:
|
|
systemArray = make([]byte, 0, len(billingBlock)+2)
|
|
systemArray = append(systemArray, '[')
|
|
systemArray = append(systemArray, billingBlock...)
|
|
systemArray = append(systemArray, ']')
|
|
}
|
|
|
|
updated, err := sjson.SetRawBytes(body, "system", systemArray)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("prepend Claude CCH billing block: %w", err)
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
func isKimiAPIEndpoint(endpoint string) bool {
|
|
parsed, err := url.Parse(strings.TrimSpace(endpoint))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return strings.EqualFold(parsed.Hostname(), "api.kimi.com")
|
|
}
|
|
|
|
func isKimiMessagesUpstream(auth *cliproxyauth.Auth, endpoint string) bool {
|
|
if auth != nil && strings.EqualFold(strings.TrimSpace(auth.Provider), "kimi") {
|
|
return true
|
|
}
|
|
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:
|
|
//
|
|
// s = (provider === "firstParty" && isFirstPartyBaseURL()) || provider === "vertex"
|
|
// ? " cch=00000;" : ""
|
|
//
|
|
// where isFirstPartyBaseURL() is true when ANTHROPIC_BASE_URL is unset or its
|
|
// host is api.anthropic.com. Every other backend (bedrock, foundry, mantle,
|
|
// anthropicAws, anthropicGoogleCloud, gateway, any custom base URL) sends the
|
|
// billing header without cch.
|
|
//
|
|
// CPA maps that onto two authorities:
|
|
//
|
|
// - A real Claude OAuth credential always signs, on every upstream. CPA is the
|
|
// hop that restores the first-party shape: a downstream Claude Code pointed at
|
|
// CPA sees a non-first-party base URL and therefore omits cch itself, so the
|
|
// value has to be regenerated here rather than inherited.
|
|
// - An API key or delegated provider signs only when it explicitly opted into
|
|
// the claude-code-cli profile AND the upstream is one the native gate accepts.
|
|
// On any other gateway the billing header still goes out, but without cch, so
|
|
// a per-request hash cannot bust that gateway's prompt cache.
|
|
//
|
|
// origin is the concrete upstream URL of the request being built. CPA additionally
|
|
// requires https and the default port, which native does not check.
|
|
func claudeCCHSigningEnabled(apiKey string, kind claudeCCHUpstreamKind, cliFingerprint bool, origin string) bool {
|
|
if isClaudeOAuthToken(apiKey) {
|
|
return true
|
|
}
|
|
if kind == claudeCCHUpstreamVertex {
|
|
return true
|
|
}
|
|
if !cliFingerprint {
|
|
return false
|
|
}
|
|
return kind == claudeCCHUpstreamAnthropic && isAnthropicUpstreamBase(origin)
|
|
}
|
|
|
|
// signAnthropicMessagesBody reproduces Claude Code 2.1.220's final-body CCH.
|
|
// It changes only the five CCH digits in the outgoing body.
|
|
func signAnthropicMessagesBody(body []byte) ([]byte, error) {
|
|
cchOffset, ok := claudeBillingCCHDigitsOffset(body)
|
|
if !ok {
|
|
return body, nil
|
|
}
|
|
|
|
unsignedBody := bytes.Clone(body)
|
|
copy(unsignedBody[cchOffset:cchOffset+claudeCCHLength], claudeCCHZero)
|
|
normalizedBody, err := normalizeClaudeCCHInput(unsignedBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("normalize Claude CCH input: %w", err)
|
|
}
|
|
|
|
hasher := xxHash64.New(claudeCCHSeed)
|
|
if _, err = hasher.Write(normalizedBody); err != nil {
|
|
return nil, fmt.Errorf("hash Claude CCH input: %w", err)
|
|
}
|
|
cch := fmt.Sprintf("%05x", hasher.Sum64()&0xFFFFF)
|
|
copy(unsignedBody[cchOffset:cchOffset+claudeCCHLength], cch)
|
|
return unsignedBody, nil
|
|
}
|
|
|
|
func claudeBillingCCHDigitsOffset(body []byte) (int, bool) {
|
|
billing := gjson.GetBytes(body, "system.0.text")
|
|
if billing.Type != gjson.String || !strings.HasPrefix(billing.String(), "x-anthropic-billing-header:") {
|
|
return 0, false
|
|
}
|
|
|
|
raw := []byte(billing.Raw)
|
|
for searchFrom := 0; searchFrom < len(raw); {
|
|
relative := bytes.Index(raw[searchFrom:], []byte("cch="))
|
|
if relative < 0 {
|
|
return 0, false
|
|
}
|
|
prefix := searchFrom + relative
|
|
digits := prefix + len("cch=")
|
|
end := digits + claudeCCHLength
|
|
if end < len(raw) && raw[end] == ';' && isLowerHex(raw[digits:end]) {
|
|
return billing.Index + digits, true
|
|
}
|
|
searchFrom = prefix + len("cch=")
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func isLowerHex(value []byte) bool {
|
|
if len(value) != claudeCCHLength {
|
|
return false
|
|
}
|
|
for _, character := range value {
|
|
if (character < '0' || character > '9') && (character < 'a' || character > 'f') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// normalizeClaudeCCHInput builds the hash view without reserializing JSON.
|
|
// Model string values are emptied, while dispatch-only members are omitted.
|
|
func normalizeClaudeCCHInput(body []byte) ([]byte, error) {
|
|
if !json.Valid(body) {
|
|
return nil, fmt.Errorf("invalid JSON body")
|
|
}
|
|
|
|
scanner := claudeCCHJSONScanner{
|
|
body: body,
|
|
edits: make([]claudeCCHNormalizationEdit, 0),
|
|
}
|
|
if err := scanner.parseValue(true); err != nil {
|
|
return nil, err
|
|
}
|
|
scanner.skipWhitespace()
|
|
if scanner.pos != len(body) {
|
|
return nil, fmt.Errorf("unexpected JSON data at byte %d", scanner.pos)
|
|
}
|
|
|
|
sort.Slice(scanner.edits, func(i, j int) bool {
|
|
return scanner.edits[i].start < scanner.edits[j].start
|
|
})
|
|
normalized := make([]byte, 0, len(body))
|
|
last := 0
|
|
for _, edit := range scanner.edits {
|
|
if edit.start < last || edit.end > len(body) {
|
|
return nil, fmt.Errorf("overlapping CCH normalization edit at byte %d", edit.start)
|
|
}
|
|
normalized = append(normalized, body[last:edit.start]...)
|
|
last = edit.end
|
|
}
|
|
normalized = append(normalized, body[last:]...)
|
|
return normalized, nil
|
|
}
|
|
|
|
func (scanner *claudeCCHJSONScanner) parseValue(collect bool) error {
|
|
scanner.skipWhitespace()
|
|
if scanner.pos >= len(scanner.body) {
|
|
return fmt.Errorf("missing JSON value at byte %d", scanner.pos)
|
|
}
|
|
|
|
switch scanner.body[scanner.pos] {
|
|
case '{':
|
|
return scanner.parseObject(collect)
|
|
case '[':
|
|
return scanner.parseArray(collect)
|
|
case '"':
|
|
_, _, err := scanner.parseString()
|
|
return err
|
|
default:
|
|
start := scanner.pos
|
|
for scanner.pos < len(scanner.body) {
|
|
switch scanner.body[scanner.pos] {
|
|
case ',', '}', ']', ' ', '\t', '\r', '\n':
|
|
if scanner.pos == start {
|
|
return fmt.Errorf("missing JSON value at byte %d", start)
|
|
}
|
|
return nil
|
|
default:
|
|
scanner.pos++
|
|
}
|
|
}
|
|
if scanner.pos == start {
|
|
return fmt.Errorf("missing JSON value at byte %d", start)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (scanner *claudeCCHJSONScanner) parseObject(collect bool) error {
|
|
scanner.pos++
|
|
scanner.skipWhitespace()
|
|
if scanner.consume('}') {
|
|
return nil
|
|
}
|
|
|
|
members := make([]claudeCCHJSONMember, 0)
|
|
commaBefore := -1
|
|
for {
|
|
scanner.skipWhitespace()
|
|
memberStart := scanner.pos
|
|
keyStart, keyEnd, err := scanner.parseString()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scanner.skipWhitespace()
|
|
if !scanner.consume(':') {
|
|
return fmt.Errorf("missing object colon at byte %d", scanner.pos)
|
|
}
|
|
scanner.skipWhitespace()
|
|
|
|
key := scanner.body[keyStart:keyEnd]
|
|
excluded := collect && isClaudeCCHExcludedKey(key)
|
|
if collect && bytes.Equal(key, []byte(`"model"`)) && scanner.pos < len(scanner.body) && scanner.body[scanner.pos] == '"' {
|
|
valueStart, valueEnd, errString := scanner.parseString()
|
|
if errString != nil {
|
|
return errString
|
|
}
|
|
scanner.addEdit(valueStart+1, valueEnd-1)
|
|
} else if err = scanner.parseValue(collect && !excluded); err != nil {
|
|
return err
|
|
}
|
|
memberEnd := scanner.pos
|
|
scanner.skipWhitespace()
|
|
|
|
commaAfter := -1
|
|
if scanner.consume(',') {
|
|
commaAfter = scanner.pos - 1
|
|
}
|
|
members = append(members, claudeCCHJSONMember{
|
|
start: memberStart,
|
|
end: memberEnd,
|
|
commaBefore: commaBefore,
|
|
commaAfter: commaAfter,
|
|
excluded: excluded,
|
|
})
|
|
if commaAfter >= 0 {
|
|
commaBefore = commaAfter
|
|
continue
|
|
}
|
|
if !scanner.consume('}') {
|
|
return fmt.Errorf("missing object end at byte %d", scanner.pos)
|
|
}
|
|
break
|
|
}
|
|
|
|
if collect {
|
|
scanner.addExcludedMemberEdits(members)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (scanner *claudeCCHJSONScanner) parseArray(collect bool) error {
|
|
scanner.pos++
|
|
scanner.skipWhitespace()
|
|
if scanner.consume(']') {
|
|
return nil
|
|
}
|
|
|
|
for {
|
|
if err := scanner.parseValue(collect); err != nil {
|
|
return err
|
|
}
|
|
scanner.skipWhitespace()
|
|
if scanner.consume(',') {
|
|
continue
|
|
}
|
|
if !scanner.consume(']') {
|
|
return fmt.Errorf("missing array end at byte %d", scanner.pos)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (scanner *claudeCCHJSONScanner) parseString() (start, end int, err error) {
|
|
if scanner.pos >= len(scanner.body) || scanner.body[scanner.pos] != '"' {
|
|
return 0, 0, fmt.Errorf("missing JSON string at byte %d", scanner.pos)
|
|
}
|
|
|
|
start = scanner.pos
|
|
scanner.pos++
|
|
for scanner.pos < len(scanner.body) {
|
|
switch scanner.body[scanner.pos] {
|
|
case '\\':
|
|
scanner.pos += 2
|
|
case '"':
|
|
scanner.pos++
|
|
return start, scanner.pos, nil
|
|
default:
|
|
scanner.pos++
|
|
}
|
|
}
|
|
return 0, 0, fmt.Errorf("unterminated JSON string at byte %d", start)
|
|
}
|
|
|
|
func (scanner *claudeCCHJSONScanner) addExcludedMemberEdits(members []claudeCCHJSONMember) {
|
|
for start := 0; start < len(members); {
|
|
if !members[start].excluded {
|
|
start++
|
|
continue
|
|
}
|
|
|
|
end := start
|
|
for end+1 < len(members) && members[end+1].excluded {
|
|
end++
|
|
}
|
|
switch {
|
|
case end+1 < len(members):
|
|
scanner.addEdit(members[start].start, members[end].commaAfter+1)
|
|
case start > 0 && end > start:
|
|
// Claude Code 2.1.220 leaves the preceding comma in its hash view
|
|
// when an object ends with multiple consecutive dispatch members.
|
|
scanner.addEdit(members[start].start, members[end].end)
|
|
case start > 0:
|
|
scanner.addEdit(members[start].commaBefore, members[end].end)
|
|
default:
|
|
scanner.addEdit(members[start].start, members[end].end)
|
|
}
|
|
start = end + 1
|
|
}
|
|
}
|
|
|
|
func (scanner *claudeCCHJSONScanner) addEdit(start, end int) {
|
|
if start >= end {
|
|
return
|
|
}
|
|
scanner.edits = append(scanner.edits, claudeCCHNormalizationEdit{start: start, end: end})
|
|
}
|
|
|
|
func (scanner *claudeCCHJSONScanner) skipWhitespace() {
|
|
for scanner.pos < len(scanner.body) {
|
|
switch scanner.body[scanner.pos] {
|
|
case ' ', '\t', '\r', '\n':
|
|
scanner.pos++
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (scanner *claudeCCHJSONScanner) consume(character byte) bool {
|
|
if scanner.pos >= len(scanner.body) || scanner.body[scanner.pos] != character {
|
|
return false
|
|
}
|
|
scanner.pos++
|
|
return true
|
|
}
|
|
|
|
func isClaudeCCHExcludedKey(key []byte) bool {
|
|
switch string(key) {
|
|
case `"max_tokens"`, `"fallbacks"`, `"fallback_credit_token"`:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func resolveClaudeKeyConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config.ClaudeKey {
|
|
if cfg == nil || auth == nil {
|
|
return nil
|
|
}
|
|
|
|
apiKey, baseURL := claudeCreds(auth)
|
|
if apiKey == "" {
|
|
return nil
|
|
}
|
|
|
|
for i := range cfg.ClaudeKey {
|
|
entry := &cfg.ClaudeKey[i]
|
|
cfgKey := strings.TrimSpace(entry.APIKey)
|
|
cfgBase := strings.TrimSpace(entry.BaseURL)
|
|
if !strings.EqualFold(cfgKey, apiKey) {
|
|
continue
|
|
}
|
|
if baseURL != "" && cfgBase != "" && !strings.EqualFold(cfgBase, baseURL) {
|
|
continue
|
|
}
|
|
return entry
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// resolveClaudeKeyCloakConfig finds the matching ClaudeKey config and returns its CloakConfig.
|
|
func resolveClaudeKeyCloakConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config.CloakConfig {
|
|
entry := resolveClaudeKeyConfig(cfg, auth)
|
|
if entry == nil {
|
|
return nil
|
|
}
|
|
return entry.Cloak
|
|
}
|
|
|
|
func rebuildMidSystemMessageEnabled(cfg *config.Config, auth *cliproxyauth.Auth) bool {
|
|
if auth != nil && auth.Attributes != nil && strings.EqualFold(strings.TrimSpace(auth.Attributes["rebuild_mid_system_message"]), "true") {
|
|
return true
|
|
}
|
|
entry := resolveClaudeKeyConfig(cfg, auth)
|
|
return entry != nil && entry.RebuildMidSystemMessage
|
|
}
|