mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
Merge pull request #3267 from codeQuest-fly/codex/fix-sub2api-injection
feat: configure Claude OAuth system prompt blocks
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 模拟
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '改写消息缓存断点',
|
||||
|
||||
@@ -3841,6 +3841,237 @@
|
||||
<Toggle v-model="form.enable_cch_signing" />
|
||||
</div>
|
||||
|
||||
<!-- Claude OAuth System Prompt Injection -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label
|
||||
class="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"admin.settings.gatewayForwarding.claudeOAuthSystemPromptInjection",
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.gatewayForwarding.claudeOAuthSystemPromptInjectionHint",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="form.enable_claude_oauth_system_prompt_injection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"admin.settings.gatewayForwarding.claudeOAuthSystemPromptBlocks",
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(block, index) in claudeOAuthSystemPromptBlocks"
|
||||
:key="block.id"
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800/60"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex flex-wrap items-center justify-between gap-3',
|
||||
block.expanded && 'mb-3',
|
||||
]"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="text-sm font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"admin.settings.gatewayForwarding.systemBlockTitle",
|
||||
{ index: index + 1 },
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
class="mt-0.5 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ getClaudeOAuthPresetLabel(block.preset) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm px-2"
|
||||
:title="
|
||||
block.expanded
|
||||
? t(
|
||||
'admin.settings.gatewayForwarding.systemBlockHide',
|
||||
)
|
||||
: t(
|
||||
'admin.settings.gatewayForwarding.systemBlockShow',
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
block.expanded
|
||||
? t(
|
||||
'admin.settings.gatewayForwarding.systemBlockHide',
|
||||
)
|
||||
: t(
|
||||
'admin.settings.gatewayForwarding.systemBlockShow',
|
||||
)
|
||||
"
|
||||
@click="toggleClaudeOAuthSystemPromptBlock(index)"
|
||||
>
|
||||
<Icon
|
||||
:name="block.expanded ? 'eyeOff' : 'eye'"
|
||||
size="xs"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm px-2"
|
||||
:disabled="index === 0"
|
||||
@click="moveClaudeOAuthSystemPromptBlock(index, -1)"
|
||||
>
|
||||
<Icon name="arrowUp" size="xs" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm px-2"
|
||||
:disabled="
|
||||
index === claudeOAuthSystemPromptBlocks.length - 1
|
||||
"
|
||||
@click="moveClaudeOAuthSystemPromptBlock(index, 1)"
|
||||
>
|
||||
<Icon name="arrowDown" size="xs" />
|
||||
</button>
|
||||
<Toggle v-model="block.enabled" />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm px-2 text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
@click="removeClaudeOAuthSystemPromptBlock(index)"
|
||||
>
|
||||
<Icon name="trash" size="xs" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="block.expanded">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"admin.settings.gatewayForwarding.systemBlockPreset",
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<Select
|
||||
v-model="block.preset"
|
||||
:options="claudeOAuthSystemPromptPresetOptions"
|
||||
@change="
|
||||
(value) =>
|
||||
applyClaudeOAuthSystemPromptPreset(index, value)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"admin.settings.gatewayForwarding.systemBlockType",
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<Select
|
||||
v-model="block.type"
|
||||
:options="claudeOAuthSystemPromptBlockTypeOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label
|
||||
class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.settings.gatewayForwarding.systemBlockText") }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="block.text"
|
||||
rows="6"
|
||||
class="input w-full resize-y font-mono text-xs leading-5"
|
||||
@input="markClaudeOAuthSystemPromptBlockCustom(block)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-3 grid gap-3 md:grid-cols-[minmax(0,1fr)_160px]"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<label
|
||||
class="text-xs font-medium text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"admin.settings.gatewayForwarding.systemBlockCacheControl",
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
<Toggle v-model="block.cacheControlEnabled" />
|
||||
</div>
|
||||
<div v-if="block.cacheControlEnabled">
|
||||
<Select
|
||||
v-model="block.cacheControlTTL"
|
||||
:options="claudeOAuthSystemPromptCacheTTLOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="addClaudeOAuthSystemPromptBlock"
|
||||
>
|
||||
<Icon name="plus" size="xs" />
|
||||
{{ t("admin.settings.gatewayForwarding.addSystemBlock") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="resetClaudeOAuthSystemPromptBlocks"
|
||||
>
|
||||
<Icon name="refresh" size="xs" />
|
||||
{{
|
||||
t("admin.settings.gatewayForwarding.resetSystemBlocks")
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t(
|
||||
"admin.settings.gatewayForwarding.claudeOAuthSystemPromptBlocksHint",
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Anthropic Cache TTL 1h Injection -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -6975,6 +7206,378 @@ function loginAgreementRoutePath(
|
||||
return `/legal/${id}`;
|
||||
}
|
||||
|
||||
type ClaudeOAuthSystemPromptPreset =
|
||||
| "billing"
|
||||
| "system"
|
||||
| "expansion"
|
||||
| "custom";
|
||||
|
||||
interface ClaudeOAuthSystemPromptBlock {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
expanded: boolean;
|
||||
type: "text";
|
||||
preset: ClaudeOAuthSystemPromptPreset;
|
||||
text: string;
|
||||
cacheControlEnabled: boolean;
|
||||
cacheControlTTL: string;
|
||||
}
|
||||
|
||||
interface ClaudeOAuthSystemPromptRawBlock {
|
||||
enabled?: boolean;
|
||||
type?: string;
|
||||
text?: string;
|
||||
cache_control?: unknown;
|
||||
}
|
||||
|
||||
const defaultClaudeCodeSystemPrompt =
|
||||
"You are Claude Code, Anthropic's official CLI for Claude.";
|
||||
|
||||
const defaultClaudeCodeExpansionPrompt = `You are an interactive agent that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
IMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.
|
||||
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
||||
|
||||
# Tone and style
|
||||
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||||
- Your responses should be short and concise.
|
||||
- When referencing specific functions or pieces of code include the pattern file_path:line_number to allow the user to easily navigate to the source code location.
|
||||
- When referencing GitHub issues or pull requests, use the owner/repo#123 format (e.g. anthropics/claude-code#100) so they render as clickable links.
|
||||
- Do not use a colon before tool calls. Your tool calls may not be shown directly in the output, so text like "Let me read the file:" followed by a read tool call should just be "Let me read the file." with a period.`;
|
||||
|
||||
let claudeOAuthSystemPromptBlockID = 0;
|
||||
|
||||
function nextClaudeOAuthSystemPromptBlockID(): string {
|
||||
claudeOAuthSystemPromptBlockID += 1;
|
||||
return `claude-oauth-system-prompt-block-${claudeOAuthSystemPromptBlockID}`;
|
||||
}
|
||||
|
||||
function normalizeClaudeOAuthSystemPromptCacheTTL(value: unknown): string {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : "5m";
|
||||
}
|
||||
|
||||
function detectClaudeOAuthSystemPromptPreset(
|
||||
text: string,
|
||||
): ClaudeOAuthSystemPromptPreset {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === "{billing_header}") {
|
||||
return "billing";
|
||||
}
|
||||
if (
|
||||
trimmed === "{claude_code_system_prompt}" ||
|
||||
trimmed === defaultClaudeCodeSystemPrompt
|
||||
) {
|
||||
return "system";
|
||||
}
|
||||
if (
|
||||
trimmed === "{claude_code_expansion_prompt}" ||
|
||||
trimmed === defaultClaudeCodeExpansionPrompt
|
||||
) {
|
||||
return "expansion";
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
function normalizeClaudeOAuthSystemPromptBlockText(
|
||||
text: string,
|
||||
expansionPrompt = "",
|
||||
): string {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === "{claude_code_system_prompt}") {
|
||||
return defaultClaudeCodeSystemPrompt;
|
||||
}
|
||||
if (trimmed === "{claude_code_expansion_prompt}") {
|
||||
return expansionPrompt.trim() || defaultClaudeCodeExpansionPrompt;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function createClaudeOAuthSystemPromptBlock(
|
||||
overrides: Partial<ClaudeOAuthSystemPromptBlock> = {},
|
||||
): ClaudeOAuthSystemPromptBlock {
|
||||
const text = overrides.text ?? "";
|
||||
return {
|
||||
id: nextClaudeOAuthSystemPromptBlockID(),
|
||||
enabled: overrides.enabled ?? true,
|
||||
expanded: overrides.expanded ?? true,
|
||||
type: "text",
|
||||
preset: overrides.preset ?? detectClaudeOAuthSystemPromptPreset(text),
|
||||
text,
|
||||
cacheControlEnabled: overrides.cacheControlEnabled ?? false,
|
||||
cacheControlTTL: overrides.cacheControlTTL ?? "5m",
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultClaudeOAuthSystemPromptBlocks(
|
||||
expansionPrompt = "",
|
||||
): ClaudeOAuthSystemPromptBlock[] {
|
||||
const normalizedExpansionPrompt = expansionPrompt.trim();
|
||||
const expansionText =
|
||||
normalizedExpansionPrompt || defaultClaudeCodeExpansionPrompt;
|
||||
|
||||
return [
|
||||
createClaudeOAuthSystemPromptBlock({
|
||||
preset: "billing",
|
||||
text: "{billing_header}",
|
||||
}),
|
||||
createClaudeOAuthSystemPromptBlock({
|
||||
preset: "system",
|
||||
text: defaultClaudeCodeSystemPrompt,
|
||||
}),
|
||||
createClaudeOAuthSystemPromptBlock({
|
||||
preset:
|
||||
expansionText === defaultClaudeCodeExpansionPrompt
|
||||
? "expansion"
|
||||
: "custom",
|
||||
text: expansionText,
|
||||
cacheControlEnabled: true,
|
||||
cacheControlTTL: "5m",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function parseClaudeOAuthSystemPromptCacheControl(cacheControl: unknown): {
|
||||
enabled: boolean;
|
||||
ttl: string;
|
||||
} {
|
||||
if (cacheControl === true) {
|
||||
return { enabled: true, ttl: "5m" };
|
||||
}
|
||||
if (
|
||||
cacheControl &&
|
||||
typeof cacheControl === "object" &&
|
||||
!Array.isArray(cacheControl)
|
||||
) {
|
||||
return {
|
||||
enabled: true,
|
||||
ttl: normalizeClaudeOAuthSystemPromptCacheTTL(
|
||||
(cacheControl as Record<string, unknown>).ttl,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { enabled: false, ttl: "5m" };
|
||||
}
|
||||
|
||||
function parseClaudeOAuthSystemPromptBlocks(
|
||||
raw: string,
|
||||
expansionPrompt = "",
|
||||
): ClaudeOAuthSystemPromptBlock[] {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return createDefaultClaudeOAuthSystemPromptBlocks(expansionPrompt);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as
|
||||
| ClaudeOAuthSystemPromptRawBlock[]
|
||||
| { blocks?: ClaudeOAuthSystemPromptRawBlock[] };
|
||||
const rawBlocks = Array.isArray(parsed)
|
||||
? parsed
|
||||
: Array.isArray(parsed.blocks)
|
||||
? parsed.blocks
|
||||
: [];
|
||||
|
||||
if (rawBlocks.length === 0) {
|
||||
return createDefaultClaudeOAuthSystemPromptBlocks(expansionPrompt);
|
||||
}
|
||||
|
||||
return rawBlocks.map((block) => {
|
||||
const cacheControl = parseClaudeOAuthSystemPromptCacheControl(
|
||||
block.cache_control,
|
||||
);
|
||||
const text = normalizeClaudeOAuthSystemPromptBlockText(
|
||||
typeof block.text === "string" ? block.text : "",
|
||||
expansionPrompt,
|
||||
);
|
||||
return createClaudeOAuthSystemPromptBlock({
|
||||
enabled: block.enabled !== false,
|
||||
type: "text",
|
||||
text,
|
||||
preset: detectClaudeOAuthSystemPromptPreset(text),
|
||||
cacheControlEnabled: cacheControl.enabled,
|
||||
cacheControlTTL: cacheControl.ttl,
|
||||
});
|
||||
});
|
||||
} catch (_error) {
|
||||
return createDefaultClaudeOAuthSystemPromptBlocks(expansionPrompt);
|
||||
}
|
||||
}
|
||||
|
||||
function serializeClaudeOAuthSystemPromptBlocksToJSON(
|
||||
blocks: ClaudeOAuthSystemPromptBlock[],
|
||||
): string {
|
||||
const source =
|
||||
blocks.length > 0
|
||||
? blocks
|
||||
: [
|
||||
createClaudeOAuthSystemPromptBlock({
|
||||
enabled: false,
|
||||
preset: "custom",
|
||||
text: "",
|
||||
}),
|
||||
];
|
||||
|
||||
const rawBlocks = source.map((block) => {
|
||||
const raw: ClaudeOAuthSystemPromptRawBlock = {
|
||||
enabled: block.enabled,
|
||||
type: block.type || "text",
|
||||
text: block.text,
|
||||
};
|
||||
if (block.cacheControlEnabled) {
|
||||
raw.cache_control = {
|
||||
type: "ephemeral",
|
||||
ttl: normalizeClaudeOAuthSystemPromptCacheTTL(block.cacheControlTTL),
|
||||
};
|
||||
}
|
||||
return raw;
|
||||
});
|
||||
|
||||
return JSON.stringify(rawBlocks, null, 2);
|
||||
}
|
||||
|
||||
const defaultClaudeOAuthSystemPromptBlocks =
|
||||
serializeClaudeOAuthSystemPromptBlocksToJSON(
|
||||
createDefaultClaudeOAuthSystemPromptBlocks(),
|
||||
);
|
||||
|
||||
const claudeOAuthSystemPromptBlocks = ref<ClaudeOAuthSystemPromptBlock[]>(
|
||||
createDefaultClaudeOAuthSystemPromptBlocks(),
|
||||
);
|
||||
|
||||
const claudeOAuthSystemPromptPresetOptions = computed(() => [
|
||||
{
|
||||
value: "billing",
|
||||
label: t("admin.settings.gatewayForwarding.systemBlockPresetBilling"),
|
||||
},
|
||||
{
|
||||
value: "system",
|
||||
label: t("admin.settings.gatewayForwarding.systemBlockPresetIdentity"),
|
||||
},
|
||||
{
|
||||
value: "expansion",
|
||||
label: t("admin.settings.gatewayForwarding.systemBlockPresetExpansion"),
|
||||
},
|
||||
{
|
||||
value: "custom",
|
||||
label: t("admin.settings.gatewayForwarding.systemBlockPresetCustom"),
|
||||
},
|
||||
]);
|
||||
|
||||
const claudeOAuthSystemPromptBlockTypeOptions = computed(() => [
|
||||
{
|
||||
value: "text",
|
||||
label: t("admin.settings.gatewayForwarding.systemBlockTypeText"),
|
||||
},
|
||||
]);
|
||||
|
||||
const claudeOAuthSystemPromptCacheTTLOptions = computed(() => [
|
||||
{ value: "5m", label: t("admin.settings.gatewayForwarding.cacheTTL5m") },
|
||||
{ value: "1h", label: t("admin.settings.gatewayForwarding.cacheTTL1h") },
|
||||
]);
|
||||
|
||||
function getClaudeOAuthPresetLabel(
|
||||
preset: ClaudeOAuthSystemPromptPreset,
|
||||
): string {
|
||||
return (
|
||||
claudeOAuthSystemPromptPresetOptions.value.find(
|
||||
(option) => option.value === preset,
|
||||
)?.label || t("admin.settings.gatewayForwarding.systemBlockPresetCustom")
|
||||
);
|
||||
}
|
||||
|
||||
function syncClaudeOAuthSystemPromptBlocksFormField(): void {
|
||||
form.claude_oauth_system_prompt_blocks =
|
||||
serializeClaudeOAuthSystemPromptBlocksToJSON(
|
||||
claudeOAuthSystemPromptBlocks.value,
|
||||
);
|
||||
}
|
||||
|
||||
function addClaudeOAuthSystemPromptBlock(): void {
|
||||
claudeOAuthSystemPromptBlocks.value.push(
|
||||
createClaudeOAuthSystemPromptBlock({
|
||||
expanded: true,
|
||||
preset: "custom",
|
||||
text: "",
|
||||
}),
|
||||
);
|
||||
syncClaudeOAuthSystemPromptBlocksFormField();
|
||||
}
|
||||
|
||||
function toggleClaudeOAuthSystemPromptBlock(index: number): void {
|
||||
const block = claudeOAuthSystemPromptBlocks.value[index];
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
block.expanded = !block.expanded;
|
||||
}
|
||||
|
||||
function removeClaudeOAuthSystemPromptBlock(index: number): void {
|
||||
claudeOAuthSystemPromptBlocks.value.splice(index, 1);
|
||||
syncClaudeOAuthSystemPromptBlocksFormField();
|
||||
}
|
||||
|
||||
function moveClaudeOAuthSystemPromptBlock(
|
||||
index: number,
|
||||
direction: -1 | 1,
|
||||
): void {
|
||||
const targetIndex = index + direction;
|
||||
if (
|
||||
targetIndex < 0 ||
|
||||
targetIndex >= claudeOAuthSystemPromptBlocks.value.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const blocks = claudeOAuthSystemPromptBlocks.value;
|
||||
const current = blocks[index];
|
||||
blocks[index] = blocks[targetIndex];
|
||||
blocks[targetIndex] = current;
|
||||
syncClaudeOAuthSystemPromptBlocksFormField();
|
||||
}
|
||||
|
||||
function applyClaudeOAuthSystemPromptPreset(
|
||||
index: number,
|
||||
value: string | number | boolean | null,
|
||||
): void {
|
||||
const block = claudeOAuthSystemPromptBlocks.value[index];
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
const preset = String(value || "custom") as ClaudeOAuthSystemPromptPreset;
|
||||
block.preset = preset;
|
||||
block.type = "text";
|
||||
if (preset === "billing") {
|
||||
block.text = "{billing_header}";
|
||||
block.cacheControlEnabled = false;
|
||||
block.cacheControlTTL = "5m";
|
||||
} else if (preset === "system") {
|
||||
block.text = defaultClaudeCodeSystemPrompt;
|
||||
block.cacheControlEnabled = false;
|
||||
block.cacheControlTTL = "5m";
|
||||
} else if (preset === "expansion") {
|
||||
block.text =
|
||||
form.claude_oauth_system_prompt.trim() ||
|
||||
defaultClaudeCodeExpansionPrompt;
|
||||
block.cacheControlEnabled = true;
|
||||
block.cacheControlTTL = "5m";
|
||||
}
|
||||
syncClaudeOAuthSystemPromptBlocksFormField();
|
||||
}
|
||||
|
||||
function markClaudeOAuthSystemPromptBlockCustom(
|
||||
block: ClaudeOAuthSystemPromptBlock,
|
||||
): void {
|
||||
block.preset = detectClaudeOAuthSystemPromptPreset(block.text);
|
||||
syncClaudeOAuthSystemPromptBlocksFormField();
|
||||
}
|
||||
|
||||
function resetClaudeOAuthSystemPromptBlocks(): void {
|
||||
claudeOAuthSystemPromptBlocks.value = createDefaultClaudeOAuthSystemPromptBlocks(
|
||||
form.claude_oauth_system_prompt,
|
||||
);
|
||||
syncClaudeOAuthSystemPromptBlocksFormField();
|
||||
}
|
||||
|
||||
|
||||
interface DefaultSubscriptionGroupOption {
|
||||
value: number;
|
||||
label: string;
|
||||
@@ -7200,6 +7803,9 @@ const form = reactive<SettingsForm>({
|
||||
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: defaultClaudeOAuthSystemPromptBlocks,
|
||||
enable_anthropic_cache_ttl_1h_injection: false,
|
||||
rewrite_message_cache_control: false,
|
||||
antigravity_user_agent_version: "",
|
||||
@@ -7823,6 +8429,15 @@ async function loadSettings() {
|
||||
(form as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
if (!form.claude_oauth_system_prompt_blocks?.trim()) {
|
||||
form.claude_oauth_system_prompt_blocks =
|
||||
defaultClaudeOAuthSystemPromptBlocks;
|
||||
}
|
||||
claudeOAuthSystemPromptBlocks.value = parseClaudeOAuthSystemPromptBlocks(
|
||||
form.claude_oauth_system_prompt_blocks,
|
||||
form.claude_oauth_system_prompt,
|
||||
);
|
||||
syncClaudeOAuthSystemPromptBlocksFormField();
|
||||
form.login_agreement_mode =
|
||||
settings.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
|
||||
form.login_agreement_updated_at =
|
||||
@@ -8152,6 +8767,12 @@ async function saveSettings() {
|
||||
form.wechat_connect_mobile_enabled,
|
||||
form.wechat_connect_mode,
|
||||
);
|
||||
const claudeOAuthSystemPromptBlocksJSON =
|
||||
serializeClaudeOAuthSystemPromptBlocksToJSON(
|
||||
claudeOAuthSystemPromptBlocks.value,
|
||||
);
|
||||
form.claude_oauth_system_prompt_blocks =
|
||||
claudeOAuthSystemPromptBlocksJSON;
|
||||
|
||||
const payload: UpdateSettingsRequest = {
|
||||
registration_enabled: form.registration_enabled,
|
||||
@@ -8305,6 +8926,12 @@ async function saveSettings() {
|
||||
enable_fingerprint_unification: form.enable_fingerprint_unification,
|
||||
enable_metadata_passthrough: form.enable_metadata_passthrough,
|
||||
enable_cch_signing: form.enable_cch_signing,
|
||||
enable_claude_oauth_system_prompt_injection:
|
||||
form.enable_claude_oauth_system_prompt_injection,
|
||||
claude_oauth_system_prompt: form.claude_oauth_system_prompt?.trim()
|
||||
? form.claude_oauth_system_prompt
|
||||
: "",
|
||||
claude_oauth_system_prompt_blocks: claudeOAuthSystemPromptBlocksJSON,
|
||||
enable_anthropic_cache_ttl_1h_injection:
|
||||
form.enable_anthropic_cache_ttl_1h_injection,
|
||||
rewrite_message_cache_control: form.rewrite_message_cache_control,
|
||||
|
||||
@@ -378,6 +378,9 @@ const baseSettingsResponse = {
|
||||
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: "",
|
||||
@@ -642,6 +645,42 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("submits Claude OAuth system prompt injection gateway settings", async () => {
|
||||
const blocks = `[{"type":"text","text":"custom block","cache_control":true}]`;
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
enable_claude_oauth_system_prompt_injection: false,
|
||||
claude_oauth_system_prompt_blocks: blocks,
|
||||
});
|
||||
|
||||
const wrapper = mountView();
|
||||
|
||||
await flushPromises();
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledTimes(1);
|
||||
expect(updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enable_claude_oauth_system_prompt_injection: false,
|
||||
}),
|
||||
);
|
||||
const payload = updateSettings.mock.calls[0][0] as {
|
||||
claude_oauth_system_prompt_blocks: string;
|
||||
};
|
||||
expect(JSON.parse(payload.claude_oauth_system_prompt_blocks)).toEqual([
|
||||
{
|
||||
enabled: true,
|
||||
type: "text",
|
||||
text: "custom block",
|
||||
cache_control: {
|
||||
type: "ephemeral",
|
||||
ttl: "5m",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("submits Antigravity user agent version gateway setting", async () => {
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
|
||||
Reference in New Issue
Block a user