diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 0beb15d3a1..9368abc76d 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -252,6 +252,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { EnableFingerprintUnification: settings.EnableFingerprintUnification, EnableMetadataPassthrough: settings.EnableMetadataPassthrough, EnableCCHSigning: settings.EnableCCHSigning, + EnableClaudeOAuthSystemPromptInjection: settings.EnableClaudeOAuthSystemPromptInjection, + ClaudeOAuthSystemPrompt: settings.ClaudeOAuthSystemPrompt, + ClaudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks, EnableAnthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection, RewriteMessageCacheControl: settings.RewriteMessageCacheControl, AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion, @@ -580,14 +583,17 @@ type UpdateSettingsRequest struct { BackendModeEnabled bool `json:"backend_mode_enabled"` // Gateway forwarding behavior - EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"` - EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"` - EnableCCHSigning *bool `json:"enable_cch_signing"` - EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"` - RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"` - AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"` - OpenAICodexUserAgent *string `json:"openai_codex_user_agent"` - OpenAIAllowClaudeCodeCodexPlugin *bool `json:"openai_allow_claude_code_codex_plugin"` + EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"` + EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"` + EnableCCHSigning *bool `json:"enable_cch_signing"` + EnableClaudeOAuthSystemPromptInjection *bool `json:"enable_claude_oauth_system_prompt_injection"` + ClaudeOAuthSystemPrompt *string `json:"claude_oauth_system_prompt"` + ClaudeOAuthSystemPromptBlocks *string `json:"claude_oauth_system_prompt_blocks"` + EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"` + RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"` + AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"` + OpenAICodexUserAgent *string `json:"openai_codex_user_agent"` + OpenAIAllowClaudeCodeCodexPlugin *bool `json:"openai_allow_claude_code_codex_plugin"` // Payment visible method routing PaymentVisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"` @@ -1643,6 +1649,24 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.EnableCCHSigning }(), + EnableClaudeOAuthSystemPromptInjection: func() bool { + if req.EnableClaudeOAuthSystemPromptInjection != nil { + return *req.EnableClaudeOAuthSystemPromptInjection + } + return previousSettings.EnableClaudeOAuthSystemPromptInjection + }(), + ClaudeOAuthSystemPrompt: func() string { + if req.ClaudeOAuthSystemPrompt != nil { + return *req.ClaudeOAuthSystemPrompt + } + return previousSettings.ClaudeOAuthSystemPrompt + }(), + ClaudeOAuthSystemPromptBlocks: func() string { + if req.ClaudeOAuthSystemPromptBlocks != nil { + return *req.ClaudeOAuthSystemPromptBlocks + } + return previousSettings.ClaudeOAuthSystemPromptBlocks + }(), EnableAnthropicCacheTTL1hInjection: func() bool { if req.EnableAnthropicCacheTTL1hInjection != nil { return *req.EnableAnthropicCacheTTL1hInjection @@ -2045,6 +2069,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification, EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough, EnableCCHSigning: updatedSettings.EnableCCHSigning, + EnableClaudeOAuthSystemPromptInjection: updatedSettings.EnableClaudeOAuthSystemPromptInjection, + ClaudeOAuthSystemPrompt: updatedSettings.ClaudeOAuthSystemPrompt, + ClaudeOAuthSystemPromptBlocks: updatedSettings.ClaudeOAuthSystemPromptBlocks, EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection, RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl, AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion, @@ -2508,6 +2535,15 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.EnableCCHSigning != after.EnableCCHSigning { changed = append(changed, "enable_cch_signing") } + if before.EnableClaudeOAuthSystemPromptInjection != after.EnableClaudeOAuthSystemPromptInjection { + changed = append(changed, "enable_claude_oauth_system_prompt_injection") + } + if before.ClaudeOAuthSystemPrompt != after.ClaudeOAuthSystemPrompt { + changed = append(changed, "claude_oauth_system_prompt") + } + if before.ClaudeOAuthSystemPromptBlocks != after.ClaudeOAuthSystemPromptBlocks { + changed = append(changed, "claude_oauth_system_prompt_blocks") + } if before.EnableAnthropicCacheTTL1hInjection != after.EnableAnthropicCacheTTL1hInjection { changed = append(changed, "enable_anthropic_cache_ttl_1h_injection") } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index da89ac237c..593a745209 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -178,14 +178,17 @@ type SystemSettings struct { BackendModeEnabled bool `json:"backend_mode_enabled"` // Gateway forwarding behavior - EnableFingerprintUnification bool `json:"enable_fingerprint_unification"` - EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"` - EnableCCHSigning bool `json:"enable_cch_signing"` - EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"` - RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"` - AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"` - OpenAICodexUserAgent string `json:"openai_codex_user_agent"` - OpenAIAllowClaudeCodeCodexPlugin bool `json:"openai_allow_claude_code_codex_plugin"` + EnableFingerprintUnification bool `json:"enable_fingerprint_unification"` + EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"` + EnableCCHSigning bool `json:"enable_cch_signing"` + EnableClaudeOAuthSystemPromptInjection bool `json:"enable_claude_oauth_system_prompt_injection"` + ClaudeOAuthSystemPrompt string `json:"claude_oauth_system_prompt"` + ClaudeOAuthSystemPromptBlocks string `json:"claude_oauth_system_prompt_blocks"` + EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"` + RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"` + AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"` + OpenAICodexUserAgent string `json:"openai_codex_user_agent"` + OpenAIAllowClaudeCodeCodexPlugin bool `json:"openai_allow_claude_code_codex_plugin"` // Web Search Emulation WebSearchEmulationEnabled bool `json:"web_search_emulation_enabled"` diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index fb9b616adc..8a103c509b 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -835,6 +835,9 @@ func TestAPIContracts(t *testing.T) { "allow_ungrouped_key_scheduling": false, "backend_mode_enabled": false, "enable_cch_signing": false, + "enable_claude_oauth_system_prompt_injection": true, + "claude_oauth_system_prompt": "", + "claude_oauth_system_prompt_blocks": "", "enable_anthropic_cache_ttl_1h_injection": false, "rewrite_message_cache_control": false, "antigravity_user_agent_version": "", @@ -1075,6 +1078,9 @@ func TestAPIContracts(t *testing.T) { "enable_fingerprint_unification": true, "enable_metadata_passthrough": false, "enable_cch_signing": false, + "enable_claude_oauth_system_prompt_injection": true, + "claude_oauth_system_prompt": "", + "claude_oauth_system_prompt_blocks": "", "enable_anthropic_cache_ttl_1h_injection": false, "rewrite_message_cache_control": false, "antigravity_user_agent_version": "", diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 11245d0016..870b1ffffb 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -421,6 +421,12 @@ const ( SettingKeyEnableMetadataPassthrough = "enable_metadata_passthrough" // SettingKeyEnableCCHSigning 是否对 billing header 中的 cch 进行 xxHash64 签名(默认 false) SettingKeyEnableCCHSigning = "enable_cch_signing" + // SettingKeyEnableClaudeOAuthSystemPromptInjection 是否对 Claude OAuth mimic 路径注入 Claude Code system blocks(默认 true) + SettingKeyEnableClaudeOAuthSystemPromptInjection = "enable_claude_oauth_system_prompt_injection" + // SettingKeyClaudeOAuthSystemPrompt Claude OAuth mimic 路径注入的通用扩展 system prompt(空值使用内置默认) + SettingKeyClaudeOAuthSystemPrompt = "claude_oauth_system_prompt" + // SettingKeyClaudeOAuthSystemPromptBlocks Claude OAuth mimic 路径注入的 system blocks JSON 配置(空值使用内置默认) + SettingKeyClaudeOAuthSystemPromptBlocks = "claude_oauth_system_prompt_blocks" // SettingKeyEnableAnthropicCacheTTL1hInjection 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false) SettingKeyEnableAnthropicCacheTTL1hInjection = "enable_anthropic_cache_ttl_1h_injection" // SettingKeyRewriteMessageCacheControl 是否改写 messages[*].content[*].cache_control(默认 false) diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index c0bc0ef15f..30fc976ef3 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -839,6 +839,70 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( } } +func TestGatewayService_AnthropicOAuth_SystemPromptInjectionCanBeDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + resetGatewayForwardingSettingsCacheForTest(t) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + body := []byte(`{"model":"claude-3-5-sonnet-latest","system":"Original system prompt","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`) + parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic) + require.NoError(t, err) + + upstream := &anthropicHTTPUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "x-request-id": []string{"rid-oauth-no-system-injection"}, + }, + Body: io.NopCloser(strings.NewReader(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-3-5-sonnet-20241022","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":12,"output_tokens":7}}`)), + }, + } + + cfg := &config.Config{ + Gateway: config.GatewayConfig{ + MaxLineSize: defaultMaxLineSize, + }, + } + settingService := NewSettingService(&gatewayTTLSettingRepo{data: map[string]string{ + SettingKeyEnableClaudeOAuthSystemPromptInjection: "false", + }}, cfg) + svc := &GatewayService{ + cfg: cfg, + responseHeaderFilter: compileResponseHeaderFilter(cfg), + httpUpstream: upstream, + rateLimitService: &RateLimitService{}, + deferredService: &DeferredService{}, + settingService: settingService, + } + + account := &Account{ + ID: 302, + Name: "anthropic-oauth-no-system-injection", + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + }, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, parsed) + require.NoError(t, err) + require.NotNil(t, result) + + system := gjson.GetBytes(upstream.lastBody, "system") + require.True(t, system.Exists()) + require.Equal(t, "Original system prompt", system.String()) + require.NotContains(t, string(upstream.lastBody), "x-anthropic-billing-header:") + require.NotContains(t, string(upstream.lastBody), "[System Instructions]") +} + func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingStillCollectsUsageAfterClientDisconnect(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/gateway_billing_block.go b/backend/internal/service/gateway_billing_block.go index 06a1a21c86..0a5128835a 100644 --- a/backend/internal/service/gateway_billing_block.go +++ b/backend/internal/service/gateway_billing_block.go @@ -3,7 +3,6 @@ package service import ( "crypto/sha256" "encoding/hex" - "encoding/json" "fmt" "github.com/tidwall/gjson" @@ -71,28 +70,24 @@ func extractFirstUserText(body []byte) string { return first } -// buildBillingAttributionBlockJSON 构造 system 数组的 billing attribution block。 +// buildBillingAttributionText 构造 system 数组的 billing attribution 文本。 // // 形态严格对齐真实 Claude Code CLI: // -// {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.161.{fp}; cc_entrypoint=cli; cch=00000;"} +// x-anthropic-billing-header: cc_version=2.1.161.{fp}; cc_entrypoint=cli; cch=00000; // // cch=00000 是签名占位符,由 signBillingHeaderCCH 在 buildUpstreamRequest 阶段 // 替换为基于完整 body 的 xxhash64 5 位十六进制摘要。 // // 此 block 不带 cache_control(与真实 CLI 一致;cache breakpoint 由后续的 // Claude Code prompt block 承担)。 -func buildBillingAttributionBlockJSON(body []byte, cliVersion string) ([]byte, error) { +func buildBillingAttributionText(body []byte, cliVersion string) (string, error) { if cliVersion == "" { - return nil, fmt.Errorf("cliVersion required") + return "", fmt.Errorf("cliVersion required") } fp := computeClaudeCodeFingerprint(body, cliVersion) - text := fmt.Sprintf( + return fmt.Sprintf( "x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=cli; cch=00000;", cliVersion, fp, - ) - return json.Marshal(map[string]string{ - "type": "text", - "text": text, - }) + ), nil } diff --git a/backend/internal/service/gateway_prompt_test.go b/backend/internal/service/gateway_prompt_test.go index eb6f58d6d6..ed702e3d06 100644 --- a/backend/internal/service/gateway_prompt_test.go +++ b/backend/internal/service/gateway_prompt_test.go @@ -5,7 +5,9 @@ import ( "strings" "testing" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) func TestIsClaudeCodeClient(t *testing.T) { @@ -465,3 +467,42 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { }) } } + +func TestRewriteSystemForNonClaudeCodeWithPrompt_UsesCustomExpansionPrompt(t *testing.T) { + body := []byte(`{"model":"claude-3","system":"Project instructions","messages":[{"role":"user","content":"hello"}]}`) + customPrompt := "Custom Claude OAuth expansion prompt" + + result := rewriteSystemForNonClaudeCodeWithPrompt(body, "Project instructions", customPrompt) + + system := gjson.GetBytes(result, "system") + require.True(t, system.IsArray()) + require.Len(t, system.Array(), 3) + require.Equal(t, customPrompt, system.Array()[2].Get("text").String()) + require.Equal(t, "ephemeral", system.Array()[2].Get("cache_control.type").String()) +} + +func TestRewriteSystemForNonClaudeCodeWithPromptBlocks_UsesConfiguredBlocks(t *testing.T) { + body := []byte(`{"model":"claude-3","system":"Project instructions","messages":[{"role":"user","content":"hello"}]}`) + blocks := `{ + "blocks": [ + {"type":"text","text":"prefix {cc_version}.{fp}","cache_control":true}, + {"enabled":false,"type":"text","text":"disabled"}, + {"type":"text","text":"{claude_code_system_prompt}"}, + {"type":"text","text":"tail","cache_control":{"type":"ephemeral","ttl":"1h"}} + ] + }` + + result := rewriteSystemForNonClaudeCodeWithPromptBlocks(body, "Project instructions", "", blocks) + + system := gjson.GetBytes(result, "system") + require.True(t, system.IsArray()) + arr := system.Array() + require.Len(t, arr, 3) + require.Contains(t, arr[0].Get("text").String(), "prefix "+claude.CLICurrentVersion+".") + require.Equal(t, "ephemeral", arr[0].Get("cache_control.type").String()) + require.Equal(t, claude.DefaultCacheControlTTL, arr[0].Get("cache_control.ttl").String()) + require.Equal(t, claudeCodeSystemPrompt, arr[1].Get("text").String()) + require.False(t, arr[1].Get("cache_control").Exists()) + require.Equal(t, "tail", arr[2].Get("text").String()) + require.Equal(t, "1h", arr[2].Get("cache_control.ttl").String()) +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 82b57f3041..2ddd2f6788 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -28,6 +28,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" @@ -1038,6 +1039,17 @@ func marshalAnthropicSystemTextBlock(text string, includeCacheControl bool) ([]b return json.Marshal(block) } +func marshalAnthropicSystemTextBlockWithCacheControl(text string, cacheControl any) ([]byte, error) { + block := map[string]any{ + "type": "text", + "text": text, + } + if cacheControl != nil { + block["cache_control"] = cacheControl + } + return json.Marshal(block) +} + func marshalAnthropicMetadata(userID string) ([]byte, error) { return json.Marshal(anthropicMetadataPayload{UserID: userID}) } @@ -1336,9 +1348,10 @@ func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody( return body } + systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx) systemRewritten := false - if !strings.Contains(strings.ToLower(model), "haiku") { - body = rewriteSystemForNonClaudeCode(body, normalizeSystemParam(systemRaw)) + if systemPromptInjectionEnabled && !strings.Contains(strings.ToLower(model), "haiku") { + body = rewriteSystemForNonClaudeCodeWithPromptBlocks(body, normalizeSystemParam(systemRaw), systemPrompt, systemPromptBlocks) systemRewritten = true } @@ -4161,7 +4174,183 @@ func injectClaudeCodePrompt(body []byte, system any) []byte { // 无法通过检测,因为后续内容仍为非 Claude Code 格式。 // 策略:将原始 system prompt 提取并注入为 user/assistant 消息对,system 仅保留 Claude Code 标识。 func rewriteSystemForNonClaudeCode(body []byte, system any) []byte { + return rewriteSystemForNonClaudeCodeWithPromptBlocks(body, system, "", "") +} + +func rewriteSystemForNonClaudeCodeWithPrompt(body []byte, system any, expansionPrompt string) []byte { + return rewriteSystemForNonClaudeCodeWithPromptBlocks(body, system, expansionPrompt, "") +} + +type claudeOAuthSystemPromptBlockConfig struct { + Enabled *bool `json:"enabled,omitempty"` + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + CacheControl json.RawMessage `json:"cache_control,omitempty"` +} + +type claudeOAuthSystemPromptBlocksEnvelope struct { + Blocks []claudeOAuthSystemPromptBlockConfig `json:"blocks"` +} + +func defaultClaudeOAuthExpansionPrompt(expansionPrompt string) string { + expansionPrompt = strings.TrimSpace(expansionPrompt) + if expansionPrompt == "" { + return claudeCodeSystemPromptExpansion + } + return expansionPrompt +} + +func parseClaudeOAuthSystemPromptBlocksConfig(raw string) ([]claudeOAuthSystemPromptBlockConfig, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + if strings.HasPrefix(raw, "[") { + var blocks []claudeOAuthSystemPromptBlockConfig + if err := json.Unmarshal([]byte(raw), &blocks); err != nil { + return nil, err + } + return blocks, nil + } + var envelope claudeOAuthSystemPromptBlocksEnvelope + if err := json.Unmarshal([]byte(raw), &envelope); err != nil { + return nil, err + } + return envelope.Blocks, nil +} + +func decodeClaudeOAuthSystemPromptCacheControl(raw json.RawMessage) (any, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) || bytes.Equal(trimmed, []byte("false")) { + return nil, nil + } + if bytes.Equal(trimmed, []byte("true")) { + return map[string]string{ + "type": "ephemeral", + "ttl": claude.DefaultCacheControlTTL, + }, nil + } + var value any + if err := json.Unmarshal(trimmed, &value); err != nil { + return nil, err + } + if _, ok := value.(map[string]any); !ok { + return nil, fmt.Errorf("cache_control must be boolean, null, or object") + } + return value, nil +} + +func expandClaudeOAuthSystemPromptTextTemplate(body []byte, text string, expansionPrompt string) (string, error) { + if text == "" { + return "", nil + } + expansionPrompt = defaultClaudeOAuthExpansionPrompt(expansionPrompt) + billingText, err := buildBillingAttributionText(body, claude.CLICurrentVersion) + if err != nil { + return "", err + } + fp := computeClaudeCodeFingerprint(body, claude.CLICurrentVersion) + replacer := strings.NewReplacer( + "{billing_header}", billingText, + "{cc_version}", claude.CLICurrentVersion, + "{fp}", fp, + "{claude_code_system_prompt}", claudeCodeSystemPrompt, + "{claude_code_expansion_prompt}", expansionPrompt, + ) + return replacer.Replace(text), nil +} + +func defaultClaudeOAuthSystemPromptBlockConfig() []claudeOAuthSystemPromptBlockConfig { + enabled := true + return []claudeOAuthSystemPromptBlockConfig{ + { + Enabled: &enabled, + Type: "text", + Text: "{billing_header}", + }, + { + Enabled: &enabled, + Type: "text", + Text: "{claude_code_system_prompt}", + }, + { + Enabled: &enabled, + Type: "text", + Text: "{claude_code_expansion_prompt}", + CacheControl: json.RawMessage( + fmt.Sprintf(`{"type":"ephemeral","ttl":%q}`, claude.DefaultCacheControlTTL), + ), + }, + } +} + +func buildClaudeOAuthSystemPromptBlocksJSON(body []byte, expansionPrompt string, blocksConfig string) ([][]byte, error) { + blocks, err := parseClaudeOAuthSystemPromptBlocksConfig(blocksConfig) + if err != nil { + return nil, err + } + if len(blocks) == 0 { + blocks = defaultClaudeOAuthSystemPromptBlockConfig() + } + + items := make([][]byte, 0, len(blocks)) + for i, block := range blocks { + if block.Enabled != nil && !*block.Enabled { + continue + } + blockType := strings.TrimSpace(block.Type) + if blockType == "" { + blockType = "text" + } + if blockType != "text" { + return nil, fmt.Errorf("system block %d type %q is not supported", i, block.Type) + } + text, err := expandClaudeOAuthSystemPromptTextTemplate(body, block.Text, expansionPrompt) + if err != nil { + return nil, err + } + if strings.TrimSpace(text) == "" { + continue + } + cacheControl, err := decodeClaudeOAuthSystemPromptCacheControl(block.CacheControl) + if err != nil { + return nil, fmt.Errorf("system block %d cache_control: %w", i, err) + } + raw, err := marshalAnthropicSystemTextBlockWithCacheControl(text, cacheControl) + if err != nil { + return nil, err + } + items = append(items, raw) + } + return items, nil +} + +func ValidateClaudeOAuthSystemPromptBlocksConfig(raw string) error { + if strings.TrimSpace(raw) == "" { + return nil + } + blocks, err := parseClaudeOAuthSystemPromptBlocksConfig(raw) + if err != nil { + return infraerrors.BadRequest("INVALID_CLAUDE_OAUTH_SYSTEM_PROMPT_BLOCKS", "claude oauth system prompt blocks must be valid JSON") + } + for i, block := range blocks { + blockType := strings.TrimSpace(block.Type) + if blockType == "" { + blockType = "text" + } + if blockType != "text" { + return infraerrors.BadRequest("INVALID_CLAUDE_OAUTH_SYSTEM_PROMPT_BLOCKS", fmt.Sprintf("system block %d type must be text", i)) + } + if _, err := decodeClaudeOAuthSystemPromptCacheControl(block.CacheControl); err != nil { + return infraerrors.BadRequest("INVALID_CLAUDE_OAUTH_SYSTEM_PROMPT_BLOCKS", fmt.Sprintf("system block %d cache_control is invalid", i)) + } + } + return nil +} + +func rewriteSystemForNonClaudeCodeWithPromptBlocks(body []byte, system any, expansionPrompt string, blocksConfig string) []byte { system = normalizeSystemParam(system) + expansionPrompt = defaultClaudeOAuthExpansionPrompt(expansionPrompt) // 1. 提取原始 system prompt 文本 var originalSystemText string @@ -4182,7 +4371,7 @@ func rewriteSystemForNonClaudeCode(body []byte, system any) []byte { // 2. 构造 system 数组,对齐真实 Claude Code CLI 的 3-block 形态: // [0] billing attribution block(cc_version={cliVer}.{fp}; cc_entrypoint=cli; cch=00000;) - // [1] "You are Claude Code..." 身份前缀 block(带 cache_control) + // [1] "You are Claude Code..." 身份前缀 block(默认不带 cache_control) // [2] 工具无关的通用提示词扩充 block(带 cache_control 作为稳定缓存断点) // // 真实 CC 的 system 在身份前缀之后还有大段提示词,仅有 2 块会在块数/体量上明显 @@ -4192,16 +4381,16 @@ func rewriteSystemForNonClaudeCode(body []byte, system any) []byte { // billing block 的 cch=00000 是占位符,会被 buildUpstreamRequest 里的 // signBillingHeaderCCH 替换成 xxhash64 签名。缺失 billing block 的系统 payload // 是 Anthropic 判定第三方的关键信号之一(真实 CLI 每个请求都带)。 - billingBlock, billingErr := buildBillingAttributionBlockJSON(body, claude.CLICurrentVersion) - // 身份块不带 cache_control;缓存断点统一落在最后一个静态块(扩充块)上, - // 使 billing+身份+扩充 整段静态前缀都被同一断点覆盖,且只消耗 1 个断点配额。 - ccPromptBlock, ccErr := marshalAnthropicSystemTextBlock(claudeCodeSystemPrompt, false) - ccExpansionBlock, expErr := marshalAnthropicSystemTextBlock(claudeCodeSystemPromptExpansion, true) - if billingErr != nil || ccErr != nil || expErr != nil { - logger.LegacyPrintf("service.gateway", "Warning: failed to build system blocks (billing=%v, cc=%v, exp=%v)", billingErr, ccErr, expErr) + systemBlocks, blockErr := buildClaudeOAuthSystemPromptBlocksJSON(body, expansionPrompt, blocksConfig) + if blockErr != nil { + logger.LegacyPrintf("service.gateway", "Warning: failed to build configured Claude OAuth system blocks: %v", blockErr) + systemBlocks, blockErr = buildClaudeOAuthSystemPromptBlocksJSON(body, expansionPrompt, "") + } + if blockErr != nil { + logger.LegacyPrintf("service.gateway", "Warning: failed to build default Claude OAuth system blocks: %v", blockErr) return body } - out, ok := setJSONRawBytes(body, "system", buildJSONArrayRaw([][]byte{billingBlock, ccPromptBlock, ccExpansionBlock})) + out, ok := setJSONRawBytes(body, "system", buildJSONArrayRaw(systemBlocks)) if !ok { logger.LegacyPrintf("service.gateway", "Warning: failed to set Claude Code system prompt") return body @@ -4481,6 +4670,13 @@ func (s *GatewayService) shouldInjectAnthropicCacheTTL1h(ctx context.Context, ac return s.settingService.IsAnthropicCacheTTL1hInjectionEnabled(ctx) } +func (s *GatewayService) claudeOAuthSystemPromptInjectionSettings(ctx context.Context) (bool, string, string) { + if s == nil || s.settingService == nil { + return true, "", "" + } + return s.settingService.GetClaudeOAuthSystemPromptInjectionSettings(ctx) +} + // Forward 转发请求到Claude API func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) { startTime := time.Now() @@ -4572,14 +4768,17 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A systemRewritten := false if !strings.Contains(strings.ToLower(reqModel), "haiku") { systemRaw, _ := parsed.SystemValue() - if err := replaceBody(rewriteSystemForNonClaudeCode(body, systemRaw)); err != nil { - return nil, err + systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx) + if systemPromptInjectionEnabled { + if err := replaceBody(rewriteSystemForNonClaudeCodeWithPromptBlocks(body, systemRaw, systemPrompt, systemPromptBlocks)); err != nil { + return nil, err + } + systemRewritten = true } - systemRewritten = true } // system 被重写时保留 CC prompt 的 cache_control: ephemeral(匹配真实 Claude Code 行为); - // 未重写时(haiku / 已含 CC 前缀)剥离客户端 cache_control,与原有行为一致。 + // 未重写时(haiku / 注入开关关闭)剥离客户端 cache_control,与原有行为一致。 // 两种情况下 enforceCacheControlLimit 都会兜底处理上限。 normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten} if s.identityService != nil { diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 7043736a80..4e72797fa9 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -103,12 +103,15 @@ const backendModeDBTimeout = 5 * time.Second // cachedGatewayForwardingSettings 缓存网关转发行为设置(进程内缓存,60s TTL) type cachedGatewayForwardingSettings struct { - fingerprintUnification bool - metadataPassthrough bool - cchSigning bool - anthropicCacheTTL1hInjection bool - rewriteMessageCacheControl bool - expiresAt int64 // unix nano + fingerprintUnification bool + metadataPassthrough bool + cchSigning bool + claudeOAuthSystemPromptInjection bool + claudeOAuthSystemPrompt string + claudeOAuthSystemPromptBlocks string + anthropicCacheTTL1hInjection bool + rewriteMessageCacheControl bool + expiresAt int64 // unix nano } var gatewayForwardingCache atomic.Value // *cachedGatewayForwardingSettings @@ -1909,6 +1912,12 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification) updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough) updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning) + updates[SettingKeyEnableClaudeOAuthSystemPromptInjection] = strconv.FormatBool(settings.EnableClaudeOAuthSystemPromptInjection) + updates[SettingKeyClaudeOAuthSystemPrompt] = settings.ClaudeOAuthSystemPrompt + if err := ValidateClaudeOAuthSystemPromptBlocksConfig(settings.ClaudeOAuthSystemPromptBlocks); err != nil { + return nil, err + } + updates[SettingKeyClaudeOAuthSystemPromptBlocks] = settings.ClaudeOAuthSystemPromptBlocks updates[SettingKeyEnableAnthropicCacheTTL1hInjection] = strconv.FormatBool(settings.EnableAnthropicCacheTTL1hInjection) updates[SettingKeyRewriteMessageCacheControl] = strconv.FormatBool(settings.RewriteMessageCacheControl) updates[SettingKeyAntigravityUserAgentVersion] = antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion) @@ -2035,12 +2044,15 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { }) gatewayForwardingSF.Forget("gateway_forwarding") gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ - fingerprintUnification: settings.EnableFingerprintUnification, - metadataPassthrough: settings.EnableMetadataPassthrough, - cchSigning: settings.EnableCCHSigning, - anthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection, - rewriteMessageCacheControl: settings.RewriteMessageCacheControl, - expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), + fingerprintUnification: settings.EnableFingerprintUnification, + metadataPassthrough: settings.EnableMetadataPassthrough, + cchSigning: settings.EnableCCHSigning, + claudeOAuthSystemPromptInjection: settings.EnableClaudeOAuthSystemPromptInjection, + claudeOAuthSystemPrompt: settings.ClaudeOAuthSystemPrompt, + claudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks, + anthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection, + rewriteMessageCacheControl: settings.RewriteMessageCacheControl, + expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), }) s.antigravityUAVersionSF.Forget("antigravity_user_agent_version") antigravityUserAgentVersion := antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion) @@ -2244,18 +2256,22 @@ func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool { } type gatewayForwardingSettingsResult struct { - fp, mp, cch, cacheTTL1h, rewriteMessageCacheControl bool + fp, mp, cch, claudeOAuthSystemPromptInjection, cacheTTL1h, rewriteMessageCacheControl bool + claudeOAuthSystemPrompt, claudeOAuthSystemPromptBlocks string } func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) gatewayForwardingSettingsResult { if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { if time.Now().UnixNano() < cached.expiresAt { return gatewayForwardingSettingsResult{ - fp: cached.fingerprintUnification, - mp: cached.metadataPassthrough, - cch: cached.cchSigning, - cacheTTL1h: cached.anthropicCacheTTL1hInjection, - rewriteMessageCacheControl: cached.rewriteMessageCacheControl, + fp: cached.fingerprintUnification, + mp: cached.metadataPassthrough, + cch: cached.cchSigning, + claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection, + claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt, + claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks, + cacheTTL1h: cached.anthropicCacheTTL1hInjection, + rewriteMessageCacheControl: cached.rewriteMessageCacheControl, } } } @@ -2263,11 +2279,14 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { if time.Now().UnixNano() < cached.expiresAt { return gatewayForwardingSettingsResult{ - fp: cached.fingerprintUnification, - mp: cached.metadataPassthrough, - cch: cached.cchSigning, - cacheTTL1h: cached.anthropicCacheTTL1hInjection, - rewriteMessageCacheControl: cached.rewriteMessageCacheControl, + fp: cached.fingerprintUnification, + mp: cached.metadataPassthrough, + cch: cached.cchSigning, + claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection, + claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt, + claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks, + cacheTTL1h: cached.anthropicCacheTTL1hInjection, + rewriteMessageCacheControl: cached.rewriteMessageCacheControl, }, nil } } @@ -2277,20 +2296,24 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) SettingKeyEnableFingerprintUnification, SettingKeyEnableMetadataPassthrough, SettingKeyEnableCCHSigning, + SettingKeyEnableClaudeOAuthSystemPromptInjection, + SettingKeyClaudeOAuthSystemPrompt, + SettingKeyClaudeOAuthSystemPromptBlocks, SettingKeyEnableAnthropicCacheTTL1hInjection, SettingKeyRewriteMessageCacheControl, }) if err != nil { slog.Warn("failed to get gateway forwarding settings", "error", err) gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ - fingerprintUnification: true, - metadataPassthrough: false, - cchSigning: false, - anthropicCacheTTL1hInjection: false, - rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), - expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(), + fingerprintUnification: true, + metadataPassthrough: false, + cchSigning: false, + claudeOAuthSystemPromptInjection: true, + anthropicCacheTTL1hInjection: false, + rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), + expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(), }) - return gatewayForwardingSettingsResult{fp: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl()}, nil + return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl()}, nil } fp := true if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" { @@ -2298,31 +2321,43 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) } mp := values[SettingKeyEnableMetadataPassthrough] == "true" cch := values[SettingKeyEnableCCHSigning] == "true" + systemPromptInjection := true + if v, ok := values[SettingKeyEnableClaudeOAuthSystemPromptInjection]; ok && v != "" { + systemPromptInjection = v == "true" + } + systemPrompt := values[SettingKeyClaudeOAuthSystemPrompt] + systemPromptBlocks := values[SettingKeyClaudeOAuthSystemPromptBlocks] cacheTTL1h := values[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true" rewriteMessageCacheControl := s.defaultRewriteMessageCacheControl() if v, ok := values[SettingKeyRewriteMessageCacheControl]; ok && v != "" { rewriteMessageCacheControl = v == "true" } gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ - fingerprintUnification: fp, - metadataPassthrough: mp, - cchSigning: cch, - anthropicCacheTTL1hInjection: cacheTTL1h, - rewriteMessageCacheControl: rewriteMessageCacheControl, - expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), + fingerprintUnification: fp, + metadataPassthrough: mp, + cchSigning: cch, + claudeOAuthSystemPromptInjection: systemPromptInjection, + claudeOAuthSystemPrompt: systemPrompt, + claudeOAuthSystemPromptBlocks: systemPromptBlocks, + anthropicCacheTTL1hInjection: cacheTTL1h, + rewriteMessageCacheControl: rewriteMessageCacheControl, + expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), }) return gatewayForwardingSettingsResult{ - fp: fp, - mp: mp, - cch: cch, - cacheTTL1h: cacheTTL1h, - rewriteMessageCacheControl: rewriteMessageCacheControl, + fp: fp, + mp: mp, + cch: cch, + claudeOAuthSystemPromptInjection: systemPromptInjection, + claudeOAuthSystemPrompt: systemPrompt, + claudeOAuthSystemPromptBlocks: systemPromptBlocks, + cacheTTL1h: cacheTTL1h, + rewriteMessageCacheControl: rewriteMessageCacheControl, }, nil }) if r, ok := val.(gatewayForwardingSettingsResult); ok { return r } - return gatewayForwardingSettingsResult{fp: true} + return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true} } // GetGatewayForwardingSettings returns cached gateway forwarding settings. @@ -2343,6 +2378,14 @@ func (s *SettingService) IsRewriteMessageCacheControlEnabled(ctx context.Context return s.getGatewayForwardingSettingsCached(ctx).rewriteMessageCacheControl } +// GetClaudeOAuthSystemPromptInjectionSettings returns the Claude OAuth mimic +// system block switch, legacy custom expansion prompt, and configurable blocks JSON. +// Empty values mean use the built-in Claude Code default blocks. +func (s *SettingService) GetClaudeOAuthSystemPromptInjectionSettings(ctx context.Context) (enabled bool, prompt string, blocks string) { + result := s.getGatewayForwardingSettingsCached(ctx) + return result.claudeOAuthSystemPromptInjection, result.claudeOAuthSystemPrompt, result.claudeOAuthSystemPromptBlocks +} + // IsEmailVerifyEnabled 检查是否开启邮件验证 func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled) @@ -3335,7 +3378,8 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // 分组隔离 result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true" - // Gateway forwarding behavior (defaults: fingerprint=true, metadata_passthrough=false, cch_signing=false) + // Gateway forwarding behavior (defaults: fingerprint=true, metadata_passthrough=false, + // cch_signing=false, claude_oauth_system_prompt_injection=true) if v, ok := settings[SettingKeyEnableFingerprintUnification]; ok && v != "" { result.EnableFingerprintUnification = v == "true" } else { @@ -3343,6 +3387,13 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true" result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true" + if v, ok := settings[SettingKeyEnableClaudeOAuthSystemPromptInjection]; ok && v != "" { + result.EnableClaudeOAuthSystemPromptInjection = v == "true" + } else { + result.EnableClaudeOAuthSystemPromptInjection = true + } + result.ClaudeOAuthSystemPrompt = settings[SettingKeyClaudeOAuthSystemPrompt] + result.ClaudeOAuthSystemPromptBlocks = settings[SettingKeyClaudeOAuthSystemPromptBlocks] result.EnableAnthropicCacheTTL1hInjection = settings[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true" if v, ok := settings[SettingKeyRewriteMessageCacheControl]; ok && v != "" { result.RewriteMessageCacheControl = v == "true" diff --git a/backend/internal/service/setting_service_claude_oauth_system_prompt_test.go b/backend/internal/service/setting_service_claude_oauth_system_prompt_test.go new file mode 100644 index 0000000000..d4a10787e7 --- /dev/null +++ b/backend/internal/service/setting_service_claude_oauth_system_prompt_test.go @@ -0,0 +1,49 @@ +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func resetGatewayForwardingSettingsCacheForTest(t *testing.T) { + t.Helper() + gatewayForwardingSF.Forget("gateway_forwarding") + gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{}) + t.Cleanup(func() { + gatewayForwardingSF.Forget("gateway_forwarding") + gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{}) + }) +} + +func TestSettingService_GetClaudeOAuthSystemPromptInjectionSettings(t *testing.T) { + t.Run("defaults to enabled with empty prompt", func(t *testing.T) { + resetGatewayForwardingSettingsCacheForTest(t) + svc := NewSettingService(&gatewayTTLSettingRepo{data: map[string]string{}}, &config.Config{}) + + enabled, prompt, blocks := svc.GetClaudeOAuthSystemPromptInjectionSettings(context.Background()) + + require.True(t, enabled) + require.Empty(t, prompt) + require.Empty(t, blocks) + }) + + t.Run("uses configured switch prompt and blocks", func(t *testing.T) { + resetGatewayForwardingSettingsCacheForTest(t) + const customPrompt = "custom prompt\n\nkeep spacing" + const customBlocks = `[{"type":"text","text":"custom block","cache_control":true}]` + svc := NewSettingService(&gatewayTTLSettingRepo{data: map[string]string{ + SettingKeyEnableClaudeOAuthSystemPromptInjection: "false", + SettingKeyClaudeOAuthSystemPrompt: customPrompt, + SettingKeyClaudeOAuthSystemPromptBlocks: customBlocks, + }}, &config.Config{}) + + enabled, prompt, blocks := svc.GetClaudeOAuthSystemPromptInjectionSettings(context.Background()) + + require.False(t, enabled) + require.Equal(t, customPrompt, prompt) + require.Equal(t, customBlocks, blocks) + }) +} diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 9721720213..7d7c0173c7 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -188,14 +188,17 @@ type SystemSettings struct { BackendModeEnabled bool // Gateway forwarding behavior - EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true) - EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false) - EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false) - EnableAnthropicCacheTTL1hInjection bool // 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false) - RewriteMessageCacheControl bool // 是否改写 messages[*].content[*].cache_control(默认 false) - AntigravityUserAgentVersion string // Antigravity 上游 User-Agent 版本号;空值使用配置/默认值 - OpenAICodexUserAgent string // OpenAI Codex 上游完整 User-Agent;空值使用内置默认 - OpenAIAllowClaudeCodeCodexPlugin bool // 全局开关:是否额外放行 Claude Code 的 Codex 插件(默认 false) + EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true) + EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false) + EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false) + EnableClaudeOAuthSystemPromptInjection bool // 是否对 Claude OAuth mimic 路径注入 Claude Code system blocks(默认 true) + ClaudeOAuthSystemPrompt string // Claude OAuth mimic 路径注入的通用扩展 system prompt;空值使用内置默认 + ClaudeOAuthSystemPromptBlocks string // Claude OAuth mimic 路径注入的 system blocks JSON 配置;空值使用内置默认 + EnableAnthropicCacheTTL1hInjection bool // 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false) + RewriteMessageCacheControl bool // 是否改写 messages[*].content[*].cache_control(默认 false) + AntigravityUserAgentVersion string // Antigravity 上游 User-Agent 版本号;空值使用配置/默认值 + OpenAICodexUserAgent string // OpenAI Codex 上游完整 User-Agent;空值使用内置默认 + OpenAIAllowClaudeCodeCodexPlugin bool // 全局开关:是否额外放行 Claude Code 的 Codex 插件(默认 false) // Web Search Emulation WebSearchEmulationEnabled bool // 是否启用 web search 模拟 diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 5be6307609..0a287a5caa 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -556,6 +556,9 @@ export interface SystemSettings { enable_fingerprint_unification: boolean; enable_metadata_passthrough: boolean; enable_cch_signing: boolean; + enable_claude_oauth_system_prompt_injection: boolean; + claude_oauth_system_prompt: string; + claude_oauth_system_prompt_blocks: string; enable_anthropic_cache_ttl_1h_injection: boolean; rewrite_message_cache_control: boolean; antigravity_user_agent_version: string; @@ -792,6 +795,9 @@ export interface UpdateSettingsRequest { enable_fingerprint_unification?: boolean; enable_metadata_passthrough?: boolean; enable_cch_signing?: boolean; + enable_claude_oauth_system_prompt_injection?: boolean; + claude_oauth_system_prompt?: string; + claude_oauth_system_prompt_blocks?: string; enable_anthropic_cache_ttl_1h_injection?: boolean; rewrite_message_cache_control?: boolean; antigravity_user_agent_version?: string; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index f71d62a19e..f3ea5ccbe8 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5712,6 +5712,30 @@ export default { metadataPassthroughHint: 'Pass through client\'s original metadata.user_id without rewriting. May improve upstream cache hit rates.', cchSigning: 'CCH Signing', cchSigningHint: 'Sign the billing header in forwarded requests with CCH hash. When disabled, the placeholder is preserved.', + claudeOAuthSystemPromptInjection: 'Claude OAuth System Blocks', + claudeOAuthSystemPromptInjectionHint: 'Inject Claude Code-like system blocks for Claude OAuth requests from non-Claude-Code clients. Enabled by default.', + claudeOAuthSystemPrompt: 'Claude OAuth Expansion Prompt', + claudeOAuthSystemPromptPlaceholder: 'Leave empty to use the built-in Claude Code expansion prompt.', + claudeOAuthSystemPromptHint: 'Legacy compatibility: controls only the third injected system block.', + claudeOAuthSystemPromptBlocks: 'Claude OAuth System Blocks', + claudeOAuthSystemPromptBlocksPlaceholder: 'Leave empty to use the built-in 3 blocks. Supports an array or {"blocks": [...]}.', + claudeOAuthSystemPromptBlocksHint: 'Each block is saved as JSON with enabled, type, text, and optional cache_control. {billing_header} stays dynamic per request; the Claude Code identity and expansion prompts can be edited directly or restored from presets.', + systemBlockTitle: 'System Block {index}', + systemBlockPreset: 'Preset', + systemBlockPresetBilling: 'Billing header', + systemBlockPresetIdentity: 'Claude Code identity', + systemBlockPresetExpansion: 'Claude Code expansion', + systemBlockPresetCustom: 'Custom', + systemBlockType: 'Type', + systemBlockTypeText: 'Text', + systemBlockText: 'Content', + systemBlockCacheControl: 'Cache control', + systemBlockHide: 'Hide block details', + systemBlockShow: 'Show block details', + addSystemBlock: 'Add block', + resetSystemBlocks: 'Reset defaults', + cacheTTL5m: '5 minutes', + cacheTTL1h: '1 hour', anthropicCacheTTL1hInjection: 'Anthropic Cache TTL Injection', anthropicCacheTTL1hInjectionHint: 'When enabled, existing ephemeral cache_control blocks in Anthropic OAuth/Setup Token request bodies are forced to 1h; response usage is billed back as 5m by default, with account-level TTL billing override taking priority.', rewriteMessageCacheControl: 'Rewrite Message Cache Breakpoints', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 996b38d6d9..4c589b2eee 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5866,6 +5866,30 @@ export default { metadataPassthroughHint: '透传客户端原始 metadata.user_id,不进行重写。可能提高上游缓存命中率。', cchSigning: 'CCH 签名', cchSigningHint: '对转发请求的 billing header 进行 CCH 哈希签名。关闭时保留原始占位符。', + claudeOAuthSystemPromptInjection: 'Claude OAuth System 注入', + claudeOAuthSystemPromptInjectionHint: '为非 Claude Code 客户端的 Claude OAuth 请求注入 Claude Code 形态的 system blocks。默认开启。', + claudeOAuthSystemPrompt: 'Claude OAuth 扩展提示词', + claudeOAuthSystemPromptPlaceholder: '留空时使用内置 Claude Code 扩展提示词。', + claudeOAuthSystemPromptHint: '兼容旧配置:仅控制第三个注入的 system block。', + claudeOAuthSystemPromptBlocks: 'Claude OAuth System Blocks', + claudeOAuthSystemPromptBlocksPlaceholder: '留空时使用内置 3 个 blocks。支持数组或 {"blocks": [...]}。', + claudeOAuthSystemPromptBlocksHint: '每个 block 会保存为带 enabled、type、text、可选 cache_control 的 JSON。{billing_header} 会按请求动态生成;Claude Code 身份提示词和扩展提示词可直接编辑,也可用预设恢复默认值。', + systemBlockTitle: 'System Block {index}', + systemBlockPreset: '预设', + systemBlockPresetBilling: 'Billing Header', + systemBlockPresetIdentity: 'Claude Code 身份提示词', + systemBlockPresetExpansion: 'Claude Code 扩展提示词', + systemBlockPresetCustom: '自定义', + systemBlockType: '类型', + systemBlockTypeText: '文本', + systemBlockText: '内容', + systemBlockCacheControl: 'Cache Control', + systemBlockHide: '隐藏 block 详情', + systemBlockShow: '展示 block 详情', + addSystemBlock: '添加 block', + resetSystemBlocks: '恢复默认', + cacheTTL5m: '5 分钟', + cacheTTL1h: '1 小时', anthropicCacheTTL1hInjection: 'Anthropic 缓存 TTL 注入', anthropicCacheTTL1hInjectionHint: '开启后,对 Anthropic OAuth/Setup Token 请求体中已有的 ephemeral 缓存块强制写入 1h;响应 usage 默认按 5m 回写计费,账号级 TTL 计费设置优先。', rewriteMessageCacheControl: '改写消息缓存断点', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index c3e0f2b965..1db5122062 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -3841,6 +3841,237 @@ + +
+
+ +

+ {{ + t( + "admin.settings.gatewayForwarding.claudeOAuthSystemPromptInjectionHint", + ) + }} +

+
+ +
+ +
+ +
+
+
+
+
+ {{ + t( + "admin.settings.gatewayForwarding.systemBlockTitle", + { index: index + 1 }, + ) + }} +
+
+ {{ getClaudeOAuthPresetLabel(block.preset) }} +
+
+
+ + + + + +
+
+ +
+
+
+ + +
+
+ +
+ +