Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion internal/adapter/provider/codex/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,7 @@ func (a *CodexAdapter) applyCodexHeaders(upstreamReq, clientReq *http.Request, a
} else {
ensureHeader(upstreamReq.Header, clientReq, "Session_id", uuid.NewString())
}
ensureHeader(upstreamReq.Header, clientReq, "User-Agent", CodexUserAgent)
upstreamReq.Header.Set("User-Agent", resolveCodexUserAgent(clientReq))
if hasAccessToken {
ensureHeader(upstreamReq.Header, clientReq, "Originator", CodexOriginator)
}
Expand All @@ -719,6 +719,20 @@ func ensureHeader(dst http.Header, clientReq *http.Request, key, defaultValue st
dst.Set(key, defaultValue)
}

func resolveCodexUserAgent(clientReq *http.Request) string {
if clientReq != nil {
if ua := strings.TrimSpace(clientReq.Header.Get("User-Agent")); isCodexCLIUserAgent(ua) {
return ua
}
}
return CodexUserAgent
}

func isCodexCLIUserAgent(userAgent string) bool {
ua := strings.ToLower(strings.TrimSpace(userAgent))
return strings.HasPrefix(ua, "codex_cli_rs/") || strings.HasPrefix(ua, "codex-cli/")
}

var codexFilteredHeaders = map[string]bool{
// Hop-by-hop headers
"connection": true,
Expand All @@ -730,6 +744,9 @@ var codexFilteredHeaders = map[string]bool{
"host": true,
"content-length": true,

// Explicitly controlled headers
"user-agent": true,

// Proxy/forwarding headers (privacy protection)
"x-forwarded-for": true,
"x-forwarded-host": true,
Expand Down
32 changes: 30 additions & 2 deletions internal/adapter/provider/codex/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func TestApplyCodexHeadersFiltersSensitiveAndPreservesUA(t *testing.T) {
a := &CodexAdapter{}
upstreamReq, _ := http.NewRequest("POST", "https://chatgpt.com/backend-api/codex/responses", nil)
clientReq, _ := http.NewRequest("POST", "http://localhost/responses", nil)
clientReq.Header.Set("User-Agent", "codex-cli-custom/1.2.3")
clientReq.Header.Set("User-Agent", "codex-cli/1.2.3")
clientReq.Header.Set("X-Forwarded-For", "1.2.3.4")
clientReq.Header.Set("Traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00")
clientReq.Header.Set("X-Request-Id", "rid-1")
Expand All @@ -58,7 +58,7 @@ func TestApplyCodexHeadersFiltersSensitiveAndPreservesUA(t *testing.T) {
if got := upstreamReq.Header.Get("X-Request-Id"); got != "" {
t.Fatalf("expected X-Request-Id filtered, got %q", got)
}
if got := upstreamReq.Header.Get("User-Agent"); got != "codex-cli-custom/1.2.3" {
if got := upstreamReq.Header.Get("User-Agent"); got != "codex-cli/1.2.3" {
t.Fatalf("expected User-Agent passthrough, got %q", got)
}
if got := upstreamReq.Header.Get("X-Custom"); got != "ok" {
Expand All @@ -77,3 +77,31 @@ func TestIsCodexResponseCompletedLine(t *testing.T) {
t.Fatal("expected invalid json line to be false")
}
}

func TestApplyCodexHeadersUsesDefaultUAForNonCLI(t *testing.T) {
a := &CodexAdapter{}
upstreamReq, _ := http.NewRequest("POST", "https://chatgpt.com/backend-api/codex/responses", nil)
clientReq, _ := http.NewRequest("POST", "http://localhost/responses", nil)
clientReq.Header.Set("User-Agent", "Mozilla/5.0")
clientReq.Header.Set("X-Custom", "ok")

a.applyCodexHeaders(upstreamReq, clientReq, "token-1", "acct-1", true, "")

if got := upstreamReq.Header.Get("User-Agent"); got != CodexUserAgent {
t.Fatalf("expected default Codex User-Agent for non-CLI client, got %q", got)
}
if got := upstreamReq.Header.Get("X-Custom"); got != "ok" {
t.Fatalf("expected X-Custom passthrough, got %q", got)
}
}

func TestApplyCodexHeadersUsesDefaultUAWhenClientReqNil(t *testing.T) {
a := &CodexAdapter{}
upstreamReq, _ := http.NewRequest("POST", "https://chatgpt.com/backend-api/codex/responses", nil)

a.applyCodexHeaders(upstreamReq, nil, "token-1", "acct-1", true, "")

if got := upstreamReq.Header.Get("User-Agent"); got != CodexUserAgent {
t.Fatalf("expected default Codex User-Agent when client request is nil, got %q", got)
}
}
77 changes: 60 additions & 17 deletions internal/adapter/provider/custom/claude_body.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ const claudeToolPrefix = "proxy_"
// userIDPattern matches Claude Code format: user_[64-hex]_account__session_[uuid-v4]
var userIDPattern = regexp.MustCompile(`^user_[a-fA-F0-9]{64}_account__session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)

// claudeCLIUserAgentPattern matches official Claude CLI user agent pattern.
// Aligns with sub2api/claude-relay-service detection: claude-cli/x.y.z
var claudeCLIUserAgentPattern = regexp.MustCompile(`(?i)^claude-cli/\d+\.\d+\.\d+`)

// processClaudeRequestBody processes Claude request body before sending to upstream.
// Following CLIProxyAPI order:
// 1. applyCloaking (system prompt injection, fake user_id, sensitive word obfuscation)
Expand Down Expand Up @@ -74,10 +78,9 @@ func applyCloaking(body []byte, clientUserAgent string, model string, cloakCfg *
return body
}

// Skip system instructions for claude-3-5-haiku models (CLIProxyAPI behavior)
if !strings.HasPrefix(model, "claude-3-5-haiku") {
body = checkSystemInstructionsWithMode(body, strictMode)
}
// Always ensure Claude Code system prompt for cloaked requests.
// This keeps messages-path requests compatible with strict Claude client validators.
body = checkSystemInstructionsWithMode(body, strictMode)

// Inject fake user_id
body = injectFakeUserID(body)
Expand All @@ -93,7 +96,7 @@ func applyCloaking(body []byte, clientUserAgent string, model string, cloakCfg *

// isClaudeCodeClient checks if the User-Agent indicates a Claude Code client.
func isClaudeCodeClient(userAgent string) bool {
return strings.HasPrefix(userAgent, "claude-cli")
return claudeCLIUserAgentPattern.MatchString(strings.TrimSpace(userAgent))
}

func isClaudeOAuthToken(apiKey string) bool {
Expand Down Expand Up @@ -273,7 +276,7 @@ func shouldCloak(cloakMode string, userAgent string) bool {
case "never":
return false
default: // "auto" or empty
return !strings.HasPrefix(userAgent, "claude-cli")
return !isClaudeCodeClient(userAgent)
}
}

Expand Down Expand Up @@ -336,30 +339,70 @@ func extractAndRemoveBetas(body []byte) ([]string, []byte) {
// In strict mode, it replaces all user system messages.
// In non-strict mode (default), it prepends to existing system messages.
func checkSystemInstructionsWithMode(body []byte, strictMode bool) []byte {
system := gjson.GetBytes(body, "system")
if hasClaudeCodeSystemPrompt(body) {
return body
}

claudeCodeInstructions := `[{"type":"text","text":"` + claudeCodeSystemPrompt + `"}]`

if strictMode {
body, _ = sjson.SetRawBytes(body, "system", []byte(claudeCodeInstructions))
return body
}

system := gjson.GetBytes(body, "system")
if system.IsArray() {
if gjson.GetBytes(body, "system.0.text").String() != claudeCodeSystemPrompt {
system.ForEach(func(_, part gjson.Result) bool {
if part.Get("type").String() == "text" {
claudeCodeInstructions, _ = sjson.SetRaw(claudeCodeInstructions, "-1", part.Raw)
}
return true
})
body, _ = sjson.SetRawBytes(body, "system", []byte(claudeCodeInstructions))
}
} else {
system.ForEach(func(_, part gjson.Result) bool {
if part.Get("type").String() == "text" {
claudeCodeInstructions, _ = sjson.SetRaw(claudeCodeInstructions, "-1", part.Raw)
}
return true
})
body, _ = sjson.SetRawBytes(body, "system", []byte(claudeCodeInstructions))
return body
}

if system.Type == gjson.String && strings.TrimSpace(system.String()) != "" {
existingBlock := `{"type":"text","text":` + system.Raw + `}`
claudeCodeInstructions, _ = sjson.SetRaw(claudeCodeInstructions, "-1", existingBlock)
}
body, _ = sjson.SetRawBytes(body, "system", []byte(claudeCodeInstructions))
return body
}

func hasClaudeCodeSystemPrompt(body []byte) bool {
system := gjson.GetBytes(body, "system")
if !system.Exists() {
return false
}

if system.IsArray() {
found := false
system.ForEach(func(_, part gjson.Result) bool {
if strings.TrimSpace(part.Get("text").String()) == claudeCodeSystemPrompt {
found = true
return false
}
if part.Type == gjson.String && strings.TrimSpace(part.String()) == claudeCodeSystemPrompt {
found = true
return false
}
return true
})
return found
}

if system.Type == gjson.String {
return strings.TrimSpace(system.String()) == claudeCodeSystemPrompt
}

if system.IsObject() {
return strings.TrimSpace(system.Get("text").String()) == claudeCodeSystemPrompt
}

return false
}

// ===== Sensitive word obfuscation (CLIProxyAPI-aligned) =====

// zeroWidthSpace is the Unicode zero-width space character used for obfuscation.
Expand Down
33 changes: 28 additions & 5 deletions internal/adapter/provider/custom/claude_body_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,18 +121,24 @@ func TestShouldCloakModes(t *testing.T) {
if shouldCloak("never", "curl/7.68.0") {
t.Error("never mode should cloak none")
}
if !shouldCloak("", "claude-cli/dev") {
t.Error("default mode should cloak non-official claude-cli UA")
}
if shouldCloak("", "Claude-CLI/2.1.17 (external, cli)") {
t.Error("default mode should not cloak case-insensitive official claude-cli UA")
}
}

func TestSkipSystemInjectionForHaiku(t *testing.T) {
func TestSystemInjectionForHaikuWhenCloaked(t *testing.T) {
body := []byte(`{"model":"claude-3-5-haiku-20241022","messages":[{"role":"user","content":"hello"}]}`)

result := applyCloaking(body, "curl/7.68.0", "claude-3-5-haiku-20241022", nil)

if gjson.GetBytes(result, "system").Exists() {
t.Error("system prompt should be skipped for claude-3-5-haiku models")
if !gjson.GetBytes(result, "system").Exists() {
t.Error("system prompt should be injected for cloaked haiku requests")
}
if !gjson.GetBytes(result, "metadata.user_id").Exists() {
t.Error("user_id should still be injected for haiku models")
t.Error("user_id should be injected for haiku models")
}
}

Expand Down Expand Up @@ -367,7 +373,7 @@ func TestNoDuplicateSystemPromptInjection(t *testing.T) {
body := []byte(`{
"model":"claude-3-5-sonnet",
"messages":[{"role":"user","content":"hello"}],
"system":[{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude."},{"type":"text","text":"Additional instructions"}]
"system":[{"type":"text","text":"Additional instructions"},{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude."}]
}`)

result := injectClaudeCodeSystemPrompt(body)
Expand Down Expand Up @@ -405,3 +411,20 @@ func TestEnsureMinThinkingBudget(t *testing.T) {
t.Fatalf("disabled thinking budget_tokens = %d, want 100 (unchanged)", got)
}
}

func TestCloakingPreservesSystemStringInNonStrictMode(t *testing.T) {
body := []byte(`{
"model":"claude-3-5-sonnet",
"system":"Keep this instruction",
"messages":[{"role":"user","content":"hello"}]
}`)
cfg := &domain.ProviderConfigCustomCloak{Mode: "always", StrictMode: false}

result := applyCloaking(body, "curl/7.68.0", "claude-3-5-sonnet", cfg)
if got := gjson.GetBytes(result, "system.0.text").String(); got != claudeCodeSystemPrompt {
t.Fatalf("expected Claude Code prompt prepended, got %q", got)
}
if got := gjson.GetBytes(result, "system.1.text").String(); got != "Keep this instruction" {
t.Fatalf("expected original system string preserved, got %q", got)
}
}
11 changes: 10 additions & 1 deletion internal/adapter/provider/custom/claude_headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,16 @@ func applyClaudeHeaders(req *http.Request, clientReq *http.Request, apiKey strin
ensureHeader(req.Header, clientHeaders, "X-Stainless-Arch", "arm64")
ensureHeader(req.Header, clientHeaders, "X-Stainless-Os", "MacOS")
ensureHeader(req.Header, clientHeaders, "X-Stainless-Timeout", "60")
ensureHeader(req.Header, clientHeaders, "User-Agent", defaultClaudeUserAgent)

clientUA := ""
if clientHeaders != nil {
clientUA = strings.TrimSpace(clientHeaders.Get("User-Agent"))
}
if isClaudeCodeClient(clientUA) {
req.Header.Set("User-Agent", clientUA)
} else {
req.Header.Set("User-Agent", defaultClaudeUserAgent)
}

// 6. Set connection and encoding headers (always override)
req.Header.Set("Connection", "keep-alive")
Expand Down
65 changes: 65 additions & 0 deletions internal/adapter/provider/custom/claude_headers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package custom

import (
"net/http"
"regexp"
"strings"
"testing"

"github.com/tidwall/gjson"
)

func TestApplyClaudeHeadersAccept(t *testing.T) {
Expand Down Expand Up @@ -95,3 +98,65 @@ func TestApplyClaudeHeadersDefaults(t *testing.T) {
t.Error("X-Stainless-Runtime should be set")
}
}

func TestApplyClaudeHeadersUserAgentPassthroughOnlyForCLI(t *testing.T) {
cliReq, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil)
cliClientReq, _ := http.NewRequest("POST", "https://example.com", nil)
cliClientReq.Header.Set("User-Agent", "claude-cli/2.1.23 (external, cli)")

applyClaudeHeaders(cliReq, cliClientReq, "sk-test", true, nil, true)
if got := cliReq.Header.Get("User-Agent"); got != "claude-cli/2.1.23 (external, cli)" {
t.Fatalf("expected CLI User-Agent passthrough, got %q", got)
}

nonCLIReq, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil)
nonCLIClientReq, _ := http.NewRequest("POST", "https://example.com", nil)
nonCLIClientReq.Header.Set("User-Agent", "Mozilla/5.0")

applyClaudeHeaders(nonCLIReq, nonCLIClientReq, "sk-test", true, nil, true)
if got := nonCLIReq.Header.Get("User-Agent"); got != defaultClaudeUserAgent {
t.Fatalf("expected default User-Agent for non-CLI client, got %q", got)
}

nonOfficialReq, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil)
nonOfficialClientReq, _ := http.NewRequest("POST", "https://example.com", nil)
nonOfficialClientReq.Header.Set("User-Agent", "claude-cli/dev")

applyClaudeHeaders(nonOfficialReq, nonOfficialClientReq, "sk-test", true, nil, true)
if got := nonOfficialReq.Header.Get("User-Agent"); got != defaultClaudeUserAgent {
t.Fatalf("expected default User-Agent for non-official CLI UA, got %q", got)
}
}

func TestCloakingBuildsSub2apiCompatibleClaudeShape(t *testing.T) {
clientReq, _ := http.NewRequest("POST", "https://example.com/v1/messages", nil)
clientReq.Header.Set("User-Agent", "curl/8.0.0")

body := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hello"}]}`)
processedBody, extraBetas := processClaudeRequestBody(body, clientReq.Header.Get("User-Agent"), nil)

upstreamReq, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil)
applyClaudeHeaders(upstreamReq, clientReq, "sk-test", true, extraBetas, true)

uaPattern := regexp.MustCompile(`(?i)^claude-cli/\d+\.\d+\.\d+`)
if got := upstreamReq.Header.Get("User-Agent"); !uaPattern.MatchString(got) {
t.Fatalf("expected sub2api-compatible User-Agent, got %q", got)
}

for _, key := range []string{"X-App", "Anthropic-Beta", "Anthropic-Version"} {
if strings.TrimSpace(upstreamReq.Header.Get(key)) == "" {
t.Fatalf("expected %s to be set", key)
}
}

userID := gjson.GetBytes(processedBody, "metadata.user_id").String()
userIDPattern := regexp.MustCompile(`^user_[a-fA-F0-9]{64}_account__session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
if !userIDPattern.MatchString(userID) {
t.Fatalf("expected sub2api-compatible metadata.user_id, got %q", userID)
}

systemText := gjson.GetBytes(processedBody, "system.0.text").String()
if !strings.Contains(systemText, "Claude Code, Anthropic's official CLI for Claude") {
t.Fatalf("expected cloaked system prompt, got %q", systemText)
}
}
Loading