Merge commit '15c2f274ea690c9a7c9db22f9f454af869db5375' into dev
This commit is contained in:
@@ -173,6 +173,8 @@ nonstream-keepalive-interval: 0
|
|||||||
# - "API"
|
# - "API"
|
||||||
# - "proxy"
|
# - "proxy"
|
||||||
# cache-user-id: true # optional: default is false; set true to reuse cached user_id per API key instead of generating a random one each request
|
# cache-user-id: true # optional: default is false; set true to reuse cached user_id per API key instead of generating a random one each request
|
||||||
|
# experimental-cch-signing: false # optional: default is false; when true, sign the final /v1/messages body using the current Claude Code cch algorithm
|
||||||
|
# # keep this disabled unless you explicitly need the behavior, so upstream seed changes fall back to legacy proxy behavior
|
||||||
|
|
||||||
# Default headers for Claude API requests. Update when Claude Code releases new versions.
|
# Default headers for Claude API requests. Update when Claude Code releases new versions.
|
||||||
# In legacy mode, user-agent/package-version/runtime-version/timeout are used as fallbacks
|
# In legacy mode, user-agent/package-version/runtime-version/timeout are used as fallbacks
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ require (
|
|||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
|
github.com/pierrec/xxHash v0.1.5 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/rs/xid v1.5.0 // indirect
|
github.com/rs/xid v1.5.0 // indirect
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
|||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
|
github.com/pierrec/xxHash v0.1.5 h1:n/jBpwTHiER4xYvK3/CdPVnLDPchj8eTJFFLUb4QHBo=
|
||||||
|
github.com/pierrec/xxHash v0.1.5/go.mod h1:w2waW5Zoa/Wc4Yqe0wgrIYAGKqRMf7czn2HNKXmuL+I=
|
||||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
|||||||
@@ -244,8 +244,8 @@ type AmpCode struct {
|
|||||||
UpstreamAPIKey string `yaml:"upstream-api-key" json:"upstream-api-key"`
|
UpstreamAPIKey string `yaml:"upstream-api-key" json:"upstream-api-key"`
|
||||||
|
|
||||||
// UpstreamAPIKeys maps client API keys (from top-level api-keys) to upstream API keys.
|
// UpstreamAPIKeys maps client API keys (from top-level api-keys) to upstream API keys.
|
||||||
// When a client authenticates with a key that matches an entry, that upstream key is used.
|
// When a request is authenticated with one of the APIKeys, the corresponding UpstreamAPIKey
|
||||||
// If no match is found, falls back to UpstreamAPIKey (default behavior).
|
// is used for the upstream Amp request.
|
||||||
UpstreamAPIKeys []AmpUpstreamAPIKeyEntry `yaml:"upstream-api-keys,omitempty" json:"upstream-api-keys,omitempty"`
|
UpstreamAPIKeys []AmpUpstreamAPIKeyEntry `yaml:"upstream-api-keys,omitempty" json:"upstream-api-keys,omitempty"`
|
||||||
|
|
||||||
// RestrictManagementToLocalhost restricts Amp management routes (/api/user, /api/threads, etc.)
|
// RestrictManagementToLocalhost restricts Amp management routes (/api/user, /api/threads, etc.)
|
||||||
@@ -367,6 +367,11 @@ type ClaudeKey struct {
|
|||||||
|
|
||||||
// Cloak configures request cloaking for non-Claude-Code clients.
|
// Cloak configures request cloaking for non-Claude-Code clients.
|
||||||
Cloak *CloakConfig `yaml:"cloak,omitempty" json:"cloak,omitempty"`
|
Cloak *CloakConfig `yaml:"cloak,omitempty" json:"cloak,omitempty"`
|
||||||
|
|
||||||
|
// ExperimentalCCHSigning enables opt-in final-body cch signing for cloaked
|
||||||
|
// Claude /v1/messages requests. It is disabled by default so upstream seed
|
||||||
|
// changes do not alter the proxy's legacy behavior.
|
||||||
|
ExperimentalCCHSigning bool `yaml:"experimental-cch-signing,omitempty" json:"experimental-cch-signing,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k ClaudeKey) GetAPIKey() string { return k.APIKey }
|
func (k ClaudeKey) GetAPIKey() string { return k.APIKey }
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
|
if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
|
||||||
bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
|
bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
|
||||||
}
|
}
|
||||||
|
if experimentalCCHSigningEnabled(e.cfg, auth) {
|
||||||
|
bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream)
|
||||||
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)
|
url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream))
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream))
|
||||||
@@ -324,6 +327,9 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
|
if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
|
||||||
bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
|
bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
|
||||||
}
|
}
|
||||||
|
if experimentalCCHSigningEnabled(e.cfg, auth) {
|
||||||
|
bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream)
|
||||||
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)
|
url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL)
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream))
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream))
|
||||||
@@ -901,7 +907,7 @@ func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkSystemInstructions(payload []byte) []byte {
|
func checkSystemInstructions(payload []byte) []byte {
|
||||||
return checkSystemInstructionsWithMode(payload, false)
|
return checkSystemInstructionsWithSigningMode(payload, false, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isClaudeOAuthToken(apiKey string) bool {
|
func isClaudeOAuthToken(apiKey string) bool {
|
||||||
@@ -1123,35 +1129,6 @@ func getCloakConfigFromAuth(auth *cliproxyauth.Auth) (string, bool, []string, bo
|
|||||||
return cloakMode, strictMode, sensitiveWords, cacheUserID
|
return cloakMode, strictMode, sensitiveWords, cacheUserID
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveClaudeKeyCloakConfig finds the matching ClaudeKey config and returns its CloakConfig.
|
|
||||||
func resolveClaudeKeyCloakConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config.CloakConfig {
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Match by API key
|
|
||||||
if strings.EqualFold(cfgKey, apiKey) {
|
|
||||||
// If baseURL is specified, also check it
|
|
||||||
if baseURL != "" && cfgBase != "" && !strings.EqualFold(cfgBase, baseURL) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return entry.Cloak
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// injectFakeUserID generates and injects a fake user ID into the request metadata.
|
// injectFakeUserID generates and injects a fake user ID into the request metadata.
|
||||||
// When useCache is false, a new user ID is generated for every call.
|
// When useCache is false, a new user ID is generated for every call.
|
||||||
func injectFakeUserID(payload []byte, apiKey string, useCache bool) []byte {
|
func injectFakeUserID(payload []byte, apiKey string, useCache bool) []byte {
|
||||||
@@ -1178,29 +1155,36 @@ func injectFakeUserID(payload []byte, apiKey string, useCache bool) []byte {
|
|||||||
// generateBillingHeader creates the x-anthropic-billing-header text block that
|
// generateBillingHeader creates the x-anthropic-billing-header text block that
|
||||||
// real Claude Code prepends to every system prompt array.
|
// real Claude Code prepends to every system prompt array.
|
||||||
// Format: x-anthropic-billing-header: cc_version=<ver>.<build>; cc_entrypoint=cli; cch=<hash>;
|
// Format: x-anthropic-billing-header: cc_version=<ver>.<build>; cc_entrypoint=cli; cch=<hash>;
|
||||||
func generateBillingHeader(payload []byte) string {
|
func generateBillingHeader(payload []byte, experimentalCCHSigning bool) string {
|
||||||
// Generate a deterministic cch hash from the payload content (system + messages + tools).
|
|
||||||
// Real Claude Code uses a 5-char hex hash that varies per request.
|
|
||||||
h := sha256.Sum256(payload)
|
|
||||||
cch := hex.EncodeToString(h[:])[:5]
|
|
||||||
|
|
||||||
// Build hash: 3-char hex, matches the pattern seen in real requests (e.g. "a43")
|
// Build hash: 3-char hex, matches the pattern seen in real requests (e.g. "a43")
|
||||||
buildBytes := make([]byte, 2)
|
buildBytes := make([]byte, 2)
|
||||||
_, _ = rand.Read(buildBytes)
|
_, _ = rand.Read(buildBytes)
|
||||||
buildHash := hex.EncodeToString(buildBytes)[:3]
|
buildHash := hex.EncodeToString(buildBytes)[:3]
|
||||||
|
|
||||||
|
if experimentalCCHSigning {
|
||||||
|
return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=00000;", buildHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a deterministic cch hash from the payload content (system + messages + tools).
|
||||||
|
// Real Claude Code uses a 5-char hex hash that varies per request.
|
||||||
|
h := sha256.Sum256(payload)
|
||||||
|
cch := hex.EncodeToString(h[:])[:5]
|
||||||
return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=%s;", buildHash, cch)
|
return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=%s;", buildHash, cch)
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkSystemInstructionsWithMode injects Claude Code-style system blocks:
|
func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte {
|
||||||
|
return checkSystemInstructionsWithSigningMode(payload, strictMode, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSystemInstructionsWithSigningMode injects Claude Code-style system blocks:
|
||||||
//
|
//
|
||||||
// system[0]: billing header (no cache_control)
|
// system[0]: billing header (no cache_control)
|
||||||
// system[1]: agent identifier (no cache_control)
|
// system[1]: agent identifier (no cache_control)
|
||||||
// system[2..]: user system messages (cache_control added when missing)
|
// system[2..]: user system messages (cache_control added when missing)
|
||||||
func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte {
|
func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, experimentalCCHSigning bool) []byte {
|
||||||
system := gjson.GetBytes(payload, "system")
|
system := gjson.GetBytes(payload, "system")
|
||||||
|
|
||||||
billingText := generateBillingHeader(payload)
|
billingText := generateBillingHeader(payload, experimentalCCHSigning)
|
||||||
billingBlock := fmt.Sprintf(`{"type":"text","text":"%s"}`, billingText)
|
billingBlock := fmt.Sprintf(`{"type":"text","text":"%s"}`, billingText)
|
||||||
// No cache_control on the agent block. It is a cloaking artifact with zero cache
|
// No cache_control on the agent block. It is a cloaking artifact with zero cache
|
||||||
// value (the last system block is what actually triggers caching of all system content).
|
// value (the last system block is what actually triggers caching of all system content).
|
||||||
@@ -1255,43 +1239,33 @@ func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte {
|
|||||||
// Cloaking includes: system prompt injection, fake user ID, and sensitive word obfuscation.
|
// Cloaking includes: system prompt injection, fake user ID, and sensitive word obfuscation.
|
||||||
func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, payload []byte, model string, apiKey string) []byte {
|
func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, payload []byte, model string, apiKey string) []byte {
|
||||||
clientUserAgent := getClientUserAgent(ctx)
|
clientUserAgent := getClientUserAgent(ctx)
|
||||||
|
useExperimentalCCHSigning := experimentalCCHSigningEnabled(cfg, auth)
|
||||||
|
|
||||||
// Get cloak config from ClaudeKey configuration
|
// Get cloak config from ClaudeKey configuration
|
||||||
cloakCfg := resolveClaudeKeyCloakConfig(cfg, auth)
|
cloakCfg := resolveClaudeKeyCloakConfig(cfg, auth)
|
||||||
|
attrMode, attrStrict, attrWords, attrCache := getCloakConfigFromAuth(auth)
|
||||||
|
|
||||||
// Determine cloak settings
|
// Determine cloak settings
|
||||||
var cloakMode string
|
cloakMode := attrMode
|
||||||
var strictMode bool
|
strictMode := attrStrict
|
||||||
var sensitiveWords []string
|
sensitiveWords := attrWords
|
||||||
var cacheUserID bool
|
cacheUserID := attrCache
|
||||||
|
|
||||||
if cloakCfg != nil {
|
if cloakCfg != nil {
|
||||||
cloakMode = cloakCfg.Mode
|
if mode := strings.TrimSpace(cloakCfg.Mode); mode != "" {
|
||||||
strictMode = cloakCfg.StrictMode
|
cloakMode = mode
|
||||||
sensitiveWords = cloakCfg.SensitiveWords
|
}
|
||||||
|
if cloakCfg.StrictMode {
|
||||||
|
strictMode = true
|
||||||
|
}
|
||||||
|
if len(cloakCfg.SensitiveWords) > 0 {
|
||||||
|
sensitiveWords = cloakCfg.SensitiveWords
|
||||||
|
}
|
||||||
if cloakCfg.CacheUserID != nil {
|
if cloakCfg.CacheUserID != nil {
|
||||||
cacheUserID = *cloakCfg.CacheUserID
|
cacheUserID = *cloakCfg.CacheUserID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to auth attributes if no config found
|
|
||||||
if cloakMode == "" {
|
|
||||||
attrMode, attrStrict, attrWords, attrCache := getCloakConfigFromAuth(auth)
|
|
||||||
cloakMode = attrMode
|
|
||||||
if !strictMode {
|
|
||||||
strictMode = attrStrict
|
|
||||||
}
|
|
||||||
if len(sensitiveWords) == 0 {
|
|
||||||
sensitiveWords = attrWords
|
|
||||||
}
|
|
||||||
if cloakCfg == nil || cloakCfg.CacheUserID == nil {
|
|
||||||
cacheUserID = attrCache
|
|
||||||
}
|
|
||||||
} else if cloakCfg == nil || cloakCfg.CacheUserID == nil {
|
|
||||||
_, _, _, attrCache := getCloakConfigFromAuth(auth)
|
|
||||||
cacheUserID = attrCache
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if cloaking should be applied
|
// Determine if cloaking should be applied
|
||||||
if !helps.ShouldCloak(cloakMode, clientUserAgent) {
|
if !helps.ShouldCloak(cloakMode, clientUserAgent) {
|
||||||
return payload
|
return payload
|
||||||
@@ -1299,7 +1273,7 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A
|
|||||||
|
|
||||||
// Skip system instructions for claude-3-5-haiku models
|
// Skip system instructions for claude-3-5-haiku models
|
||||||
if !strings.HasPrefix(model, "claude-3-5-haiku") {
|
if !strings.HasPrefix(model, "claude-3-5-haiku") {
|
||||||
payload = checkSystemInstructionsWithMode(payload, strictMode)
|
payload = checkSystemInstructionsWithSigningMode(payload, strictMode, useExperimentalCCHSigning)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject fake user ID
|
// Inject fake user ID
|
||||||
@@ -1318,7 +1292,7 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A
|
|||||||
// According to Anthropic's documentation, cache prefixes are created in order: tools -> system -> messages.
|
// According to Anthropic's documentation, cache prefixes are created in order: tools -> system -> messages.
|
||||||
// This function adds cache_control to:
|
// This function adds cache_control to:
|
||||||
// 1. The LAST tool in the tools array (caches all tool definitions)
|
// 1. The LAST tool in the tools array (caches all tool definitions)
|
||||||
// 2. The LAST element in the system array (caches system prompt)
|
// 2. The LAST system prompt element
|
||||||
// 3. The SECOND-TO-LAST user turn (caches conversation history for multi-turn)
|
// 3. The SECOND-TO-LAST user turn (caches conversation history for multi-turn)
|
||||||
//
|
//
|
||||||
// Up to 4 cache breakpoints are allowed per request. Tools, System, and Messages are INDEPENDENT breakpoints.
|
// Up to 4 cache breakpoints are allowed per request. Tools, System, and Messages are INDEPENDENT breakpoints.
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -14,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/klauspost/compress/zstd"
|
"github.com/klauspost/compress/zstd"
|
||||||
|
xxHash64 "github.com/pierrec/xxHash/xxHash64"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
|
||||||
@@ -1417,6 +1420,35 @@ func TestDecodeResponseBody_MagicByteGzipNoHeader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDecodeResponseBody_MagicByteZstdNoHeader verifies that decodeResponseBody
|
||||||
|
// detects zstd-compressed content via magic bytes even when Content-Encoding is absent.
|
||||||
|
func TestDecodeResponseBody_MagicByteZstdNoHeader(t *testing.T) {
|
||||||
|
const plaintext = "data: {\"type\":\"message_stop\"}\n"
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
enc, err := zstd.NewWriter(&buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zstd.NewWriter: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = enc.Write([]byte(plaintext))
|
||||||
|
_ = enc.Close()
|
||||||
|
|
||||||
|
rc := io.NopCloser(&buf)
|
||||||
|
decoded, err := decodeResponseBody(rc, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decodeResponseBody error: %v", err)
|
||||||
|
}
|
||||||
|
defer decoded.Close()
|
||||||
|
|
||||||
|
got, err := io.ReadAll(decoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll error: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != plaintext {
|
||||||
|
t.Errorf("decoded = %q, want %q", got, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestDecodeResponseBody_PlainTextNoHeader verifies that decodeResponseBody returns
|
// TestDecodeResponseBody_PlainTextNoHeader verifies that decodeResponseBody returns
|
||||||
// plain text untouched when Content-Encoding is absent and no magic bytes match.
|
// plain text untouched when Content-Encoding is absent and no magic bytes match.
|
||||||
func TestDecodeResponseBody_PlainTextNoHeader(t *testing.T) {
|
func TestDecodeResponseBody_PlainTextNoHeader(t *testing.T) {
|
||||||
@@ -1488,77 +1520,6 @@ func TestClaudeExecutor_ExecuteStream_GzipNoContentEncodingHeader(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity verifies
|
|
||||||
// that injecting Accept-Encoding via auth.Attributes cannot override the stream
|
|
||||||
// path's enforced identity encoding.
|
|
||||||
func TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity(t *testing.T) {
|
|
||||||
var gotEncoding string
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
gotEncoding = r.Header.Get("Accept-Encoding")
|
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
_, _ = w.Write([]byte("data: {\"type\":\"message_stop\"}\n\n"))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
executor := NewClaudeExecutor(&config.Config{})
|
|
||||||
// Inject Accept-Encoding via the custom header attribute mechanism.
|
|
||||||
auth := &cliproxyauth.Auth{Attributes: map[string]string{
|
|
||||||
"api_key": "key-123",
|
|
||||||
"base_url": server.URL,
|
|
||||||
"header:Accept-Encoding": "gzip, deflate, br, zstd",
|
|
||||||
}}
|
|
||||||
payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
|
||||||
|
|
||||||
result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
|
|
||||||
Model: "claude-3-5-sonnet-20241022",
|
|
||||||
Payload: payload,
|
|
||||||
}, cliproxyexecutor.Options{
|
|
||||||
SourceFormat: sdktranslator.FromString("claude"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ExecuteStream error: %v", err)
|
|
||||||
}
|
|
||||||
for chunk := range result.Chunks {
|
|
||||||
if chunk.Err != nil {
|
|
||||||
t.Fatalf("unexpected chunk error: %v", chunk.Err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if gotEncoding != "identity" {
|
|
||||||
t.Errorf("Accept-Encoding = %q; stream path must enforce identity regardless of auth.Attributes override", gotEncoding)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDecodeResponseBody_MagicByteZstdNoHeader verifies that decodeResponseBody
|
|
||||||
// detects zstd-compressed content via magic bytes (28 b5 2f fd) even when
|
|
||||||
// Content-Encoding is absent.
|
|
||||||
func TestDecodeResponseBody_MagicByteZstdNoHeader(t *testing.T) {
|
|
||||||
const plaintext = "data: {\"type\":\"message_stop\"}\n"
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
enc, err := zstd.NewWriter(&buf)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("zstd.NewWriter: %v", err)
|
|
||||||
}
|
|
||||||
_, _ = enc.Write([]byte(plaintext))
|
|
||||||
_ = enc.Close()
|
|
||||||
|
|
||||||
rc := io.NopCloser(&buf)
|
|
||||||
decoded, err := decodeResponseBody(rc, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("decodeResponseBody error: %v", err)
|
|
||||||
}
|
|
||||||
defer decoded.Close()
|
|
||||||
|
|
||||||
got, err := io.ReadAll(decoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadAll error: %v", err)
|
|
||||||
}
|
|
||||||
if string(got) != plaintext {
|
|
||||||
t.Errorf("decoded = %q, want %q", got, plaintext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestClaudeExecutor_Execute_GzipErrorBodyNoContentEncodingHeader verifies that the
|
// TestClaudeExecutor_Execute_GzipErrorBodyNoContentEncodingHeader verifies that the
|
||||||
// error path (4xx) correctly decompresses a gzip body even when the upstream omits
|
// error path (4xx) correctly decompresses a gzip body even when the upstream omits
|
||||||
// the Content-Encoding header. This closes the gap left by PR #1771, which only
|
// the Content-Encoding header. This closes the gap left by PR #1771, which only
|
||||||
@@ -1642,6 +1603,45 @@ func TestClaudeExecutor_ExecuteStream_GzipErrorBodyNoContentEncodingHeader(t *te
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity verifies that the
|
||||||
|
// streaming executor enforces Accept-Encoding: identity regardless of auth.Attributes override.
|
||||||
|
func TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity(t *testing.T) {
|
||||||
|
var gotEncoding string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotEncoding = r.Header.Get("Accept-Encoding")
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
_, _ = w.Write([]byte("data: {\"type\":\"message_stop\"}\n\n"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
executor := NewClaudeExecutor(&config.Config{})
|
||||||
|
auth := &cliproxyauth.Auth{Attributes: map[string]string{
|
||||||
|
"api_key": "key-123",
|
||||||
|
"base_url": server.URL,
|
||||||
|
"header:Accept-Encoding": "gzip, deflate, br, zstd",
|
||||||
|
}}
|
||||||
|
payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
||||||
|
|
||||||
|
result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
|
||||||
|
Model: "claude-3-5-sonnet-20241022",
|
||||||
|
Payload: payload,
|
||||||
|
}, cliproxyexecutor.Options{
|
||||||
|
SourceFormat: sdktranslator.FromString("claude"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExecuteStream error: %v", err)
|
||||||
|
}
|
||||||
|
for chunk := range result.Chunks {
|
||||||
|
if chunk.Err != nil {
|
||||||
|
t.Fatalf("unexpected chunk error: %v", chunk.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotEncoding != "identity" {
|
||||||
|
t.Errorf("Accept-Encoding = %q; stream path must enforce identity regardless of auth.Attributes override", gotEncoding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Test case 1: String system prompt is preserved and converted to a content block
|
// Test case 1: String system prompt is preserved and converted to a content block
|
||||||
func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) {
|
func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) {
|
||||||
payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`)
|
payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`)
|
||||||
@@ -1725,3 +1725,115 @@ func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) {
|
|||||||
t.Fatalf("blocks[2] text mangled, got %q", blocks[2].Get("text").String())
|
t.Fatalf("blocks[2] text mangled, got %q", blocks[2].Get("text").String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClaudeExecutor_ExperimentalCCHSigningDisabledByDefaultKeepsLegacyHeader(t *testing.T) {
|
||||||
|
var seenBody []byte
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
seenBody = bytes.Clone(body)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-3-5-sonnet","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
executor := NewClaudeExecutor(&config.Config{})
|
||||||
|
auth := &cliproxyauth.Auth{Attributes: map[string]string{
|
||||||
|
"api_key": "key-123",
|
||||||
|
"base_url": server.URL,
|
||||||
|
}}
|
||||||
|
payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
||||||
|
|
||||||
|
_, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
||||||
|
Model: "claude-3-5-sonnet-20241022",
|
||||||
|
Payload: payload,
|
||||||
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(seenBody) == 0 {
|
||||||
|
t.Fatal("expected request body to be captured")
|
||||||
|
}
|
||||||
|
|
||||||
|
billingHeader := gjson.GetBytes(seenBody, "system.0.text").String()
|
||||||
|
if !strings.HasPrefix(billingHeader, "x-anthropic-billing-header:") {
|
||||||
|
t.Fatalf("system.0.text = %q, want billing header", billingHeader)
|
||||||
|
}
|
||||||
|
if strings.Contains(billingHeader, "cch=00000;") {
|
||||||
|
t.Fatalf("legacy mode should not forward cch placeholder, got %q", billingHeader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeExecutor_ExperimentalCCHSigningOptInSignsFinalBody(t *testing.T) {
|
||||||
|
var seenBody []byte
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
seenBody = bytes.Clone(body)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-3-5-sonnet","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
executor := NewClaudeExecutor(&config.Config{
|
||||||
|
ClaudeKey: []config.ClaudeKey{{
|
||||||
|
APIKey: "key-123",
|
||||||
|
BaseURL: server.URL,
|
||||||
|
ExperimentalCCHSigning: true,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
auth := &cliproxyauth.Auth{Attributes: map[string]string{
|
||||||
|
"api_key": "key-123",
|
||||||
|
"base_url": server.URL,
|
||||||
|
}}
|
||||||
|
const messageText = "please keep literal cch=00000 in this message"
|
||||||
|
payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"please keep literal cch=00000 in this message"}]}]}`)
|
||||||
|
|
||||||
|
_, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
|
||||||
|
Model: "claude-3-5-sonnet-20241022",
|
||||||
|
Payload: payload,
|
||||||
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(seenBody) == 0 {
|
||||||
|
t.Fatal("expected request body to be captured")
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(seenBody, "messages.0.content.0.text").String(); got != messageText {
|
||||||
|
t.Fatalf("message text = %q, want %q", got, messageText)
|
||||||
|
}
|
||||||
|
|
||||||
|
billingPattern := regexp.MustCompile(`(x-anthropic-billing-header:[^"]*?\bcch=)([0-9a-f]{5})(;)`)
|
||||||
|
match := billingPattern.FindSubmatch(seenBody)
|
||||||
|
if match == nil {
|
||||||
|
t.Fatalf("expected signed billing header in body: %s", string(seenBody))
|
||||||
|
}
|
||||||
|
actualCCH := string(match[2])
|
||||||
|
unsignedBody := billingPattern.ReplaceAll(seenBody, []byte(`${1}00000${3}`))
|
||||||
|
wantCCH := fmt.Sprintf("%05x", xxHash64.Checksum(unsignedBody, 0x6E52736AC806831E)&0xFFFFF)
|
||||||
|
if actualCCH != wantCCH {
|
||||||
|
t.Fatalf("cch = %q, want %q\nbody: %s", actualCCH, wantCCH, string(seenBody))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCloaking_PreservesConfiguredStrictModeAndSensitiveWordsWhenModeOmitted(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
ClaudeKey: []config.ClaudeKey{{
|
||||||
|
APIKey: "key-123",
|
||||||
|
Cloak: &config.CloakConfig{
|
||||||
|
StrictMode: true,
|
||||||
|
SensitiveWords: []string{"proxy"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-123"}}
|
||||||
|
payload := []byte(`{"system":"proxy rules","messages":[{"role":"user","content":[{"type":"text","text":"proxy access"}]}]}`)
|
||||||
|
|
||||||
|
out := applyCloaking(context.Background(), cfg, auth, payload, "claude-3-5-sonnet-20241022", "key-123")
|
||||||
|
|
||||||
|
blocks := gjson.GetBytes(out, "system").Array()
|
||||||
|
if len(blocks) != 2 {
|
||||||
|
t.Fatalf("expected strict mode to keep only injected system blocks, got %d", len(blocks))
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(out, "messages.0.content.0.text").String(); !strings.Contains(got, zeroWidthSpace) {
|
||||||
|
t.Fatalf("expected configured sensitive word obfuscation to apply, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
xxHash64 "github.com/pierrec/xxHash/xxHash64"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
const claudeCCHSeed uint64 = 0x6E52736AC806831E
|
||||||
|
|
||||||
|
var claudeBillingHeaderPlaceholderPattern = regexp.MustCompile(`(x-anthropic-billing-header:[^"]*?\bcch=)(00000)(;)`)
|
||||||
|
|
||||||
|
func signAnthropicMessagesBody(body []byte) []byte {
|
||||||
|
if !claudeBillingHeaderPlaceholderPattern.Match(body) {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
cch := fmt.Sprintf("%05x", xxHash64.Checksum(body, claudeCCHSeed)&0xFFFFF)
|
||||||
|
return claudeBillingHeaderPlaceholderPattern.ReplaceAll(body, []byte("${1}"+cch+"${3}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 experimentalCCHSigningEnabled(cfg *config.Config, auth *cliproxyauth.Auth) bool {
|
||||||
|
entry := resolveClaudeKeyConfig(cfg, auth)
|
||||||
|
return entry != nil && entry.ExperimentalCCHSigning
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user