From 106948e775bca7293c9e50aa9acc56d4b119c034 Mon Sep 17 00:00:00 2001 From: Claude Code via ted Date: Tue, 23 Jun 2026 22:08:58 +0800 Subject: [PATCH 1/5] feat: tighten profile pool defaults --- README.md | 2 +- .../handler/admin/account_codex_import.go | 20 ++- backend/internal/handler/gateway_handler.go | 2 +- backend/internal/handler/gateway_helper.go | 24 ++- .../handler/gateway_helper_hotpath_test.go | 6 +- backend/internal/pkg/ctxkey/ctxkey.go | 4 + backend/internal/server/api_contract_test.go | 4 +- backend/internal/service/account.go | 35 +++-- .../account_test_service_openai_test.go | 11 +- backend/internal/service/admin_service.go | 28 +++- .../admin_service_profile_pool_test.go | 21 +++ .../internal/service/claude_code_validator.go | 14 ++ .../service/codex_environment_profile.go | 5 +- backend/internal/service/domain_constants.go | 2 +- .../service/environment_profile_pool.go | 141 +++++++++++++++++- .../service/environment_profile_pool_test.go | 14 ++ .../service/environment_profile_test.go | 50 ++++++- .../internal/service/gateway_prompt_test.go | 11 ++ backend/internal/service/gateway_service.go | 11 +- backend/internal/service/setting_service.go | 12 +- ...service_claude_oauth_system_prompt_test.go | 4 +- backend/internal/service/settings_view.go | 2 +- .../components/account/AccountUsageCell.vue | 8 +- .../components/account/CreateAccountModal.vue | 5 +- .../components/account/EditAccountModal.vue | 13 +- .../account/EnvironmentProfileCard.vue | 38 ----- .../__tests__/EnvironmentProfileCard.spec.ts | 59 ++++++++ frontend/src/components/layout/AppSidebar.vue | 4 +- frontend/src/i18n/locales/en.ts | 11 +- frontend/src/i18n/locales/zh.ts | 11 +- frontend/src/router/__tests__/guards.spec.ts | 7 +- frontend/src/router/index.ts | 3 - frontend/src/types/index.ts | 2 +- frontend/src/views/admin/AccountsView.vue | 21 ++- frontend/src/views/admin/SettingsView.vue | 2 +- .../AccountsView.usageWindowsHint.spec.ts | 48 +++++- .../admin/__tests__/SettingsView.spec.ts | 2 +- 37 files changed, 517 insertions(+), 140 deletions(-) create mode 100644 frontend/src/components/account/__tests__/EnvironmentProfileCard.spec.ts diff --git a/README.md b/README.md index 11816646a..859409b31 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ RUN_MODE=standard - Claude OAuth / Setup Token 账号支持 `claude_environment_profile_pool`,并兼容旧 `claude_environment_profile`。 - OpenAI OAuth / Codex 账号支持 `codex_environment_profile_pool`,并兼容旧 `codex_environment_profile`。 -- 一个账号的可用槽位数来自账号 `concurrency`;`concurrency=5` 表示最多 5 个并发 Profile 槽位。 +- 一个账号的可用槽位数优先来自账号等级,支持 Claude `pro/max5/max20` 与 Codex `plus/pro5/pro20/team` 自动映射到 5/10/20;也可用 `environment_profile_manual_capacity` 手动覆盖,最后回退到 `concurrency`。 - 请求按 linux / windows / macos / desktop 环境绑定槽位;同环境请求优先复用匹配槽位,空槽首次绑定后不自动改绑。 - 当前凭据冷却、限流或槽位耗尽时,调度可切换到下一个可用凭据的匹配环境槽位。 - 管理员可在账号 UI 中查看、重置和锁定 Profile 池。 diff --git a/backend/internal/handler/admin/account_codex_import.go b/backend/internal/handler/admin/account_codex_import.go index c5fcfd1dd..951ffe550 100644 --- a/backend/internal/handler/admin/account_codex_import.go +++ b/backend/internal/handler/admin/account_codex_import.go @@ -163,8 +163,9 @@ func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessi if req.UpdateExisting != nil { updateExisting = *req.UpdateExisting } - concurrency := 3 - if req.Concurrency != nil { + manualConcurrency := req.Concurrency != nil + concurrency := 0 + if manualConcurrency { concurrency = *req.Concurrency } priority := 50 @@ -217,6 +218,17 @@ func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessi } credentials := mergeCodexImportMap(item.Credentials, credentialExtras) extra := mergeCodexImportMap(req.Extra, item.Extra) + itemConcurrency := concurrency + if !manualConcurrency { + itemConcurrency = service.AccountEnvironmentProfileCapacity(&service.Account{ + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: credentials, + Extra: extra, + Concurrency: concurrency, + }) + } + for _, warning := range item.WarningTexts { result.Warnings = append(result.Warnings, CodexSessionImportMessage{ Index: entry.Index, @@ -249,7 +261,7 @@ func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessi updateInput := &service.UpdateAccountInput{ Credentials: mergedCredentials, Extra: mergedExtra, - Concurrency: req.Concurrency, + Concurrency: &itemConcurrency, Priority: req.Priority, RateMultiplier: req.RateMultiplier, LoadFactor: req.LoadFactor, @@ -306,7 +318,7 @@ func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessi Credentials: credentials, Extra: extra, ProxyID: req.ProxyID, - Concurrency: concurrency, + Concurrency: itemConcurrency, Priority: priority, RateMultiplier: req.RateMultiplier, LoadFactor: req.LoadFactor, diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 9a38bccb2..f52c39dd7 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -179,7 +179,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { // 检查是否为 Claude Code 客户端,设置到 context 中(复用已解析请求,避免二次反序列化)。 SetClaudeCodeClientContext(c, body, parsedReq) - isClaudeCodeClient := service.IsClaudeCodeClient(c.Request.Context()) + isClaudeCodeClient := isClaudeCodeOrGenericEntrypoint(c) // 版本检查:仅对 Claude Code 客户端,拒绝低于最低版本的请求 if !h.checkClaudeCodeVersion(c) { diff --git a/backend/internal/handler/gateway_helper.go b/backend/internal/handler/gateway_helper.go index 96c8ef534..070575842 100644 --- a/backend/internal/handler/gateway_helper.go +++ b/backend/internal/handler/gateway_helper.go @@ -19,17 +19,24 @@ import ( // claudeCodeValidator is a singleton validator for Claude Code client detection var claudeCodeValidator = service.NewClaudeCodeValidator() +func isClaudeCodeOrGenericEntrypoint(c *gin.Context) bool { + if c == nil || c.Request == nil { + return false + } + return service.IsClaudeCodeClient(c.Request.Context()) || service.IsGenericClaudeEntrypoint(c.Request.Context()) +} + // SetClaudeCodeClientContext 检查请求是否来自 Claude Code 客户端,并设置到 context 中 // 返回更新后的 context -func normalizeClaudeGoHTTPEntrypointHeaders(c *gin.Context) { +func normalizeClaudeGoHTTPEntrypointHeaders(c *gin.Context) bool { if c == nil || c.Request == nil { - return + return false } if !isGoHTTPDefaultUserAgent(c.GetHeader("User-Agent")) { - return + return false } if !strings.Contains(c.Request.URL.Path, "messages") { - return + return false } for key, value := range claude.GetHeaders(nil) { value = strings.TrimSpace(value) @@ -40,6 +47,7 @@ func normalizeClaudeGoHTTPEntrypointHeaders(c *gin.Context) { } c.Request.Header.Set("anthropic-version", "2023-06-01") c.Request.Header.Set("anthropic-beta", claude.DefaultBetaHeader) + return true } func isGoHTTPDefaultUserAgent(ua string) bool { @@ -51,7 +59,13 @@ func SetClaudeCodeClientContext(c *gin.Context, body []byte, parsedReq *service. if c == nil || c.Request == nil { return } - normalizeClaudeGoHTTPEntrypointHeaders(c) + genericEntrypoint := normalizeClaudeGoHTTPEntrypointHeaders(c) + if genericEntrypoint { + ctx := service.SetClaudeCodeClient(c.Request.Context(), false) + ctx = service.SetGenericClaudeEntrypoint(ctx, true) + c.Request = c.Request.WithContext(ctx) + return + } ua := c.GetHeader("User-Agent") // Fast path:非 Claude CLI UA 直接判定 false,避免热路径二次 JSON 反序列化。 if !claudeCodeValidator.ValidateUserAgent(ua) { diff --git a/backend/internal/handler/gateway_helper_hotpath_test.go b/backend/internal/handler/gateway_helper_hotpath_test.go index 157e58914..2d1727637 100644 --- a/backend/internal/handler/gateway_helper_hotpath_test.go +++ b/backend/internal/handler/gateway_helper_hotpath_test.go @@ -196,13 +196,15 @@ func TestSetClaudeCodeClientContext_FastPathAndStrictPath(t *testing.T) { require.False(t, service.IsClaudeCodeClient(c.Request.Context())) }) - t.Run("go_http_probe_headers_normalize_to_claude_code", func(t *testing.T) { + t.Run("go_http_probe_headers_normalize_without_real_claude_code_trust", func(t *testing.T) { c, _ := newHelperTestContext(http.MethodPost, "/v1/messages") c.Request.Header.Set("User-Agent", "Go-http-client/1.1") SetClaudeCodeClientContext(c, validClaudeCodeBodyJSON(), nil) - require.True(t, service.IsClaudeCodeClient(c.Request.Context())) + require.False(t, service.IsClaudeCodeClient(c.Request.Context())) + require.True(t, service.IsGenericClaudeEntrypoint(c.Request.Context())) + require.True(t, isClaudeCodeOrGenericEntrypoint(c)) require.NotEqual(t, "Go-http-client/1.1", c.Request.Header.Get("User-Agent")) require.Contains(t, c.Request.Header.Get("User-Agent"), "claude-cli/") require.NotEmpty(t, c.Request.Header.Get("X-App")) diff --git a/backend/internal/pkg/ctxkey/ctxkey.go b/backend/internal/pkg/ctxkey/ctxkey.go index dacd1bd1c..3363ddb87 100644 --- a/backend/internal/pkg/ctxkey/ctxkey.go +++ b/backend/internal/pkg/ctxkey/ctxkey.go @@ -32,6 +32,10 @@ const ( // IsClaudeCodeClient 标识当前请求是否来自 Claude Code 客户端 IsClaudeCodeClient Key = "ctx_is_claude_code_client" + // IsGenericClaudeEntrypoint marks a generic HTTP client entrypoint whose headers were normalized + // for Claude compatibility, but must not be trusted as a real Claude Code client. + IsGenericClaudeEntrypoint Key = "ctx_is_generic_claude_entrypoint" + // ThinkingEnabled 标识当前请求是否开启 thinking(用于 Antigravity 最终模型名推导与模型维度限流) ThinkingEnabled Key = "ctx_thinking_enabled" diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index ce015a1ac..072c8ba63 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -835,7 +835,7 @@ 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, + "enable_claude_oauth_system_prompt_injection": false, "claude_oauth_system_prompt": "", "claude_oauth_system_prompt_blocks": "", "enable_anthropic_cache_ttl_1h_injection": false, @@ -1080,7 +1080,7 @@ func TestAPIContracts(t *testing.T) { "enable_fingerprint_unification": true, "enable_metadata_passthrough": false, "enable_cch_signing": false, - "enable_claude_oauth_system_prompt_injection": true, + "enable_claude_oauth_system_prompt_injection": false, "claude_oauth_system_prompt": "", "claude_oauth_system_prompt_blocks": "", "enable_anthropic_cache_ttl_1h_injection": false, diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 678ad9acb..6bfbd04aa 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1541,14 +1541,11 @@ func (a *Account) GetClaudeEnvironmentProfile() (*ClaudeEnvironmentProfile, bool } func (a *Account) AllowClaudeDesktopEnvironmentLearn() bool { - if a == nil || !a.IsAnthropicOAuthOrSetupToken() { + if a == nil || !a.IsAnthropicOAuthOrSetupToken() || a.Extra == nil { return false } - if a.Extra == nil { - return true - } allowed, ok := a.Extra[claudeEnvironmentAllowDesktopLearnKey].(bool) - return !ok || allowed + return ok && allowed } func (a *Account) ClaudeEnvironmentFamilyPreference() string { @@ -1587,17 +1584,14 @@ func (a *Account) GetCodexEnvironmentProfile() (*CodexEnvironmentProfile, bool) } func (a *Account) AllowCodexOfficialClientEnvironmentLearn() bool { - if a == nil || !a.IsOpenAIOAuth() { + if a == nil || !a.IsOpenAIOAuth() || a.Extra == nil { return false } - if a.Extra == nil { - return true - } if allowed, ok := a.Extra[codexEnvironmentAllowOfficialClientLearnKey].(bool); ok { return allowed } allowed, ok := a.Extra[codexEnvironmentAllowOfficialClientLearnLegacyKey].(bool) - return !ok || allowed + return ok && allowed } func (a *Account) CodexEnvironmentFamilyPreference() string { @@ -1611,6 +1605,27 @@ func (a *Account) CodexEnvironmentFamilyPreference() string { return strings.TrimSpace(preference) } +func (a *Account) ClaudeAccountTier() string { + return normalizeClaudeAccountTier(firstEnvironmentProfileTierValue(a, []string{ + "claude_account_tier", + "claude_plan_type", + "plan_type", + "subscription_tier", + "tier", + })) +} + +func (a *Account) CodexAccountTier() string { + return normalizeCodexAccountTier(firstEnvironmentProfileTierValue(a, []string{ + "codex_account_tier", + "codex_plan_type", + "plan_type", + "chatgpt_plan_type", + "subscription_tier", + "tier", + })) +} + // IsCodexCLIOnlyEnabled 返回 OpenAI OAuth 账号是否启用"仅允许 Codex 官方客户端"。 // 字段:accounts.extra.codex_cli_only。 // 字段缺失或类型不正确时,按 false(关闭)处理。 diff --git a/backend/internal/service/account_test_service_openai_test.go b/backend/internal/service/account_test_service_openai_test.go index 910567fb2..0f33b7ec7 100644 --- a/backend/internal/service/account_test_service_openai_test.go +++ b/backend/internal/service/account_test_service_openai_test.go @@ -73,6 +73,15 @@ type openAIAccountTestRepo struct { setErrorMsg string } +func (r *openAIAccountTestRepo) GetByID(_ context.Context, id int64) (*Account, error) { + if r.accountsByID != nil { + if account, ok := r.accountsByID[id]; ok { + return account, nil + } + } + return nil, ErrAccountNotFound +} + func (r *openAIAccountTestRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error { r.updatedExtra = updates return nil @@ -225,7 +234,7 @@ func TestAccountTestService_OpenAI429BodyOnlyPersistsRateLimitAndClearsStaleErro require.Equal(t, StatusActive, account.Status) require.Empty(t, account.ErrorMessage) require.NotNil(t, account.RateLimitResetAt) - require.Empty(t, repo.updatedExtra) + require.Contains(t, repo.updatedExtra, codexEnvironmentProfilePoolKey) } func TestAccountTestService_OpenAI429SyncsObservedPlanType(t *testing.T) { diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index b31192c11..45ca8fd0a 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -2581,7 +2581,7 @@ func (s *adminServiceImpl) GetAccountsByIDs(ctx context.Context, ids []int64) ([ return accounts, nil } -func withDefaultEnvironmentProfilePool(platform, accountType string, concurrency int, extra map[string]any) map[string]any { +func withDefaultEnvironmentProfilePool(platform, accountType string, concurrency int, credentials, extra map[string]any) map[string]any { if !shouldCreateDefaultEnvironmentProfilePool(platform, accountType, extra) { return extra } @@ -2589,11 +2589,18 @@ func withDefaultEnvironmentProfilePool(platform, accountType string, concurrency for key, value := range extra { merged[key] = value } + capacity := environmentProfileCapacity(&Account{ + Platform: platform, + Type: accountType, + Credentials: credentials, + Extra: merged, + Concurrency: concurrency, + }) if platform == PlatformAnthropic { - merged[claudeEnvironmentProfilePoolKey] = newClaudeEnvironmentProfilePool(concurrency) + merged[claudeEnvironmentProfilePoolKey] = newClaudeEnvironmentProfilePool(capacity) return merged } - merged[codexEnvironmentProfilePoolKey] = newCodexEnvironmentProfilePool(concurrency) + merged[codexEnvironmentProfilePoolKey] = newCodexEnvironmentProfilePool(capacity) return merged } @@ -2652,15 +2659,26 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou } } + concurrency := input.Concurrency + if concurrency <= 0 && shouldCreateDefaultEnvironmentProfilePool(input.Platform, input.Type, input.Extra) { + concurrency = environmentProfileCapacity(&Account{ + Platform: input.Platform, + Type: input.Type, + Credentials: input.Credentials, + Extra: input.Extra, + Concurrency: input.Concurrency, + }) + } + account := &Account{ Name: input.Name, Notes: normalizeAccountNotes(input.Notes), Platform: input.Platform, Type: input.Type, Credentials: input.Credentials, - Extra: withDefaultEnvironmentProfilePool(input.Platform, input.Type, input.Concurrency, input.Extra), + Extra: withDefaultEnvironmentProfilePool(input.Platform, input.Type, concurrency, input.Credentials, input.Extra), ProxyID: input.ProxyID, - Concurrency: input.Concurrency, + Concurrency: concurrency, Priority: input.Priority, Status: StatusActive, Schedulable: true, diff --git a/backend/internal/service/admin_service_profile_pool_test.go b/backend/internal/service/admin_service_profile_pool_test.go index 1c85bc07b..e590bf850 100644 --- a/backend/internal/service/admin_service_profile_pool_test.go +++ b/backend/internal/service/admin_service_profile_pool_test.go @@ -76,6 +76,27 @@ func TestAdminServiceCreateAccountDefaultEnvironmentProfilePool(t *testing.T) { require.NotContains(t, account.Extra, codexEnvironmentProfileLockedKey) }) + t.Run("openai oauth codex tier sets default pool capacity", func(t *testing.T) { + repo := &createAccountProfilePoolRepo{} + svc := &adminServiceImpl{accountRepo: repo} + + account, err := svc.CreateAccount(context.Background(), &CreateAccountInput{ + Name: "codex-pro20", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{"access_token": "token", "plan_type": "pro20"}, + Concurrency: 3, + SkipDefaultGroupBind: true, + }) + + require.NoError(t, err) + pool, err := DecodeCodexEnvironmentProfilePool(account.Extra[codexEnvironmentProfilePoolKey]) + require.NoError(t, err) + require.NotNil(t, pool) + require.Equal(t, 20, pool.Capacity) + require.Len(t, pool.Slots, 20) + }) + t.Run("preserves explicit disabled single environment", func(t *testing.T) { repo := &createAccountProfilePoolRepo{} svc := &adminServiceImpl{accountRepo: repo} diff --git a/backend/internal/service/claude_code_validator.go b/backend/internal/service/claude_code_validator.go index 03b0a8281..4ba354556 100644 --- a/backend/internal/service/claude_code_validator.go +++ b/backend/internal/service/claude_code_validator.go @@ -298,6 +298,20 @@ func SetClaudeCodeClient(ctx context.Context, isClaudeCode bool) context.Context return context.WithValue(ctx, ctxkey.IsClaudeCodeClient, isClaudeCode) } +// IsGenericClaudeEntrypoint returns true when a generic HTTP client request was normalized +// to Claude-compatible headers but should still take the OAuth mimicry path. +func IsGenericClaudeEntrypoint(ctx context.Context) bool { + if v, ok := ctx.Value(ctxkey.IsGenericClaudeEntrypoint).(bool); ok { + return v + } + return false +} + +// SetGenericClaudeEntrypoint marks a normalized generic HTTP entrypoint request. +func SetGenericClaudeEntrypoint(ctx context.Context, enabled bool) context.Context { + return context.WithValue(ctx, ctxkey.IsGenericClaudeEntrypoint, enabled) +} + // ExtractVersion 从 User-Agent 中提取 Claude Code 版本号 // 返回 "2.1.22" 形式的版本号,如果不匹配返回空字符串 func (v *ClaudeCodeValidator) ExtractVersion(ua string) string { diff --git a/backend/internal/service/codex_environment_profile.go b/backend/internal/service/codex_environment_profile.go index 50e489df0..f42b598c5 100644 --- a/backend/internal/service/codex_environment_profile.go +++ b/backend/internal/service/codex_environment_profile.go @@ -32,7 +32,6 @@ const ( CodexClientFamilyCLI CodexClientFamily = "cli" CodexClientFamilyDesktop CodexClientFamily = "desktop" CodexClientFamilyVSCode CodexClientFamily = "vscode" - CodexClientFamilyCustom CodexClientFamily = "custom" ) type CodexEnvironmentProfile struct { @@ -349,8 +348,6 @@ func normalizeCodexClientFamily(family CodexClientFamily) CodexClientFamily { return CodexClientFamilyDesktop case CodexClientFamilyVSCode: return CodexClientFamilyVSCode - case CodexClientFamilyCustom: - return CodexClientFamilyCustom default: return "" } @@ -362,7 +359,7 @@ func normalizeCodexProfileFamilyPreference(preference string) (string, error) { return "auto", nil } switch preference { - case "auto", string(CodexClientFamilyCLI), string(CodexClientFamilyDesktop), string(CodexClientFamilyVSCode), string(CodexClientFamilyCustom): + case "auto", string(CodexClientFamilyCLI), string(CodexClientFamilyDesktop), string(CodexClientFamilyVSCode): return preference, nil default: return "", fmt.Errorf("invalid codex profile family preference") diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index c6b697e37..db8e372d6 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -423,7 +423,7 @@ 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 是否对 Claude OAuth mimic 路径注入 Claude Code system blocks(默认 false) SettingKeyEnableClaudeOAuthSystemPromptInjection = "enable_claude_oauth_system_prompt_injection" // SettingKeyClaudeOAuthSystemPrompt Claude OAuth mimic 路径注入的通用扩展 system prompt(空值使用内置默认) SettingKeyClaudeOAuthSystemPrompt = "claude_oauth_system_prompt" diff --git a/backend/internal/service/environment_profile_pool.go b/backend/internal/service/environment_profile_pool.go index b509fc872..92d69c37c 100644 --- a/backend/internal/service/environment_profile_pool.go +++ b/backend/internal/service/environment_profile_pool.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "fmt" "io" "net/http" @@ -217,11 +218,147 @@ func normalizeEnvironmentClass(env EnvironmentClass) EnvironmentClass { } } +// AccountEnvironmentProfileCapacity returns the fixed profile slot capacity. +// safe for concurrent calls: it only reads immutable account fields. +func AccountEnvironmentProfileCapacity(account *Account) int { + return environmentProfileCapacity(account) +} + func environmentProfileCapacity(account *Account) int { - if account == nil || account.Concurrency <= 0 { + if account == nil { return 1 } - return account.Concurrency + if capacity := parseEnvironmentProfileCapacity(account.Extra[environmentProfileManualCapacityKey]); capacity > 0 { + return capacity + } + if capacity := accountTierEnvironmentProfileCapacity(account); capacity > 0 { + return capacity + } + if account.Concurrency > 0 { + return account.Concurrency + } + return 1 +} + +const environmentProfileManualCapacityKey = "environment_profile_manual_capacity" + +func accountTierEnvironmentProfileCapacity(account *Account) int { + if account == nil { + return 0 + } + switch account.Platform { + case PlatformAnthropic: + return claudeTierEnvironmentProfileCapacity(account.ClaudeAccountTier()) + case PlatformOpenAI: + return codexTierEnvironmentProfileCapacity(account.CodexAccountTier()) + default: + return 0 + } +} + +func claudeTierEnvironmentProfileCapacity(tier string) int { + switch normalizeClaudeAccountTier(tier) { + case "pro": + return 5 + case "max5": + return 10 + case "max20": + return 20 + default: + return 0 + } +} + +func codexTierEnvironmentProfileCapacity(tier string) int { + switch normalizeCodexAccountTier(tier) { + case "plus": + return 5 + case "pro5": + return 10 + case "pro20", "team": + return 20 + default: + return 0 + } +} + +func firstEnvironmentProfileTierValue(account *Account, keys []string) string { + if account == nil { + return "" + } + for _, key := range keys { + if value := strings.TrimSpace(account.GetCredential(key)); value != "" { + return value + } + } + for _, key := range keys { + if value := strings.TrimSpace(account.GetExtraString(key)); value != "" { + return value + } + } + return "" +} + +func normalizeClaudeAccountTier(raw string) string { + value := normalizeEnvironmentProfileTierToken(raw) + switch { + case strings.Contains(value, "max20") || strings.Contains(value, "maxx20") || strings.Contains(value, "20x"): + return "max20" + case strings.Contains(value, "max5") || strings.Contains(value, "maxx5") || strings.Contains(value, "5x"): + return "max5" + case strings.Contains(value, "pro"): + return "pro" + default: + return "" + } +} + +func normalizeCodexAccountTier(raw string) string { + value := normalizeEnvironmentProfileTierToken(raw) + switch { + case strings.Contains(value, "team"): + return "team" + case strings.Contains(value, "pro20") || strings.Contains(value, "20x"): + return "pro20" + case strings.Contains(value, "pro5") || strings.Contains(value, "5x"): + return "pro5" + case strings.Contains(value, "plus"): + return "plus" + default: + return "" + } +} + +func normalizeEnvironmentProfileTierToken(raw string) string { + value := strings.ToLower(strings.TrimSpace(raw)) + replacer := strings.NewReplacer(" ", "", "-", "", "_", "", ".", "") + return replacer.Replace(value) +} + +func parseEnvironmentProfileCapacity(raw any) int { + switch v := raw.(type) { + case int: + if v > 0 { + return v + } + case int64: + if v > 0 { + return int(v) + } + case float64: + if v > 0 { + return int(v) + } + case json.Number: + if n, err := v.Int64(); err == nil && n > 0 { + return int(n) + } + case string: + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { + return n + } + } + return 0 } func DetectClaudeEnvironmentClass(headers http.Header, body []byte) EnvironmentClass { diff --git a/backend/internal/service/environment_profile_pool_test.go b/backend/internal/service/environment_profile_pool_test.go index 54bbf8873..997557642 100644 --- a/backend/internal/service/environment_profile_pool_test.go +++ b/backend/internal/service/environment_profile_pool_test.go @@ -40,6 +40,20 @@ func TestDetectEnvironmentClass(t *testing.T) { } } +func TestEnvironmentProfileCapacityUsesTierAndManualOverride(t *testing.T) { + manual := &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth, Concurrency: 5, Extra: map[string]any{environmentProfileManualCapacityKey: 12}} + require.Equal(t, 12, environmentProfileCapacity(manual)) + + claudeMax20 := &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth, Concurrency: 5, Credentials: map[string]any{"plan_type": "Max 20"}, Extra: map[string]any{}} + require.Equal(t, 20, environmentProfileCapacity(claudeMax20)) + + codexPro5 := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 3, Credentials: map[string]any{"plan_type": "pro_5"}, Extra: map[string]any{}} + require.Equal(t, 10, environmentProfileCapacity(codexPro5)) + + codexPlus := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 3, Credentials: map[string]any{"plan_type": "plus"}, Extra: map[string]any{}} + require.Equal(t, 5, environmentProfileCapacity(codexPlus)) +} + func TestClaudeEnvironmentProfilePoolBindsFiveWindowsSlots(t *testing.T) { account := &Account{ID: 701, Platform: PlatformAnthropic, Type: AccountTypeOAuth, Concurrency: 5, Extra: map[string]any{}} pool, err := getOrCreateClaudeEnvironmentProfilePool(account) diff --git a/backend/internal/service/environment_profile_test.go b/backend/internal/service/environment_profile_test.go index 7e980c9c5..19e28c98d 100644 --- a/backend/internal/service/environment_profile_test.go +++ b/backend/internal/service/environment_profile_test.go @@ -125,7 +125,9 @@ func TestClaudeEnvironmentProfileLearnsDesktopFirstRequest(t *testing.T) { ID: 102, Platform: PlatformAnthropic, Type: AccountTypeSetupToken, - Extra: map[string]any{}, + Extra: map[string]any{ + claudeEnvironmentAllowDesktopLearnKey: true, + }, } repo := &environmentProfileAccountRepo{account: account} svc := &GatewayService{accountRepo: repo} @@ -148,6 +150,52 @@ func TestClaudeEnvironmentProfileLearnsDesktopFirstRequest(t *testing.T) { require.Equal(t, 1, repo.updateCount()) } +func TestClaudeEnvironmentProfileDefaultsToFixedCodeCLI(t *testing.T) { + account := &Account{ + ID: 104, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Extra: map[string]any{}, + } + repo := &environmentProfileAccountRepo{account: account} + svc := &GatewayService{accountRepo: repo} + headers := http.Header{ + "User-Agent": []string{"Claude Desktop/1.0 Electron"}, + "X-App": []string{"claude-desktop"}, + "Anthropic-Client-Type": []string{"desktop"}, + "Anthropic-Client-Id": []string{"client-fixed"}, + "Anthropic-Client-Device-Id": []string{"device-fixed"}, + } + + profile, err := svc.getOrCreateClaudeEnvironmentProfile(context.Background(), account, headers, nil) + require.NoError(t, err) + require.NotNil(t, profile) + require.Equal(t, ClaudeClientFamilyCodeCLI, profile.Family) + require.Equal(t, claudeEnvironmentProfileSourceAutoDefault, profile.Source) + require.NotEqual(t, "client-fixed", profile.ClientID) +} + +func TestCodexEnvironmentProfileDefaultsToFixedCLI(t *testing.T) { + account := &Account{ + ID: 202, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{}, + } + repo := &environmentProfileAccountRepo{account: account} + svc := &OpenAIGatewayService{accountRepo: repo} + headers := http.Header{ + "User-Agent": []string{"Codex Desktop/1.0"}, + "originator": []string{"codex_chatgpt_desktop"}, + } + + profile, err := svc.getOrCreateCodexEnvironmentProfile(context.Background(), account, headers) + require.NoError(t, err) + require.NotNil(t, profile) + require.Equal(t, CodexClientFamilyCLI, profile.Family) + require.Equal(t, "auto_default", profile.Source) +} + func TestClaudeEnvironmentProfileSkipsGenericDesktopLearning(t *testing.T) { account := &Account{ ID: 103, diff --git a/backend/internal/service/gateway_prompt_test.go b/backend/internal/service/gateway_prompt_test.go index 4321f1778..e18af0dde 100644 --- a/backend/internal/service/gateway_prompt_test.go +++ b/backend/internal/service/gateway_prompt_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "encoding/json" "strings" "testing" @@ -86,6 +87,16 @@ func TestIsClaudeCodeClient(t *testing.T) { } } +func TestShouldTreatAsRealClaudeCodeClientRejectsGenericEntrypoint(t *testing.T) { + legacyUserID := "user_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6_account_550e8400-e29b-41d4-a716-446655440000_session_123e4567-e89b-12d3-a456-426614174000" + ctx := SetClaudeCodeClient(context.Background(), true) + ctx = SetGenericClaudeEntrypoint(ctx, true) + + got := shouldTreatAsRealClaudeCodeClient(ctx, "claude-cli/2.1.161 (external, cli)", legacyUserID) + + require.False(t, got) +} + func TestSystemIncludesClaudeCodePrompt(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 9a26f7ad0..e4b431c0e 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -4121,6 +4121,13 @@ func isClaudeCodeClient(userAgent string, metadataUserID string) bool { return ParseMetadataUserID(metadataUserID) != nil } +func shouldTreatAsRealClaudeCodeClient(ctx context.Context, userAgent string, metadataUserID string) bool { + if IsGenericClaudeEntrypoint(ctx) { + return false + } + return IsClaudeCodeClient(ctx) || isClaudeCodeClient(userAgent, metadataUserID) +} + // normalizeSystemParam 将 json.RawMessage 类型的 system 参数转为标准 Go 类型(string / []any / nil), // 避免 type switch 中 json.RawMessage(底层 []byte)无法匹配 case string / case []any / case nil 的问题。 // 这是 Go 的 typed nil 陷阱:(json.RawMessage, nil) ≠ (nil, nil)。 @@ -4860,7 +4867,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A // 最低缓存门槛,导致系统级缓存失效)。 // // 对于非 Claude Code 的第三方客户端(opencode 等),仍然走完整 mimicry。 - isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID) + isClaudeCode := shouldTreatAsRealClaudeCodeClient(ctx, c.GetHeader("User-Agent"), parsed.MetadataUserID) shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode if shouldMimicClaudeCode { @@ -9865,7 +9872,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, return err } - isClaudeCodeCT := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID) + isClaudeCodeCT := shouldTreatAsRealClaudeCodeClient(ctx, c.GetHeader("User-Agent"), parsed.MetadataUserID) shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCodeCT if shouldMimicClaudeCode { diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index e96b90ac2..146b5c44b 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -2464,12 +2464,12 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) fingerprintUnification: true, metadataPassthrough: false, cchSigning: false, - claudeOAuthSystemPromptInjection: true, + claudeOAuthSystemPromptInjection: false, anthropicCacheTTL1hInjection: false, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(), }) - return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl()}, nil + return gatewayForwardingSettingsResult{fp: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl()}, nil } fp := true if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" { @@ -2477,7 +2477,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) } mp := values[SettingKeyEnableMetadataPassthrough] == "true" cch := values[SettingKeyEnableCCHSigning] == "true" - systemPromptInjection := true + systemPromptInjection := false if v, ok := values[SettingKeyEnableClaudeOAuthSystemPromptInjection]; ok && v != "" { systemPromptInjection = v == "true" } @@ -2513,7 +2513,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) if r, ok := val.(gatewayForwardingSettingsResult); ok { return r } - return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true} + return gatewayForwardingSettingsResult{fp: true} } // GetGatewayForwardingSettings returns cached gateway forwarding settings. @@ -3547,7 +3547,7 @@ 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, claude_oauth_system_prompt_injection=true) + // cch_signing=false, claude_oauth_system_prompt_injection=false) if v, ok := settings[SettingKeyEnableFingerprintUnification]; ok && v != "" { result.EnableFingerprintUnification = v == "true" } else { @@ -3558,7 +3558,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin if v, ok := settings[SettingKeyEnableClaudeOAuthSystemPromptInjection]; ok && v != "" { result.EnableClaudeOAuthSystemPromptInjection = v == "true" } else { - result.EnableClaudeOAuthSystemPromptInjection = true + result.EnableClaudeOAuthSystemPromptInjection = false } result.ClaudeOAuthSystemPrompt = settings[SettingKeyClaudeOAuthSystemPrompt] result.ClaudeOAuthSystemPromptBlocks = settings[SettingKeyClaudeOAuthSystemPromptBlocks] 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 index d4a10787e..927cbc9ad 100644 --- a/backend/internal/service/setting_service_claude_oauth_system_prompt_test.go +++ b/backend/internal/service/setting_service_claude_oauth_system_prompt_test.go @@ -19,13 +19,13 @@ func resetGatewayForwardingSettingsCacheForTest(t *testing.T) { } func TestSettingService_GetClaudeOAuthSystemPromptInjectionSettings(t *testing.T) { - t.Run("defaults to enabled with empty prompt", func(t *testing.T) { + t.Run("defaults to disabled 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.False(t, enabled) require.Empty(t, prompt) require.Empty(t, blocks) }) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index fc4f27b28..077b2ea4b 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -193,7 +193,7 @@ type SystemSettings struct { EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true) EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false) EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false) - EnableClaudeOAuthSystemPromptInjection bool // 是否对 Claude OAuth mimic 路径注入 Claude Code system blocks(默认 true) + EnableClaudeOAuthSystemPromptInjection bool // 是否对 Claude OAuth mimic 路径注入 Claude Code system blocks(默认 false) 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) diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 156b4e249..47ccf2800 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -68,14 +68,8 @@ color="purple" /> - +
- - {{ t('admin.accounts.usageWindow.passiveSampled') }} -
- + { if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title') diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 88d9e94ab..0c520a381 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -2344,9 +2344,8 @@ - + { @@ -2626,11 +2623,11 @@ const codexCLIOnlyEnabled = ref(false) const codexCLIOnlyAllowClaudeCodeEnabled = ref(false) const claudeEnvironmentSingleEnabled = ref(true) const claudeEnvironmentProfileLocked = ref(true) -const claudeEnvironmentAllowDesktopLearn = ref(true) +const claudeEnvironmentAllowDesktopLearn = ref(false) const claudeEnvironmentFamilyPreference = ref('auto') const codexEnvironmentSingleEnabled = ref(true) const codexEnvironmentProfileLocked = ref(true) -const codexEnvironmentAllowOfficialClientLearn = ref(true) +const codexEnvironmentAllowOfficialClientLearn = ref(false) const codexEnvironmentFamilyPreference = ref('auto') const profileResetting = ref(false) type CodexImageGenerationBridgeMode = 'inherit' | 'enabled' | 'disabled' @@ -3015,11 +3012,11 @@ const syncFormFromAccount = (newAccount: Account | null) => { webSearchEmulationMode.value = 'default' claudeEnvironmentSingleEnabled.value = extra?.claude_single_environment !== false claudeEnvironmentProfileLocked.value = extra?.claude_environment_profile_locked !== false - claudeEnvironmentAllowDesktopLearn.value = extra?.claude_environment_allow_desktop_learn !== false + claudeEnvironmentAllowDesktopLearn.value = extra?.claude_environment_allow_desktop_learn === true claudeEnvironmentFamilyPreference.value = typeof extra?.claude_environment_profile_family_preference === 'string' ? extra.claude_environment_profile_family_preference : 'auto' codexEnvironmentSingleEnabled.value = extra?.codex_single_environment !== false codexEnvironmentProfileLocked.value = extra?.codex_environment_profile_locked !== false - codexEnvironmentAllowOfficialClientLearn.value = extra?.codex_environment_allow_official_client_learn !== false + codexEnvironmentAllowOfficialClientLearn.value = extra?.codex_environment_allow_official_client_learn === true codexEnvironmentFamilyPreference.value = typeof extra?.codex_environment_profile_family_preference === 'string' ? extra.codex_environment_profile_family_preference : 'auto' if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) { openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true diff --git a/frontend/src/components/account/EnvironmentProfileCard.vue b/frontend/src/components/account/EnvironmentProfileCard.vue index fcb7c257f..22a576b9a 100644 --- a/frontend/src/components/account/EnvironmentProfileCard.vue +++ b/frontend/src/components/account/EnvironmentProfileCard.vue @@ -99,21 +99,6 @@ > {{ t('admin.accounts.environmentProfile.poolStatus', { count: boundSlotCount, capacity: poolCapacity }) }} -
-
-
- {{ slot.title }} - {{ slot.state }} -
-
- {{ slot.detail }} -
-
-
(() => { { value: 'vscode', label: t('admin.accounts.environmentProfile.codexVSCode') - }, - { - value: 'custom', - label: t('admin.accounts.environmentProfile.codexCustom') } ] }) @@ -283,25 +264,6 @@ const poolSlots = computed(() => (Array.isArray(props.pool?.slots) ? props.pool. const hasPoolSlots = computed(() => poolSlots.value.length > 0) const poolCapacity = computed(() => props.pool?.capacity ?? poolSlots.value.length) const boundSlotCount = computed(() => poolSlots.value.filter((slot) => slot.state === 'bound').length) - -const slotRows = computed(() => - poolSlots.value.map((slot) => { - const profile = slot.profile - const family = profile && typeof profile === 'object' ? stringifyValue(profile.family) : '-' - const source = profile && typeof profile === 'object' ? stringifyValue(profile.source) : '-' - const platform = profile && typeof profile === 'object' ? stringifyValue(profile.platform) : '-' - return { - key: `${slot.slot}-${slot.environment}`, - title: t('admin.accounts.environmentProfile.slotTitle', { - slot: slot.slot + 1, - environment: slot.environment - }), - state: slot.state, - detail: `${family} · ${source} · ${platform}` - } - }) -) - const profileRows = computed(() => { const profile = props.profile if (!profile) return [] diff --git a/frontend/src/components/account/__tests__/EnvironmentProfileCard.spec.ts b/frontend/src/components/account/__tests__/EnvironmentProfileCard.spec.ts new file mode 100644 index 000000000..7d00414d3 --- /dev/null +++ b/frontend/src/components/account/__tests__/EnvironmentProfileCard.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +import EnvironmentProfileCard from '../EnvironmentProfileCard.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, params?: Record) => { + if (key === 'admin.accounts.environmentProfile.poolStatus') { + return `Profile pool: ${params?.count}/${params?.capacity} bound` + } + return key + } + }) +})) + +describe('EnvironmentProfileCard', () => { + it('shows profile pool summary without slot details', () => { + const wrapper = mount(EnvironmentProfileCard, { + props: { + family: 'claude', + pool: { + capacity: 2, + slots: [ + { + slot: 0, + environment: 'linux', + state: 'bound', + profile: { + family: 'code_cli', + source: 'learned_verified_desktop', + user_agent: 'claude-cli/2.1.0', + client_id: 'client', + device_id: 'device', + session_seed: 'seed', + platform: 'linux', + arch: 'x64' + } + }, + { + slot: 1, + environment: 'windows', + state: 'empty' + } + ] + }, + singleEnvironment: true, + locked: true, + allowLearn: false, + familyPreference: 'auto' + } + }) + + expect(wrapper.text()).toContain('Profile pool: 1/2 bound') + expect(wrapper.text()).not.toContain('Slot 1') + expect(wrapper.text()).not.toContain('learned_verified_desktop') + expect(wrapper.text()).not.toContain('code_cli ·') + }) +}) diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 48ecff990..c1d891702 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -719,16 +719,14 @@ const adminNavItems = computed((): NavItem[] => { { path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon }, { path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon, featureFlag: flagOpsMonitoring }, { path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true }, - { path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true }, + { path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon }, { path: '/admin/channels', label: t('nav.channelManagement'), icon: ChannelIcon, - hideInSimpleMode: true, expandOnly: true, children: [ { path: '/admin/channels/pricing', label: t('nav.channelPricing'), icon: PriceTagIcon }, - { path: '/admin/channels/monitor', label: t('nav.channelMonitor'), icon: SignalIcon, featureFlag: flagChannelMonitor }, ], }, { path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 4236e9a97..afc4bf927 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3266,7 +3266,7 @@ export default { codexTitle: 'Codex environment profile pool', codexDesc: 'OAuth Codex traffic uses request-environment slots; reset the pool to rebind a slot.', singleEnvironment: 'Environment profile pool', - singleEnvironmentDesc: 'When enabled, account concurrency becomes the available profile-slot capacity.', + singleEnvironmentDesc: 'When enabled, account tier automatically selects 5/10/20 profile slots; environment_profile_manual_capacity can override it.', familyPreference: 'Profile family', familyAuto: 'Auto', claudeCodeCLI: 'Claude Code CLI', @@ -3274,12 +3274,11 @@ export default { codexCLI: 'Codex CLI', codexDesktop: 'Codex Desktop', codexVSCode: 'Codex VSCode', - codexCustom: 'Custom', locked: 'Profile locked', - allowDesktopLearn: 'Allow Desktop first-learn', - allowDesktopLearnDesc: 'Only applies while the Claude slot is unbound.', - allowOfficialClientLearn: 'Allow official client first-learn', - allowOfficialClientLearnDesc: 'Only applies while the Codex slot is unbound.', + allowDesktopLearn: 'Allow Desktop learning', + allowDesktopLearnDesc: 'Default off. When enabled, only standard Claude Desktop can write a fixed profile.', + allowOfficialClientLearn: 'Allow official client learning', + allowOfficialClientLearnDesc: 'Default off. When enabled, only standard Codex CLI/Desktop/VSCode can write a fixed profile.', status: 'Current profile', poolStatus: 'Profile pool: {count}/{capacity} bound', slotTitle: 'Slot {slot} · {environment}', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index f82899fa7..502ebaf7d 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3395,7 +3395,7 @@ export default { codexTitle: 'Codex 环境 Profile 池', codexDesc: 'OpenAI OAuth/Codex 流量按请求环境使用并发槽位;槽位绑定后需重置才会重新绑定。', singleEnvironment: '环境 Profile 池', - singleEnvironmentDesc: '开启后按账号并发数提供 Profile 槽位,同环境请求优先复用匹配槽位。', + singleEnvironmentDesc: '开启后按账户等级自动使用 5/10/20 个 Profile 槽位;可用 environment_profile_manual_capacity 手动覆盖。', familyPreference: 'Profile 家族', familyAuto: '自动', claudeCodeCLI: 'Claude Code CLI', @@ -3403,12 +3403,11 @@ export default { codexCLI: 'Codex CLI', codexDesktop: 'Codex Desktop', codexVSCode: 'Codex VSCode', - codexCustom: '自定义', locked: '锁定 Profile', - allowDesktopLearn: '允许首次学习 Desktop', - allowDesktopLearnDesc: '仅在 Claude 槽位尚未绑定时生效。', - allowOfficialClientLearn: '允许首次学习官方客户端', - allowOfficialClientLearnDesc: '仅在 Codex 槽位尚未绑定时生效。', + allowDesktopLearn: '允许学习 Desktop', + allowDesktopLearnDesc: '默认关闭。开启后仅允许标准 Claude Desktop 写入固定 Profile。', + allowOfficialClientLearn: '允许学习官方客户端', + allowOfficialClientLearnDesc: '默认关闭。开启后仅允许标准 Codex CLI/Desktop/VSCode 写入固定 Profile。', status: '当前 Profile', poolStatus: 'Profile 池:{count}/{capacity} 已绑定', slotTitle: '槽位 {slot} · {environment}', diff --git a/frontend/src/router/__tests__/guards.spec.ts b/frontend/src/router/__tests__/guards.spec.ts index 84f9ff348..f864d6726 100644 --- a/frontend/src/router/__tests__/guards.spec.ts +++ b/frontend/src/router/__tests__/guards.spec.ts @@ -118,10 +118,8 @@ function simulateGuard( if (authState.isSimpleMode) { const restrictedPaths = [ '/admin/announcements', - '/admin/groups', '/admin/subscriptions', '/admin/redeem', - '/admin/channels/pricing', '/admin/channels/monitor', '/admin/risk-control', '/admin/affiliates', @@ -299,7 +297,7 @@ describe('路由守卫逻辑', () => { expect(redirect).toBe('/dashboard') }) - it('管理员简易模式访问 /admin/groups 重定向到 /admin/dashboard', () => { + it('管理员简易模式可访问 /admin/groups', () => { const authState: MockAuthState = { isAuthenticated: true, isAdmin: true, @@ -308,7 +306,7 @@ describe('路由守卫逻辑', () => { hasPendingAuthSession: false, } const redirect = simulateGuard('/admin/groups', { requiresAdmin: true }, authState) - expect(redirect).toBe('/admin/dashboard') + expect(redirect).toBeNull() }) it('管理员简易模式访问 /admin/subscriptions 重定向', () => { @@ -352,7 +350,6 @@ describe('路由守卫逻辑', () => { hasPendingAuthSession: false, } for (const path of [ - '/admin/channels/pricing', '/admin/channels/monitor', '/admin/risk-control', '/admin/affiliates/invites', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index d0c5d5aff..ba5aeddcd 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -844,11 +844,8 @@ router.beforeEach(async (to, _from, next) => { if (authStore.isSimpleMode) { const restrictedPaths = [ '/admin/announcements', - '/admin/groups', '/admin/subscriptions', '/admin/redeem', - '/admin/channels/pricing', - '/admin/channels/monitor', '/admin/risk-control', '/admin/affiliates', '/subscriptions', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 56bc80fe5..6075109b8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -818,7 +818,7 @@ export interface TempUnschedulableStatus { } export type ClaudeClientFamily = 'code_cli' | 'desktop' -export type CodexClientFamily = 'cli' | 'desktop' | 'vscode' | 'custom' +export type CodexClientFamily = 'cli' | 'desktop' | 'vscode' export interface ClaudeEnvironmentProfile { family: ClaudeClientFamily diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index acd736ff3..11a64ca40 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -239,6 +239,12 @@ > {{ getAntigravityTierLabel(row) }} + + {{ t('admin.accounts.usageWindow.passiveSampled') }} +
([]) const groups = ref([]) @@ -1054,6 +1058,13 @@ const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn( { immediate: false } ) +function hasPassiveUsageSample(row: Account): boolean { + if (row.platform !== 'anthropic' || (row.type !== 'oauth' && row.type !== 'setup-token')) return false + const extra = row.extra as Record | undefined + if (!extra) return false + return typeof extra.passive_usage_sampled_at === 'string' +} + // Antigravity 订阅等级辅助函数 function getAntigravityTierFromRow(row: any): string | null { if (row.platform !== 'antigravity') return null @@ -1145,11 +1156,9 @@ const allColumns = computed(() => { { key: 'capacity', label: t('admin.accounts.columns.capacity'), sortable: false }, { key: 'status', label: t('admin.accounts.columns.status'), sortable: true }, { key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true }, - { key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false } + { key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false }, + { key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false } ] - if (!authStore.isSimpleMode) { - c.push({ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false }) - } c.push( { key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false }, { key: 'proxy', label: t('admin.accounts.columns.proxy'), sortable: false }, diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index dccaf7da0..6169c6e79 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -7921,7 +7921,7 @@ const form = reactive({ enable_fingerprint_unification: true, enable_metadata_passthrough: false, enable_cch_signing: false, - enable_claude_oauth_system_prompt_injection: true, + enable_claude_oauth_system_prompt_injection: false, claude_oauth_system_prompt: "", claude_oauth_system_prompt_blocks: defaultClaudeOAuthSystemPromptBlocks, enable_anthropic_cache_ttl_1h_injection: false, diff --git a/frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts index 81e7d87e0..f8944a79b 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts @@ -61,7 +61,7 @@ vi.mock('vue-i18n', async () => { } }) -// Render the per-column header slots so we can assert the usage-window header hint. +// Render the relevant DataTable slots so we can assert column-level content. const DataTableStub = { props: ['columns', 'data'], template: ` @@ -71,6 +71,9 @@ const DataTableStub = {
+
+ +
` } @@ -161,4 +164,47 @@ describe('admin AccountsView usage windows hint', () => { expect(hint.exists()).toBe(true) expect(hint.text()).toBe('admin.accounts.usageWindowsHint') }) + + it('renders passive sampling in the platform type column', async () => { + listAccounts.mockResolvedValue({ + items: [ + { + id: 42, + name: 'claude-pro', + platform: 'anthropic', + type: 'oauth', + credentials: {}, + extra: { passive_usage_sampled_at: '2026-06-23T00:00:00Z' }, + proxy_id: null, + concurrency: 5, + priority: 1, + status: 'active', + error_message: null, + last_used_at: null, + expires_at: null, + auto_pause_on_expired: true, + created_at: '2026-06-23T00:00:00Z', + updated_at: '2026-06-23T00:00:00Z', + schedulable: true, + rate_limited_at: null, + rate_limit_reset_at: null, + overload_until: null, + temp_unschedulable_until: null, + temp_unschedulable_reason: null, + session_window_start: null + } + ], + total: 1, + page: 1, + page_size: 20, + pages: 1 + }) + + const wrapper = mountView() + await flushPromises() + + const row = wrapper.find('[data-test="account-row"]') + expect(row.exists()).toBe(true) + expect(row.text()).toContain('admin.accounts.usageWindow.passiveSampled') + }) }) diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index e00f83fa6..7df63b493 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -391,7 +391,7 @@ const baseSettingsResponse = { enable_fingerprint_unification: true, enable_metadata_passthrough: false, enable_cch_signing: false, - enable_claude_oauth_system_prompt_injection: true, + enable_claude_oauth_system_prompt_injection: false, claude_oauth_system_prompt: "", claude_oauth_system_prompt_blocks: "", enable_anthropic_cache_ttl_1h_injection: false, From 316ed7b80b96c825111947a4e68ca2af3c2e102a Mon Sep 17 00:00:00 2001 From: Claude Code via ted Date: Tue, 23 Jun 2026 23:04:24 +0800 Subject: [PATCH 2/5] feat: freeze claude env profile to 3 OS slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R4.2 升级:凭证级 3 OS 槽位冻结式 profile pool(windows/macos/linux), 对应 claude-pro-3 封号根因(同凭证 71 分钟暴露 4 个 device_id + 7 套 anthropic-beta + UA/beta 版本不自洽)。 - pool schema v2:固定 3 槽位,每槽模拟生成冻结 device_id/client_id/ cli_version/beta_set,终身不变;desktop 归并 windows - 预生成(新凭证落库)+ 懒生成(未绑定 pool 凭证首次请求) - 透传路径(mimic=false)也强制重写 metadata.user_id.device_id 为槽位 固定值,与 enableMPT 解耦;不再透传客户端 device_id - 透传路径 anthropic-beta 按槽位 BetaSet 归一,不透传客户端 beta, 消除 UA=2.1.161 却声称 2.1.186 beta 的版本不自洽 - 解除并发与槽位互斥:v2 槽位为共享身份,并发请求复用同槽 (5 个 windows 请求都走 windows 槽),lease 串行锁不再占用 - 学习链路彻底移除:删除 learnClaudeCodeHeaderProfile,source 只剩 simulated;旧 claude_code_header_profile 复用读取保留(旧账号回退) - 旧账号不改动:旧 schema / 旧 profile 回退现有逻辑,不读写不覆盖 - 新增 8 个单元测试 + 1 个并发复用测试;全仓测试通过 --- backend/internal/service/admin_service.go | 2 +- .../service/claude_code_header_profile.go | 57 ------ .../claude_code_header_profile_test.go | 50 ----- .../service/claude_environment_profile.go | 3 + .../claude_environment_profile_pool.go | 176 +++++++++++++++++ .../claude_environment_profile_pool_test.go | 180 ++++++++++++++++++ .../service/claude_jsonl_replay_test.go | 5 +- .../service/environment_profile_pool.go | 35 ++++ backend/internal/service/gateway_service.go | 49 ++++- 9 files changed, 440 insertions(+), 117 deletions(-) create mode 100644 backend/internal/service/claude_environment_profile_pool_test.go diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 45ca8fd0a..62b56c8b9 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -2597,7 +2597,7 @@ func withDefaultEnvironmentProfilePool(platform, accountType string, concurrency Concurrency: concurrency, }) if platform == PlatformAnthropic { - merged[claudeEnvironmentProfilePoolKey] = newClaudeEnvironmentProfilePool(capacity) + merged[claudeEnvironmentProfilePoolKey] = newFrozenClaudeEnvironmentProfilePool(claude.CLICurrentVersion) return merged } merged[codexEnvironmentProfilePoolKey] = newCodexEnvironmentProfilePool(capacity) diff --git a/backend/internal/service/claude_code_header_profile.go b/backend/internal/service/claude_code_header_profile.go index bc17fee7e..ad78b6dee 100644 --- a/backend/internal/service/claude_code_header_profile.go +++ b/backend/internal/service/claude_code_header_profile.go @@ -1,7 +1,6 @@ package service import ( - "context" "encoding/json" "log/slog" "net/http" @@ -41,22 +40,6 @@ var sensitiveHeaderKeywords = []string{ "api-key", } -func filterClaudeCodeHeaderProfile(headers http.Header) map[string]string { - filtered := make(map[string]string) - for key, values := range headers { - canonicalKey := strings.ToLower(strings.TrimSpace(key)) - if canonicalKey == "" || isSensitiveClaudeCodeHeader(canonicalKey) || !isClaudeCodeHeaderAllowed(canonicalKey) { - continue - } - value := strings.TrimSpace(firstNonEmptyHeaderValue(values)) - if value == "" { - continue - } - filtered[canonicalKey] = value - } - return filtered -} - func isSensitiveClaudeCodeHeader(key string) bool { lowerKey := strings.ToLower(strings.TrimSpace(key)) for _, keyword := range sensitiveHeaderKeywords { @@ -83,46 +66,6 @@ func firstNonEmptyHeaderValue(values []string) string { return "" } -func (s *GatewayService) learnClaudeCodeHeaderProfile(ctx context.Context, account *Account, headers http.Header) { - if s == nil || s.accountRepo == nil || account == nil || !account.IsAnthropicOAuthOrSetupToken() { - return - } - filteredHeaders := filterClaudeCodeHeaderProfile(headers) - if len(filteredHeaders) == 0 { - return - } - profile := ClaudeCodeHeaderProfile{ - Headers: filteredHeaders, - LearnedFrom: "real_claude_code_request", - UpdatedAt: time.Now().UTC(), - ClientVersion: ExtractCLIVersion(headers.Get("User-Agent")), - ClientFamily: "claude-cli", - } - encoded, err := json.Marshal(profile) - if err != nil { - slog.Warn("claude_code_header_profile_marshal_failed", "account_id", account.ID, "error", err) - return - } - if len(encoded) > claudeCodeHeaderProfileMaxSize { - slog.Warn("claude_code_header_profile_too_large", "account_id", account.ID, "size", len(encoded)) - return - } - if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{claudeCodeHeaderProfileKey: profile}); err != nil { - slog.Warn("claude_code_header_profile_update_failed", "account_id", account.ID, "error", err) - return - } - if account.Extra == nil { - account.Extra = make(map[string]any, 1) - } - account.Extra[claudeCodeHeaderProfileKey] = profile - slog.Info("claude_code_header_profile_learned", - "account_id", account.ID, - "account_name", account.Name, - "client_version", profile.ClientVersion, - "headers_count", len(profile.Headers), - ) -} - func (s *GatewayService) getClaudeCodeHeaderProfile(account *Account) *ClaudeCodeHeaderProfile { if account == nil || account.Extra == nil { return nil diff --git a/backend/internal/service/claude_code_header_profile_test.go b/backend/internal/service/claude_code_header_profile_test.go index f502bd1a1..f77f97d59 100644 --- a/backend/internal/service/claude_code_header_profile_test.go +++ b/backend/internal/service/claude_code_header_profile_test.go @@ -1,7 +1,6 @@ package service import ( - "context" "net/http" "testing" "time" @@ -9,55 +8,6 @@ import ( "github.com/stretchr/testify/require" ) -type claudeCodeHeaderProfileAccountRepo struct { - AccountRepository - updates map[string]any -} - -func (r *claudeCodeHeaderProfileAccountRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error { - r.updates = updates - return nil -} - -func TestFilterClaudeCodeHeaderProfileWhitelistAndSensitive(t *testing.T) { - headers := http.Header{} - headers.Set("User-Agent", "claude-cli/2.1.22") - headers.Set("X-App", "claude-cli") - headers.Set("Anthropic-Client-Sha", "abc123") - headers.Set("Authorization", "Bearer secret") - headers.Set("Cookie", "session=secret") - headers.Set("X-Api-Key", "secret") - headers.Set("X-Other", "ignored") - - got := filterClaudeCodeHeaderProfile(headers) - - require.Equal(t, map[string]string{ - "user-agent": "claude-cli/2.1.22", - "x-app": "claude-cli", - "anthropic-client-sha": "abc123", - }, got) -} - -func TestLearnClaudeCodeHeaderProfilePersistsOAuthAccount(t *testing.T) { - repo := &claudeCodeHeaderProfileAccountRepo{} - svc := &GatewayService{accountRepo: repo} - account := &Account{ID: 42, Name: "claude", Platform: PlatformAnthropic, Type: AccountTypeOAuth} - headers := http.Header{} - headers.Set("User-Agent", "claude-cli/2.1.22") - headers.Set("X-App", "claude-cli") - headers.Set("Authorization", "Bearer secret") - - svc.learnClaudeCodeHeaderProfile(context.Background(), account, headers) - - raw, ok := repo.updates[claudeCodeHeaderProfileKey] - require.True(t, ok) - profile, ok := raw.(ClaudeCodeHeaderProfile) - require.True(t, ok) - require.Equal(t, "claude-cli/2.1.22", profile.Headers["user-agent"]) - require.NotContains(t, profile.Headers, "authorization") - require.Equal(t, profile, account.Extra[claudeCodeHeaderProfileKey]) -} - func TestGetClaudeCodeHeaderProfileRejectsExpiredProfile(t *testing.T) { svc := &GatewayService{} account := &Account{Extra: map[string]any{ diff --git a/backend/internal/service/claude_environment_profile.go b/backend/internal/service/claude_environment_profile.go index ab47cc8a5..33f23699a 100644 --- a/backend/internal/service/claude_environment_profile.go +++ b/backend/internal/service/claude_environment_profile.go @@ -24,6 +24,7 @@ const ( claudeEnvironmentProfileSourceAutoDefault = "auto_default" claudeEnvironmentProfileSourceLearnedDesktop = "learned_verified_desktop" claudeEnvironmentProfileSourceAdmin = "admin" + claudeEnvironmentProfileSourceSimulated = "simulated" ) type ClaudeClientFamily string @@ -49,6 +50,8 @@ type ClaudeEnvironmentProfile struct { RuntimeVersion string `json:"runtime_version"` ClientType string `json:"client_type"` Headers map[string]string `json:"headers"` + BetaSet []string `json:"beta_set,omitempty"` + FrozenAt time.Time `json:"frozen_at,omitempty"` TelemetryPolicy string `json:"telemetry_policy"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/backend/internal/service/claude_environment_profile_pool.go b/backend/internal/service/claude_environment_profile_pool.go index 8fa56a904..f65db7147 100644 --- a/backend/internal/service/claude_environment_profile_pool.go +++ b/backend/internal/service/claude_environment_profile_pool.go @@ -4,14 +4,23 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "net/http" "strings" "sync" "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/google/uuid" ) const claudeEnvironmentProfilePoolKey = "claude_environment_profile_pool" +// claudeEnvironmentProfilePoolSchemaV2 是 3 OS 槽位冻结式 pool 的 schema 标记。 +// v2: 固定 windows/macos/linux 三个槽位,每槽预生成冻结 profile(device_id/client_id/beta_set 终身不变)。 +// 旧 pool(无 Schema 字段或 Schema != v2)视为 legacy,回退现有逻辑,不读写不覆盖。 +const claudeEnvironmentProfilePoolSchemaV2 = "v2" + type ClaudeEnvironmentProfileSlot struct { Slot int `json:"slot"` Environment EnvironmentClass `json:"environment"` @@ -23,11 +32,17 @@ type ClaudeEnvironmentProfileSlot struct { type ClaudeEnvironmentProfilePool struct { mu sync.Mutex `json:"-"` + Schema string `json:"schema,omitempty"` Version int `json:"version"` Capacity int `json:"capacity"` Slots []ClaudeEnvironmentProfileSlot `json:"slots"` } +// IsV2 报告 pool 是否为 schema v2(3 OS 槽位冻结)。 +func (p *ClaudeEnvironmentProfilePool) IsV2() bool { + return p != nil && p.Schema == claudeEnvironmentProfilePoolSchemaV2 +} + func DecodeClaudeEnvironmentProfilePool(raw any) (*ClaudeEnvironmentProfilePool, error) { if raw == nil { return nil, nil @@ -251,6 +266,72 @@ func buildClaudeEnvironmentProfileForClass(env EnvironmentClass) *ClaudeEnvironm return profile } +// buildFrozenClaudeEnvironmentProfileForSlot 为指定 OS 槽位模拟生成一份冻结 profile。 +// device_id/client_id 模拟生成并冻结;cli_version/beta_set 取传入版本的自洽集合。 +// desktop 槽位不应出现(routeToSlot 已归并到 windows),此处仅处理 windows/macos/linux。 +func buildFrozenClaudeEnvironmentProfileForSlot(env EnvironmentClass, cliVersion string) *ClaudeEnvironmentProfile { + profile := buildClaudeEnvironmentProfileForClass(env) + if cliVersion = strings.TrimSpace(cliVersion); cliVersion == "" { + cliVersion = ExtractCLIVersion(profile.UserAgent) + } + profile.Source = claudeEnvironmentProfileSourceSimulated + profile.ClientID = generateClientID() + profile.DeviceID = generateClientID() + profile.SessionSeed = uuid.NewString() + profile.ClientVersion = cliVersion + profile.UserAgent = "claude-cli/" + cliVersion + " (external, cli)" + profile.XApp = "claude-code" + profile.ClientType = "cli" + profile.Family = ClaudeClientFamilyCodeCLI + profile.Runtime = "node" + if profile.RuntimeVersion == "" { + profile.RuntimeVersion = defaultClaudeCodeRuntimeVersion() + } + profile.BetaSet = betaSetForCLIVersion(cliVersion) + profile.Headers = map[string]string{} + profile.TelemetryPolicy = claudeEnvironmentTelemetryPolicyLocalAck + profile.FrozenAt = nowForEnvironmentProfilePool() + profile.CreatedAt = profile.FrozenAt + profile.UpdatedAt = profile.FrozenAt + return profile +} + +// newFrozenClaudeEnvironmentProfilePool 一次性模拟生成 schema v2 pool(windows/macos/linux 三个冻结槽位)。 +func newFrozenClaudeEnvironmentProfilePool(cliVersion string) *ClaudeEnvironmentProfilePool { + now := nowForEnvironmentProfilePool() + slots := make([]ClaudeEnvironmentProfileSlot, len(fixedClaudeEnvironmentSlotClasses)) + for i, env := range fixedClaudeEnvironmentSlotClasses { + profile := buildFrozenClaudeEnvironmentProfileForSlot(env, cliVersion) + slots[i] = ClaudeEnvironmentProfileSlot{ + Slot: i, + Environment: env, + State: EnvironmentProfileSlotBound, + Profile: profile, + CreatedAt: now, + UpdatedAt: now, + } + } + return &ClaudeEnvironmentProfilePool{ + Schema: claudeEnvironmentProfilePoolSchemaV2, + Version: 2, + Capacity: len(slots), + Slots: slots, + } +} + +// betaSetForCLIVersion 返回指定 CLI 版本对应的自洽 anthropic-beta 集合。 +// 当前对齐 FullClaudeCodeMimicryBetas;版本维度差异留待后续按版本细化。 +func betaSetForCLIVersion(cliVersion string) []string { + out := make([]string, len(claude.FullClaudeCodeMimicryBetas())) + copy(out, claude.FullClaudeCodeMimicryBetas()) + return out +} + +func defaultClaudeCodeRuntimeVersion() string { + headers := claude.GetHeaders(nil) + return strings.TrimPrefix(headers["X-Stainless-Runtime-Version"], "v") +} + func (s *GatewayService) acquireClaudeEnvironmentProfileForRequest(ctx context.Context, account *Account, headers http.Header, body []byte) (*EnvironmentProfileSlotLease, *ClaudeEnvironmentProfile, error) { if s == nil || account == nil || !account.IsClaudeSingleEnvironmentEnabled() { return nil, nil, nil @@ -270,6 +351,72 @@ func (s *GatewayService) acquireClaudeEnvironmentProfileForRequest(ctx context.C return nil, nil, err } } + + // v2 路径:3 OS 槽位冻结式 pool。 + if pool, err := decodeClaudeEnvironmentProfilePool(account); err != nil { + return nil, nil, err + } else if pool != nil && pool.IsV2() { + return s.acquireV2ClaudeEnvironmentProfileSlot(ctx, account, pool, headers, body) + } + + // 旧账号不改动:存在 legacy pool 或旧 claude_environment_profile 时,回退现有逻辑。 + if accountHasLegacyClaudeEnvironmentProfile(account) { + slog.Debug("claude_environment_profile_legacy_fallback", + "account_id", account.ID, + "reason", "legacy_schema_unmigrated") + return s.acquireLegacyClaudeEnvironmentProfileSlot(ctx, account, headers, body) + } + + // 未绑定 pool 的凭证:懒生成 v2 pool 并落库。 + cliVersion := s.claudeCLIVersion() + pool := newFrozenClaudeEnvironmentProfilePool(cliVersion) + if s.accountRepo != nil { + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{claudeEnvironmentProfilePoolKey: pool}); err != nil { + return nil, nil, err + } + } + slog.Info("claude_environment_profile_pool_generated", + "account_id", account.ID, + "schema", claudeEnvironmentProfilePoolSchemaV2, + "cli_version", cliVersion) + return s.acquireV2ClaudeEnvironmentProfileSlot(ctx, account, pool, headers, body) +} + +// acquireV2ClaudeEnvironmentProfileSlot 在 schema v2 pool 上按客户端来源 OS 选槽。 +// v2 槽位是共享身份而非互斥资源:并发请求复用同一槽位(如 5 个 windows 请求都走 windows 槽), +// 不占用 lease manager 的串行锁。lease.ReleaseFunc 为 no-op,仅保留 lease 结构以兼容下游 +// attachEnvironmentProfileLeaseToRequest / wrapResponseBodyWithEnvironmentProfileLease。 +func (s *GatewayService) acquireV2ClaudeEnvironmentProfileSlot(ctx context.Context, account *Account, pool *ClaudeEnvironmentProfilePool, headers http.Header, body []byte) (*EnvironmentProfileSlotLease, *ClaudeEnvironmentProfile, error) { + env := routeToSlot(DetectClaudeEnvironmentClass(headers, body)) + slotIdx := slotIndexOfEnvironmentClass(env) + if slotIdx < 0 || slotIdx >= len(pool.Slots) { + return nil, nil, environmentProfileSlotExhaustedError() + } + pool.mu.Lock() + defer pool.mu.Unlock() + if err := pool.Normalize(); err != nil { + return nil, nil, err + } + profile := pool.Slots[slotIdx].Profile + if profile == nil { + return nil, nil, fmt.Errorf("v2 claude environment profile slot %d has no frozen profile", slotIdx) + } + lease := &EnvironmentProfileSlotLease{ + AccountID: account.ID, + Slot: slotIdx, + Environment: pool.Slots[slotIdx].Environment, + ReleaseFunc: func() {}, // v2 无互斥,释放为 no-op + } + slog.Debug("claude_environment_profile_slot_applied", + "account_id", account.ID, + "slot", string(env), + "device_id", profile.DeviceID, + "cli_version", profile.ClientVersion) + return lease, profile, nil +} + +// acquireLegacyClaudeEnvironmentProfileSlot 是旧 schema 账号的回退路径,保持现有动态分桶行为。 +func (s *GatewayService) acquireLegacyClaudeEnvironmentProfileSlot(ctx context.Context, account *Account, headers http.Header, body []byte) (*EnvironmentProfileSlotLease, *ClaudeEnvironmentProfile, error) { pool, err := getOrCreateClaudeEnvironmentProfilePool(account) if err != nil { return nil, nil, err @@ -294,6 +441,35 @@ func (s *GatewayService) acquireClaudeEnvironmentProfileForRequest(ctx context.C return lease, profile, nil } +// accountHasLegacyClaudeEnvironmentProfile 报告账号是否持有旧 schema pool 或旧 claude_environment_profile。 +// 这类账号不改动,回退现有逻辑。 +func accountHasLegacyClaudeEnvironmentProfile(account *Account) bool { + if account == nil || account.Extra == nil { + return false + } + if _, ok := account.GetClaudeEnvironmentProfile(); ok { + return true + } + if pool, err := DecodeClaudeEnvironmentProfilePool(account.Extra[claudeEnvironmentProfilePoolKey]); err == nil && pool != nil && !pool.IsV2() { + return true + } + return false +} + +func decodeClaudeEnvironmentProfilePool(account *Account) (*ClaudeEnvironmentProfilePool, error) { + if account == nil || account.Extra == nil { + return nil, nil + } + return DecodeClaudeEnvironmentProfilePool(account.Extra[claudeEnvironmentProfilePoolKey]) +} + +// isV2ClaudeEnvironmentProfile 报告 profile 是否为 schema v2 槽位冻结式(模拟生成)。 +// 用于决定是否强制重写 device_id(透传路径也强制)。 +// 判据:source == simulated 且 FrozenAt 非零。 +func isV2ClaudeEnvironmentProfile(profile *ClaudeEnvironmentProfile) bool { + return profile != nil && profile.Source == claudeEnvironmentProfileSourceSimulated && !profile.FrozenAt.IsZero() +} + func environmentClassFromClaudeProfile(profile *ClaudeEnvironmentProfile) EnvironmentClass { if profile == nil { return EnvironmentClassWindows diff --git a/backend/internal/service/claude_environment_profile_pool_test.go b/backend/internal/service/claude_environment_profile_pool_test.go new file mode 100644 index 000000000..774c1241b --- /dev/null +++ b/backend/internal/service/claude_environment_profile_pool_test.go @@ -0,0 +1,180 @@ +package service + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRouteToSlot(t *testing.T) { + cases := []struct { + in EnvironmentClass + want EnvironmentClass + }{ + {EnvironmentClassWindows, EnvironmentClassWindows}, + {EnvironmentClassLinux, EnvironmentClassLinux}, + {EnvironmentClassMacOS, EnvironmentClassMacOS}, + {EnvironmentClassDesktop, EnvironmentClassWindows}, // desktop 归并 windows + {"", EnvironmentClassWindows}, // 未知默认 windows + } + for _, c := range cases { + require.Equal(t, c.want, routeToSlot(c.in), "routeToSlot(%q)", c.in) + } +} + +func TestSlotIndexOfEnvironmentClass(t *testing.T) { + require.Equal(t, 0, slotIndexOfEnvironmentClass(EnvironmentClassWindows)) + require.Equal(t, 0, slotIndexOfEnvironmentClass(EnvironmentClassDesktop)) // desktop → windows slot 0 + require.Equal(t, 1, slotIndexOfEnvironmentClass(EnvironmentClassMacOS)) + require.Equal(t, 2, slotIndexOfEnvironmentClass(EnvironmentClassLinux)) +} + +func TestNewFrozenClaudeEnvironmentProfilePool(t *testing.T) { + pool := newFrozenClaudeEnvironmentProfilePool("2.1.161") + require.NotNil(t, pool) + require.True(t, pool.IsV2()) + require.Equal(t, "v2", pool.Schema) + require.Len(t, pool.Slots, 3) + + // 三槽位分别是 windows/macos/linux,顺序固定 + require.Equal(t, EnvironmentClassWindows, pool.Slots[0].Environment) + require.Equal(t, EnvironmentClassMacOS, pool.Slots[1].Environment) + require.Equal(t, EnvironmentClassLinux, pool.Slots[2].Environment) + + // 每槽 profile 冻结:device_id/client_id 非空、source=simulated、cli_version 一致 + for i, slot := range pool.Slots { + require.NotNil(t, slot.Profile, "slot %d profile", i) + require.NotEmpty(t, slot.Profile.DeviceID) + require.NotEmpty(t, slot.Profile.ClientID) + require.Equal(t, claudeEnvironmentProfileSourceSimulated, slot.Profile.Source) + require.Equal(t, "2.1.161", slot.Profile.ClientVersion) + require.NotZero(t, slot.Profile.FrozenAt) + require.NotEmpty(t, slot.Profile.BetaSet) + } + + // windows/linux = x64, macos = arm64 + require.Equal(t, "x64", pool.Slots[0].Profile.Arch) + require.Equal(t, "arm64", pool.Slots[1].Profile.Arch) + require.Equal(t, "x64", pool.Slots[2].Profile.Arch) + + // 每槽 device_id 互不相同 + devs := map[string]struct{}{} + for _, slot := range pool.Slots { + devs[slot.Profile.DeviceID] = struct{}{} + } + require.Len(t, devs, 3, "三个槽位 device_id 应互不相同") +} + +func TestNewFrozenPoolDeviceIdStableAcrossCalls(t *testing.T) { + // 同一 pool 内 device_id 冻结;不同 pool 实例 device_id 不同(模拟生成) + p1 := newFrozenClaudeEnvironmentProfilePool("2.1.161") + p2 := newFrozenClaudeEnvironmentProfilePool("2.1.161") + require.NotEqual(t, p1.Slots[0].Profile.DeviceID, p2.Slots[0].Profile.DeviceID) +} + +func TestIsV2ClaudeEnvironmentProfile(t *testing.T) { + // v2 冻结 profile + pool := newFrozenClaudeEnvironmentProfilePool("2.1.161") + require.True(t, isV2ClaudeEnvironmentProfile(pool.Slots[0].Profile)) + + // legacy profile(无 FrozenAt / source 非 simulated) + legacy := defaultClaudeCodeEnvironmentProfile(nil) + require.False(t, isV2ClaudeEnvironmentProfile(legacy)) + + // nil + require.False(t, isV2ClaudeEnvironmentProfile(nil)) +} + +func TestAcquireV2SlotRoutesByClientOS(t *testing.T) { + svc := &GatewayService{claudeEnvironmentProfileSlotLeases: NewEnvironmentProfileSlotLeaseManager()} + pool := newFrozenClaudeEnvironmentProfilePool("2.1.161") + account := &Account{ID: 9001, Platform: PlatformAnthropic, Type: AccountTypeOAuth, + Extra: map[string]any{claudeEnvironmentProfilePoolKey: pool}} + + // linux 客户端 → linux 槽(slot 2) + hLinux := http.Header{} + hLinux.Set("X-Stainless-OS", "Linux") + lease, profile, err := svc.acquireV2ClaudeEnvironmentProfileSlot(nil, account, pool, hLinux, nil) + require.NoError(t, err) + require.Equal(t, EnvironmentClassLinux, lease.Environment) + require.Equal(t, pool.Slots[2].Profile.DeviceID, profile.DeviceID) + lease.ReleaseFunc() + + // windows 客户端 → windows 槽(slot 0) + hWin := http.Header{} + hWin.Set("X-Stainless-OS", "Windows") + lease, profile, err = svc.acquireV2ClaudeEnvironmentProfileSlot(nil, account, pool, hWin, nil) + require.NoError(t, err) + require.Equal(t, EnvironmentClassWindows, lease.Environment) + require.Equal(t, pool.Slots[0].Profile.DeviceID, profile.DeviceID) + lease.ReleaseFunc() + + // desktop 客户端 → 归并 windows 槽(slot 0) + hDesk := http.Header{} + hDesk.Set("User-Agent", "Claude Desktop (electron)") + lease, profile, err = svc.acquireV2ClaudeEnvironmentProfileSlot(nil, account, pool, hDesk, nil) + require.NoError(t, err) + require.Equal(t, EnvironmentClassWindows, lease.Environment) + require.Equal(t, pool.Slots[0].Profile.DeviceID, profile.DeviceID) + lease.ReleaseFunc() +} + +func TestAccountHasLegacyClaudeEnvironmentProfile(t *testing.T) { + // 旧 claude_environment_profile 字段 → legacy + account := &Account{Extra: map[string]any{}} + profile := defaultClaudeCodeEnvironmentProfile(nil) + account.Extra[claudeEnvironmentProfileKey] = profile + require.True(t, accountHasLegacyClaudeEnvironmentProfile(account)) + + // v2 pool → 非 legacy + account2 := &Account{Extra: map[string]any{}} + account2.Extra[claudeEnvironmentProfilePoolKey] = newFrozenClaudeEnvironmentProfilePool("2.1.161") + require.False(t, accountHasLegacyClaudeEnvironmentProfile(account2)) + + // 空 → 非 legacy + account3 := &Account{Extra: map[string]any{}} + require.False(t, accountHasLegacyClaudeEnvironmentProfile(account3)) +} + +func TestAcquireV2SlotConcurrentReuseSameSlot(t *testing.T) { + // v2 槽位是共享身份:并发请求复用同一槽位,不互斥。 + // 5 个 windows 请求都应成功拿到 windows 槽(slot 0),且 activeCount 保持 0(不占用 lease 锁)。 + svc := &GatewayService{claudeEnvironmentProfileSlotLeases: NewEnvironmentProfileSlotLeaseManager()} + pool := newFrozenClaudeEnvironmentProfilePool("2.1.161") + account := &Account{ID: 9102, Platform: PlatformAnthropic, Type: AccountTypeOAuth, + Extra: map[string]any{claudeEnvironmentProfilePoolKey: pool}} + + hWin := http.Header{} + hWin.Set("X-Stainless-OS", "Windows") + + type result struct { + lease *EnvironmentProfileSlotLease + profile *ClaudeEnvironmentProfile + err error + } + n := 5 + results := make([]result, n) + start := make(chan struct{}) + done := make(chan struct{}) + go func() { + close(start) + for i := 0; i < n; i++ { + lease, profile, err := svc.acquireV2ClaudeEnvironmentProfileSlot(nil, account, pool, hWin, nil) + results[i] = result{lease, profile, err} + } + close(done) + }() + <-start + <-done + + for i, r := range results { + require.NoError(t, r.err, "concurrent windows request %d", i) + require.NotNil(t, r.lease) + require.Equal(t, EnvironmentClassWindows, r.lease.Environment) + require.Equal(t, 0, r.lease.Slot) + require.Equal(t, pool.Slots[0].Profile.DeviceID, r.profile.DeviceID) + } + // v2 不占用 lease manager 的串行锁 + require.Equal(t, 0, svc.claudeEnvironmentProfileSlotLeases.activeCount()) +} diff --git a/backend/internal/service/claude_jsonl_replay_test.go b/backend/internal/service/claude_jsonl_replay_test.go index 6139b4391..b7a6da2ba 100644 --- a/backend/internal/service/claude_jsonl_replay_test.go +++ b/backend/internal/service/claude_jsonl_replay_test.go @@ -123,7 +123,10 @@ func TestClaudeJSONLReplayMockEnvironmentBuildsRequests(t *testing.T) { pool, err := DecodeClaudeEnvironmentProfilePool(repo.account.Extra[claudeEnvironmentProfilePoolKey]) require.NoError(t, err) require.NotNil(t, pool) - require.Equal(t, account.Concurrency, pool.Capacity) + // v2 schema:固定 3 OS 槽位冻结(windows/macos/linux),容量与并发解耦。 + require.True(t, pool.IsV2(), "pool should be schema v2") + require.Equal(t, 3, pool.Capacity) + require.Len(t, pool.Slots, 3) require.Equal(t, 0, svc.claudeEnvironmentProfileSlotLeases.activeCount()) require.Len(t, upstream.requests, len(cases)) } diff --git a/backend/internal/service/environment_profile_pool.go b/backend/internal/service/environment_profile_pool.go index 92d69c37c..7ab4d652c 100644 --- a/backend/internal/service/environment_profile_pool.go +++ b/backend/internal/service/environment_profile_pool.go @@ -368,6 +368,41 @@ func DetectClaudeEnvironmentClass(headers http.Header, body []byte) EnvironmentC return detectEnvironmentClassFromHeaders(headers) } +// routeToSlot 将探测到的环境类映射到固定的 3 OS 槽位之一。 +// desktop 归并到 windows(不单独建槽),windows/macos/linux 原样。 +// 仅用于选槽,不决定出口身份。 +func routeToSlot(env EnvironmentClass) EnvironmentClass { + switch normalizeEnvironmentClass(env) { + case EnvironmentClassWindows, EnvironmentClassDesktop: + return EnvironmentClassWindows + case EnvironmentClassMacOS: + return EnvironmentClassMacOS + case EnvironmentClassLinux: + return EnvironmentClassLinux + default: + return EnvironmentClassWindows + } +} + +// fixedClaudeEnvironmentSlotClasses 是 schema v2 pool 固定的 3 个 OS 槽位顺序。 +// 索引即 slot 编号:0=windows, 1=macos, 2=linux。 +var fixedClaudeEnvironmentSlotClasses = []EnvironmentClass{ + EnvironmentClassWindows, + EnvironmentClassMacOS, + EnvironmentClassLinux, +} + +// slotIndexOfEnvironmentClass 返回环境类在固定 3 槽位中的索引;未找到返回 -1。 +func slotIndexOfEnvironmentClass(env EnvironmentClass) int { + target := routeToSlot(env) + for i, class := range fixedClaudeEnvironmentSlotClasses { + if class == target { + return i + } + } + return -1 +} + func DetectCodexEnvironmentClass(headers http.Header) EnvironmentClass { if detectCodexClientFamilyFromHeaders(headers) == CodexClientFamilyDesktop { return EnvironmentClassDesktop diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index e4b431c0e..e4a7a3535 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -5481,9 +5481,8 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A } } - if isClaudeCode && resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { - s.learnClaudeCodeHeaderProfile(ctx, account, c.Request.Header) - } + // R4.2 学习链路已移除:profile 改为 3 OS 槽位冻结式模拟生成,不再从真实客户端响应学习。 + // 旧 claude_code_header_profile 字段的复用读取保留(旧账号回退),但不再写入。 // 触发上游接受回调(提前释放串行锁,不等流完成) if parsed.OnUpstreamAccepted != nil { parsed.OnUpstreamAccepted() @@ -6772,7 +6771,17 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex if enableFP { fingerprint = fp } - if !enableMPT { + // device_id 收口:当取到 v2 槽位 profile 时,强制用其冻结 device_id 重写 + // metadata.user_id,与 enableMPT 解耦(透传路径也强制重写,不透传客户端原值)。 + // v2 profile 携带冻结的 DeviceID;非 v2 路径保持原 enableMPT 门控。 + if claudeEnvironmentProfile != nil && claudeEnvironmentProfile.DeviceID != "" && isV2ClaudeEnvironmentProfile(claudeEnvironmentProfile) { + accountUUID := account.GetExtraString("account_uuid") + if accountUUID != "" { + if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, claudeEnvironmentProfile.DeviceID, fp.UserAgent); err == nil && len(newBody) > 0 { + body = newBody + } + } + } else if !enableMPT { accountUUID := account.GetExtraString("account_uuid") if accountUUID != "" && fp.ClientID != "" { if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 { @@ -6802,7 +6811,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account, modelID) effectiveDropSet := mergeDropSets(policyFilterSet) finalBetaHeader, finalBetaShouldSet := s.computeFinalAnthropicBeta( - tokenType, mimicClaudeCode, modelID, clientHeaders, body, effectiveDropSet, + tokenType, mimicClaudeCode, modelID, clientHeaders, body, effectiveDropSet, claudeEnvironmentProfile, ) // 能力维度 body sanitize:与最终 anthropic-beta header 对称 @@ -7126,6 +7135,7 @@ func (s *GatewayService) computeFinalAnthropicBeta( clientHeaders http.Header, body []byte, effectiveDropSet map[string]struct{}, + slotProfile *ClaudeEnvironmentProfile, ) (string, bool) { clientBeta := "" if clientHeaders != nil { @@ -7142,7 +7152,12 @@ func (s *GatewayService) computeFinalAnthropicBeta( } return mergeAnthropicBetaDropping(requiredBetas, "", effectiveDropSet), true } - // 真 Claude Code 客户端透传路径 + // v2 槽位冻结 profile:透传路径也按槽位 BetaSet 归一,不透传客户端 beta。 + // 消除 "UA=2.1.161 却声称 2.1.186 beta" 的版本不自洽。 + if isV2ClaudeEnvironmentProfile(slotProfile) && len(slotProfile.BetaSet) > 0 { + return stripBetaTokensWithSet(strings.Join(slotProfile.BetaSet, ","), effectiveDropSet), true + } + // 真 Claude Code 客户端透传路径(legacy / 无 v2 profile) return stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBeta), effectiveDropSet), true } @@ -7177,6 +7192,7 @@ func (s *GatewayService) computeFinalCountTokensAnthropicBeta( clientHeaders http.Header, body []byte, effectiveDropSet map[string]struct{}, + slotProfile *ClaudeEnvironmentProfile, ) (string, bool) { clientBeta := "" if clientHeaders != nil { @@ -7192,6 +7208,14 @@ func (s *GatewayService) computeFinalCountTokensAnthropicBeta( requiredBetas := append(claude.FullClaudeCodeMimicryBetas(), claude.BetaTokenCounting) return mergeAnthropicBetaDropping(requiredBetas, clientBeta, effectiveDropSet), true } + // v2 槽位冻结 profile:透传路径按槽位 BetaSet 归一 + 补 token-counting。 + if isV2ClaudeEnvironmentProfile(slotProfile) && len(slotProfile.BetaSet) > 0 { + beta := strings.Join(slotProfile.BetaSet, ",") + if !strings.Contains(beta, claude.BetaTokenCounting) { + beta = beta + "," + claude.BetaTokenCounting + } + return stripBetaTokensWithSet(beta, effectiveDropSet), true + } if clientBeta == "" { return claude.CountTokensBetaHeader, true } @@ -10304,7 +10328,16 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con }) if err == nil { ctFingerprint = fp - if !ctEnableMPT { + // device_id 收口(同 buildUpstreamRequest):v2 槽位 profile 强制重写 device_id, + // 与 enableMPT 解耦;legacy 路径保持原门控。 + if ctClaudeEnvironmentProfile != nil && ctClaudeEnvironmentProfile.DeviceID != "" && isV2ClaudeEnvironmentProfile(ctClaudeEnvironmentProfile) { + accountUUID := account.GetExtraString("account_uuid") + if accountUUID != "" { + if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, ctClaudeEnvironmentProfile.DeviceID, fp.UserAgent); err == nil && len(newBody) > 0 { + body = newBody + } + } + } else if !ctEnableMPT { accountUUID := account.GetExtraString("account_uuid") if accountUUID != "" && fp.ClientID != "" { if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 { @@ -10324,7 +10357,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con // 顺序约束同 buildUpstreamRequest。 ctEffectiveDropSet := mergeDropSets(s.getBetaPolicyFilterSet(ctx, c, account, modelID)) finalBetaHeader, finalBetaShouldSet := s.computeFinalCountTokensAnthropicBeta( - tokenType, mimicClaudeCode, modelID, clientHeaders, body, ctEffectiveDropSet, + tokenType, mimicClaudeCode, modelID, clientHeaders, body, ctEffectiveDropSet, ctClaudeEnvironmentProfile, ) // 能力维度 body sanitize:与最终 anthropic-beta header 对称 From 0b2105b16033f8bf49b17f1c09849da73c54049d Mon Sep 17 00:00:00 2001 From: Claude Code via ted Date: Wed, 24 Jun 2026 00:22:27 +0800 Subject: [PATCH 3/5] feat: align codex profile to 3 OS slots + manual slot edit UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex profile pool 对齐 Claude v2:3 OS 槽位冻结(windows/macos/linux), 并发复用同槽,旧 Codex 账号统一升级迁移(非保守回退)。 - CodexEnvironmentProfilePool 加 Schema/IsV2;Profile 加 FrozenAt - newFrozenCodexEnvironmentProfilePool:一次性模拟生成 3 槽冻结 - acquireCodex v2 路径:按客户端 OS 选槽,共享身份无互斥,并发复用 - 旧 Codex 账号统一迁移为 v2(删旧 pool/profile,生成 v2 落库) - 新凭证落库预生成 v2 pool 手工编辑 profile UI 入口(按槽位编辑字段): - 后端新增 PUT /:id/{claude,codex}-environment-profile/slot 端点 + UpdateClaudeEnvironmentProfileSlot / UpdateCodexEnvironmentProfileSlot + applyClaude/CodexProfileOverrides 合并非空字段覆盖,保留冻结语义 - 前端 EnvironmentProfileCard: v2 pool 按 3 槽位展示可编辑表单 (device_id/client_id/UA/cli_version/beta_set 等),编辑+保存 - EditAccountModal 接 save-slot 事件调 slot 编辑 API - 类型 + i18n(zh/en)补齐 slot 编辑相关文案 go build/test 全通过;frontend vue-tsc + vitest + build 通过 --- .../internal/handler/admin/account_handler.go | 56 ++++++ .../handler/admin/admin_service_stub_test.go | 8 + backend/internal/server/routes/admin.go | 2 + backend/internal/service/admin_service.go | 167 +++++++++++++++++- .../service/codex_environment_profile.go | 2 + .../service/codex_environment_profile_pool.go | 131 ++++++++++++-- frontend/src/api/admin/accounts.ts | 28 ++- .../components/account/EditAccountModal.vue | 41 +++++ .../account/EnvironmentProfileCard.vue | 152 +++++++++++++++- frontend/src/i18n/locales/en.ts | 14 +- frontend/src/i18n/locales/zh.ts | 22 ++- frontend/src/types/index.ts | 19 +- 12 files changed, 611 insertions(+), 31 deletions(-) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 684403398..227fa904c 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -143,6 +143,11 @@ type UpdateClaudeEnvironmentProfileRequest struct { Profile *service.ClaudeEnvironmentProfile `json:"profile"` } +type UpdateClaudeEnvironmentProfileSlotRequest struct { + Slot string `json:"slot"` + Profile *service.ClaudeEnvironmentProfile `json:"profile"` +} + type UpdateCodexEnvironmentProfileSettingsRequest struct { SingleEnvironment *bool `json:"single_environment"` ProfileLocked *bool `json:"profile_locked"` @@ -154,6 +159,11 @@ type UpdateCodexEnvironmentProfileRequest struct { Profile *service.CodexEnvironmentProfile `json:"profile"` } +type UpdateCodexEnvironmentProfileSlotRequest struct { + Slot string `json:"slot"` + Profile *service.CodexEnvironmentProfile `json:"profile"` +} + // BulkUpdateAccountsRequest represents the payload for bulk editing accounts type BulkUpdateAccountsRequest struct { AccountIDs []int64 `json:"account_ids"` @@ -727,6 +737,29 @@ func (h *AccountHandler) UpdateClaudeEnvironmentProfile(c *gin.Context) { response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) } +func (h *AccountHandler) UpdateClaudeEnvironmentProfileSlot(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + var req UpdateClaudeEnvironmentProfileSlotRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if req.Profile == nil { + response.BadRequest(c, "profile is required") + return + } + account, err := h.adminService.UpdateClaudeEnvironmentProfileSlot(c.Request.Context(), accountID, service.EnvironmentClass(req.Slot), req.Profile) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) +} + func (h *AccountHandler) ResetClaudeEnvironmentProfile(c *gin.Context) { accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { @@ -800,6 +833,29 @@ func (h *AccountHandler) UpdateCodexEnvironmentProfile(c *gin.Context) { response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) } +func (h *AccountHandler) UpdateCodexEnvironmentProfileSlot(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + var req UpdateCodexEnvironmentProfileSlotRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if req.Profile == nil { + response.BadRequest(c, "profile is required") + return + } + account, err := h.adminService.UpdateCodexEnvironmentProfileSlot(c.Request.Context(), accountID, service.EnvironmentClass(req.Slot), req.Profile) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) +} + func (h *AccountHandler) ResetCodexEnvironmentProfile(c *gin.Context) { accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index d032f41c5..3c7c7149b 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -652,6 +652,10 @@ func (s *stubAdminService) UpdateClaudeEnvironmentProfile(ctx context.Context, i return &service.Account{ID: id, Platform: service.PlatformAnthropic, Type: service.AccountTypeOAuth, Extra: map[string]any{"claude_environment_profile": profile}}, nil } +func (s *stubAdminService) UpdateClaudeEnvironmentProfileSlot(ctx context.Context, id int64, slot service.EnvironmentClass, overrides *service.ClaudeEnvironmentProfile) (*service.Account, error) { + return &service.Account{ID: id, Platform: service.PlatformAnthropic, Type: service.AccountTypeOAuth, Extra: map[string]any{"claude_environment_profile_pool.slot": slot, "overrides": overrides}}, nil +} + func (s *stubAdminService) ResetClaudeEnvironmentProfile(ctx context.Context, id int64) (*service.Account, error) { return &service.Account{ID: id, Platform: service.PlatformAnthropic, Type: service.AccountTypeOAuth, Extra: map[string]any{}}, nil } @@ -664,6 +668,10 @@ func (s *stubAdminService) UpdateCodexEnvironmentProfile(ctx context.Context, id return &service.Account{ID: id, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Extra: map[string]any{"codex_environment_profile": profile}}, nil } +func (s *stubAdminService) UpdateCodexEnvironmentProfileSlot(ctx context.Context, id int64, slot service.EnvironmentClass, overrides *service.CodexEnvironmentProfile) (*service.Account, error) { + return &service.Account{ID: id, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Extra: map[string]any{"codex_environment_profile_pool.slot": slot, "overrides": overrides}}, nil +} + func (s *stubAdminService) ResetCodexEnvironmentProfile(ctx context.Context, id int64) (*service.Account, error) { return &service.Account{ID: id, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Extra: map[string]any{}}, nil } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index b53ff7974..7b8b23026 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -306,9 +306,11 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.POST("/:id/recover-state", h.Admin.Account.RecoverState) accounts.PUT("/:id/claude-environment-profile/settings", h.Admin.Account.UpdateClaudeEnvironmentProfileSettings) accounts.PUT("/:id/claude-environment-profile", h.Admin.Account.UpdateClaudeEnvironmentProfile) + accounts.PUT("/:id/claude-environment-profile/slot", h.Admin.Account.UpdateClaudeEnvironmentProfileSlot) accounts.POST("/:id/claude-environment-profile/reset", h.Admin.Account.ResetClaudeEnvironmentProfile) accounts.PUT("/:id/codex-environment-profile/settings", h.Admin.Account.UpdateCodexEnvironmentProfileSettings) accounts.PUT("/:id/codex-environment-profile", h.Admin.Account.UpdateCodexEnvironmentProfile) + accounts.PUT("/:id/codex-environment-profile/slot", h.Admin.Account.UpdateCodexEnvironmentProfileSlot) accounts.POST("/:id/codex-environment-profile/reset", h.Admin.Account.ResetCodexEnvironmentProfile) accounts.POST("/:id/refresh", h.Admin.Account.Refresh) accounts.POST("/:id/apply-oauth-credentials", h.Admin.Account.ApplyOAuthCredentials) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 62b56c8b9..3d4ea37cf 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -87,9 +87,11 @@ type AdminService interface { UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error UpdateClaudeEnvironmentProfileSettings(ctx context.Context, id int64, updates map[string]any) (*Account, error) UpdateClaudeEnvironmentProfile(ctx context.Context, id int64, profile *ClaudeEnvironmentProfile) (*Account, error) + UpdateClaudeEnvironmentProfileSlot(ctx context.Context, id int64, slot EnvironmentClass, overrides *ClaudeEnvironmentProfile) (*Account, error) ResetClaudeEnvironmentProfile(ctx context.Context, id int64) (*Account, error) UpdateCodexEnvironmentProfileSettings(ctx context.Context, id int64, updates map[string]any) (*Account, error) UpdateCodexEnvironmentProfile(ctx context.Context, id int64, profile *CodexEnvironmentProfile) (*Account, error) + UpdateCodexEnvironmentProfileSlot(ctx context.Context, id int64, slot EnvironmentClass, overrides *CodexEnvironmentProfile) (*Account, error) ResetCodexEnvironmentProfile(ctx context.Context, id int64) (*Account, error) EnableAllOpenAIWS(ctx context.Context, id int64) error ResetOpenAIWS(ctx context.Context, id int64) error @@ -2589,18 +2591,13 @@ func withDefaultEnvironmentProfilePool(platform, accountType string, concurrency for key, value := range extra { merged[key] = value } - capacity := environmentProfileCapacity(&Account{ - Platform: platform, - Type: accountType, - Credentials: credentials, - Extra: merged, - Concurrency: concurrency, - }) + // v2 pool 固定 3 OS 槽位冻结,容量与并发解耦,不再依赖 environmentProfileCapacity。 + _ = concurrency if platform == PlatformAnthropic { merged[claudeEnvironmentProfilePoolKey] = newFrozenClaudeEnvironmentProfilePool(claude.CLICurrentVersion) return merged } - merged[codexEnvironmentProfilePoolKey] = newCodexEnvironmentProfilePool(capacity) + merged[codexEnvironmentProfilePoolKey] = newFrozenCodexEnvironmentProfilePool() return merged } @@ -2954,6 +2951,87 @@ func (s *adminServiceImpl) UpdateClaudeEnvironmentProfile(ctx context.Context, i return s.accountRepo.GetByID(ctx, id) } +// UpdateClaudeEnvironmentProfileSlot 按槽位编辑 v2 pool 中指定 OS 槽的冻结 profile 字段。 +// slot 为 "windows"|"macos"|"linux";overrides 中的非空字段覆盖到该槽 profile,冻结语义保留(FrozenAt 不变)。 +func (s *adminServiceImpl) UpdateClaudeEnvironmentProfileSlot(ctx context.Context, id int64, slot EnvironmentClass, overrides *ClaudeEnvironmentProfile) (*Account, error) { + account, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if account == nil || !account.IsAnthropicOAuthOrSetupToken() { + return nil, errors.New("claude environment profile is only supported for Anthropic OAuth/SetupToken accounts") + } + pool, err := DecodeClaudeEnvironmentProfilePool(account.Extra[claudeEnvironmentProfilePoolKey]) + if err != nil { + return nil, err + } + if pool == nil || !pool.IsV2() { + return nil, errors.New("claude environment profile pool is not v2; migrate or reset first") + } + slotIdx := slotIndexOfEnvironmentClass(routeToSlot(slot)) + if slotIdx < 0 || slotIdx >= len(pool.Slots) { + return nil, errors.New("invalid claude environment profile slot") + } + target := pool.Slots[slotIdx].Profile + if target == nil { + return nil, errors.New("claude environment profile slot has no frozen profile") + } + applyClaudeProfileOverrides(target, overrides) + target.Source = claudeEnvironmentProfileSourceAdmin + target.UpdatedAt = time.Now().UTC() + if err := ValidateClaudeEnvironmentProfile(target); err != nil { + return nil, err + } + if err := s.accountRepo.UpdateExtra(ctx, id, map[string]any{claudeEnvironmentProfilePoolKey: pool}); err != nil { + return nil, err + } + slog.Info("claude_environment_profile_slot_updated", "account_id", id, "slot", string(slot)) + return s.accountRepo.GetByID(ctx, id) +} + +// applyClaudeProfileOverrides 将 overrides 中的非空字段覆盖到 target(保留冻结 device_id 除非显式覆盖)。 +func applyClaudeProfileOverrides(target, overrides *ClaudeEnvironmentProfile) { + if overrides == nil || target == nil { + return + } + if v := strings.TrimSpace(overrides.DeviceID); v != "" { + target.DeviceID = v + } + if v := strings.TrimSpace(overrides.ClientID); v != "" { + target.ClientID = v + } + if v := strings.TrimSpace(overrides.UserAgent); v != "" { + target.UserAgent = v + } + if v := strings.TrimSpace(overrides.ClientVersion); v != "" { + target.ClientVersion = v + } + if v := strings.TrimSpace(overrides.XApp); v != "" { + target.XApp = v + } + if v := strings.TrimSpace(overrides.Platform); v != "" { + target.Platform = v + } + if v := strings.TrimSpace(overrides.Arch); v != "" { + target.Arch = v + } + if v := strings.TrimSpace(overrides.Runtime); v != "" { + target.Runtime = v + } + if v := strings.TrimSpace(overrides.RuntimeVersion); v != "" { + target.RuntimeVersion = v + } + if v := strings.TrimSpace(overrides.ClientType); v != "" { + target.ClientType = v + } + if overrides.BetaSet != nil { + target.BetaSet = overrides.BetaSet + } + if overrides.Headers != nil { + target.Headers = overrides.Headers + } +} + func (s *adminServiceImpl) ResetClaudeEnvironmentProfile(ctx context.Context, id int64) (*Account, error) { account, err := s.accountRepo.GetByID(ctx, id) if err != nil { @@ -3047,6 +3125,79 @@ func (s *adminServiceImpl) UpdateCodexEnvironmentProfile(ctx context.Context, id return s.accountRepo.GetByID(ctx, id) } +// UpdateCodexEnvironmentProfileSlot 按槽位编辑 v2 pool 中指定 OS 槽的冻结 Codex profile 字段。 +func (s *adminServiceImpl) UpdateCodexEnvironmentProfileSlot(ctx context.Context, id int64, slot EnvironmentClass, overrides *CodexEnvironmentProfile) (*Account, error) { + account, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if account == nil || !account.IsOpenAIOAuth() { + return nil, errors.New("codex environment profile is only supported for OpenAI OAuth accounts") + } + pool, err := DecodeCodexEnvironmentProfilePool(account.Extra[codexEnvironmentProfilePoolKey]) + if err != nil { + return nil, err + } + if pool == nil || !pool.IsV2() { + return nil, errors.New("codex environment profile pool is not v2; migrate or reset first") + } + slotIdx := slotIndexOfEnvironmentClass(routeToSlot(slot)) + if slotIdx < 0 || slotIdx >= len(pool.Slots) { + return nil, errors.New("invalid codex environment profile slot") + } + target := pool.Slots[slotIdx].Profile + if target == nil { + return nil, errors.New("codex environment profile slot has no frozen profile") + } + applyCodexProfileOverrides(target, overrides) + target.Source = "admin" + target.UpdatedAt = time.Now().UTC() + if err := target.Validate(); err != nil { + return nil, err + } + if err := s.accountRepo.UpdateExtra(ctx, id, map[string]any{codexEnvironmentProfilePoolKey: pool}); err != nil { + return nil, err + } + slog.Info("codex_environment_profile_slot_updated", "account_id", id, "slot", string(slot)) + return s.accountRepo.GetByID(ctx, id) +} + +func applyCodexProfileOverrides(target, overrides *CodexEnvironmentProfile) { + if overrides == nil || target == nil { + return + } + if v := strings.TrimSpace(overrides.UserAgent); v != "" { + target.UserAgent = v + } + if v := strings.TrimSpace(overrides.Originator); v != "" { + target.Originator = v + } + if v := strings.TrimSpace(overrides.Version); v != "" { + target.Version = v + } + if v := strings.TrimSpace(overrides.SessionSeed); v != "" { + target.SessionSeed = v + } + if v := strings.TrimSpace(overrides.ConversationSeed); v != "" { + target.ConversationSeed = v + } + if v := strings.TrimSpace(overrides.ClientType); v != "" { + target.ClientType = v + } + if v := strings.TrimSpace(overrides.Platform); v != "" { + target.Platform = v + } + if v := strings.TrimSpace(overrides.Arch); v != "" { + target.Arch = v + } + if v := strings.TrimSpace(overrides.TLSProfile); v != "" { + target.TLSProfile = v + } + if overrides.Headers != nil { + target.Headers = overrides.Headers + } +} + func (s *adminServiceImpl) ResetCodexEnvironmentProfile(ctx context.Context, id int64) (*Account, error) { account, err := s.accountRepo.GetByID(ctx, id) if err != nil { diff --git a/backend/internal/service/codex_environment_profile.go b/backend/internal/service/codex_environment_profile.go index f42b598c5..2bb07a8a4 100644 --- a/backend/internal/service/codex_environment_profile.go +++ b/backend/internal/service/codex_environment_profile.go @@ -24,6 +24,7 @@ const ( codexEnvironmentAllowOfficialClientLearnKey = "codex_environment_allow_official_client_learn" codexEnvironmentProfileFamilyPreferenceKey = "codex_environment_profile_family_preference" codexEnvironmentAllowOfficialClientLearnLegacyKey = "codex_environment_allow_desktop_learn" + codexEnvironmentProfileSourceSimulated = "simulated" ) type CodexClientFamily string @@ -47,6 +48,7 @@ type CodexEnvironmentProfile struct { Arch string `json:"arch"` TLSProfile string `json:"tls_profile"` Headers map[string]string `json:"headers"` + FrozenAt time.Time `json:"frozen_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/backend/internal/service/codex_environment_profile_pool.go b/backend/internal/service/codex_environment_profile_pool.go index 289ba05ae..dc5d81c1d 100644 --- a/backend/internal/service/codex_environment_profile_pool.go +++ b/backend/internal/service/codex_environment_profile_pool.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "net/http" "strings" "sync" @@ -12,6 +13,9 @@ import ( const codexEnvironmentProfilePoolKey = "codex_environment_profile_pool" +// codexEnvironmentProfilePoolSchemaV2 是 3 OS 槽位冻结式 pool 的 schema 标记(与 Claude v2 对齐)。 +const codexEnvironmentProfilePoolSchemaV2 = "v2" + type CodexEnvironmentProfileSlot struct { Slot int `json:"slot"` Environment EnvironmentClass `json:"environment"` @@ -23,11 +27,17 @@ type CodexEnvironmentProfileSlot struct { type CodexEnvironmentProfilePool struct { mu sync.Mutex `json:"-"` + Schema string `json:"schema,omitempty"` Version int `json:"version"` Capacity int `json:"capacity"` Slots []CodexEnvironmentProfileSlot `json:"slots"` } +// IsV2 报告 pool 是否为 schema v2(3 OS 槽位冻结)。 +func (p *CodexEnvironmentProfilePool) IsV2() bool { + return p != nil && p.Schema == codexEnvironmentProfilePoolSchemaV2 +} + func DecodeCodexEnvironmentProfilePool(raw any) (*CodexEnvironmentProfilePool, error) { if raw == nil { return nil, nil @@ -261,6 +271,65 @@ func buildCodexEnvironmentProfileForClass(env EnvironmentClass) (*CodexEnvironme return profile, profile.Validate() } +// buildFrozenCodexEnvironmentProfileForSlot 为指定 OS 槽位模拟生成一份冻结 Codex profile。 +// session_seed/conversation_seed 模拟生成并冻结;originator/version/tls_profile/platform/arch 按 OS 归一。 +func buildFrozenCodexEnvironmentProfileForSlot(env EnvironmentClass) (*CodexEnvironmentProfile, error) { + profile, err := buildCodexEnvironmentProfileForClass(env) + if err != nil { + return nil, err + } + profile.Source = codexEnvironmentProfileSourceSimulated + profile.FrozenAt = nowForEnvironmentProfilePool() + profile.CreatedAt = profile.FrozenAt + profile.UpdatedAt = profile.FrozenAt + if err := profile.Validate(); err != nil { + return nil, err + } + return profile, nil +} + +// newFrozenCodexEnvironmentProfilePool 一次性模拟生成 schema v2 pool(windows/macos/linux 三个冻结槽位)。 +func newFrozenCodexEnvironmentProfilePool() *CodexEnvironmentProfilePool { + now := nowForEnvironmentProfilePool() + slots := make([]CodexEnvironmentProfileSlot, len(fixedClaudeEnvironmentSlotClasses)) + for i, env := range fixedClaudeEnvironmentSlotClasses { + profile, err := buildFrozenCodexEnvironmentProfileForSlot(env) + if err != nil { + // 不应发生:buildCodexEnvironmentProfileForClass 已 Validate 过 + profile = mustBuildFallbackFrozenCodexProfile(env) + } + slots[i] = CodexEnvironmentProfileSlot{ + Slot: i, + Environment: env, + State: EnvironmentProfileSlotBound, + Profile: profile, + CreatedAt: now, + UpdatedAt: now, + } + } + return &CodexEnvironmentProfilePool{ + Schema: codexEnvironmentProfilePoolSchemaV2, + Version: 2, + Capacity: len(slots), + Slots: slots, + } +} + +func mustBuildFallbackFrozenCodexProfile(env EnvironmentClass) *CodexEnvironmentProfile { + profile, err := buildCodexEnvironmentProfileForClass(env) + if err != nil { + profile, _ = buildCodexEnvironmentProfileForClass(EnvironmentClassWindows) + } + profile.Source = codexEnvironmentProfileSourceSimulated + profile.FrozenAt = nowForEnvironmentProfilePool() + return profile +} + +// isV2CodexEnvironmentProfile 报告 profile 是否为 schema v2 槽位冻结式(模拟生成)。 +func isV2CodexEnvironmentProfile(profile *CodexEnvironmentProfile) bool { + return profile != nil && profile.Source == codexEnvironmentProfileSourceSimulated && !profile.FrozenAt.IsZero() +} + func (s *OpenAIGatewayService) acquireCodexEnvironmentProfileForRequest(ctx context.Context, account *Account, headers http.Header) (*EnvironmentProfileSlotLease, *CodexEnvironmentProfile, error) { if s == nil { return nil, nil, nil @@ -310,24 +379,64 @@ func acquireCodexEnvironmentProfileForRequestWithRepo(ctx context.Context, accou return nil, nil, err } } - pool, err := getOrCreateCodexEnvironmentProfilePool(account) - if err != nil { + + // v2 路径:已绑定 schema v2 pool。 + if pool, err := DecodeCodexEnvironmentProfilePool(account.Extra[codexEnvironmentProfilePoolKey]); err != nil { return nil, nil, err + } else if pool != nil && pool.IsV2() { + return acquireV2CodexEnvironmentProfileSlot(account, pool, headers) } - env := DetectCodexEnvironmentClass(headers) - lease, profile, err := acquireCodexEnvironmentProfileSlot(pool, manager, account, env, "", buildCodexEnvironmentProfileForClass) - if err != nil { - if err == ErrNoEnvironmentProfileSlot { - return nil, nil, environmentProfileSlotExhaustedError() + + // Codex 旧账号统一升级迁移:生成 v2 pool 并落库,删除旧 pool / 旧 codex_environment_profile。 + if account.Extra != nil { + if _, exists := account.Extra[codexEnvironmentProfilePoolKey]; exists { + if deleter, ok := accountRepo.(accountExtraKeyDeleter); ok { + _ = deleter.DeleteExtraKeys(ctx, account.ID, []string{codexEnvironmentProfileKey}) + } } - return nil, nil, err } - if lease != nil && lease.BoundNew && accountRepo != nil { - if err := accountRepo.UpdateExtra(ctx, account.ID, map[string]any{codexEnvironmentProfilePoolKey: pool}); err != nil { - lease.ReleaseFunc() + pool := newFrozenCodexEnvironmentProfilePool() + if accountRepo != nil { + updates := map[string]any{codexEnvironmentProfilePoolKey: pool} + if err := accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil { return nil, nil, err } } + slog.Info("codex_environment_profile_pool_generated", + "account_id", account.ID, + "schema", codexEnvironmentProfilePoolSchemaV2, + "reason", "unified_migration") + return acquireV2CodexEnvironmentProfileSlot(account, pool, headers) +} + +// acquireV2CodexEnvironmentProfileSlot 在 schema v2 pool 上按客户端来源 OS 选槽。 +// v2 槽位是共享身份而非互斥资源:并发请求复用同一槽位,不占用 lease manager 串行锁。 +func acquireV2CodexEnvironmentProfileSlot(account *Account, pool *CodexEnvironmentProfilePool, headers http.Header) (*EnvironmentProfileSlotLease, *CodexEnvironmentProfile, error) { + env := routeToSlot(DetectCodexEnvironmentClass(headers)) + slotIdx := slotIndexOfEnvironmentClass(env) + if slotIdx < 0 || slotIdx >= len(pool.Slots) { + return nil, nil, environmentProfileSlotExhaustedError() + } + pool.mu.Lock() + defer pool.mu.Unlock() + if err := pool.Normalize(); err != nil { + return nil, nil, err + } + profile := pool.Slots[slotIdx].Profile + if profile == nil { + return nil, nil, fmt.Errorf("v2 codex environment profile slot %d has no frozen profile", slotIdx) + } + lease := &EnvironmentProfileSlotLease{ + AccountID: account.ID, + Slot: slotIdx, + Environment: pool.Slots[slotIdx].Environment, + ReleaseFunc: func() {}, // v2 无互斥,释放为 no-op + } + slog.Debug("codex_environment_profile_slot_applied", + "account_id", account.ID, + "slot", string(env), + "platform", profile.Platform, + "version", profile.Version) return lease, profile, nil } diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 7909a4a4a..806e4442d 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -22,8 +22,10 @@ import type { CheckMixedChannelResponse, ClaudeEnvironmentProfileSettingsRequest, ClaudeEnvironmentProfileUpdateRequest, + ClaudeEnvironmentProfileSlotUpdateRequest, CodexEnvironmentProfileSettingsRequest, - CodexEnvironmentProfileUpdateRequest + CodexEnvironmentProfileUpdateRequest, + CodexEnvironmentProfileSlotUpdateRequest } from '@/types' /** @@ -172,6 +174,17 @@ export async function updateClaudeEnvironmentProfile( return data } +export async function updateClaudeEnvironmentProfileSlot( + id: number, + request: ClaudeEnvironmentProfileSlotUpdateRequest +): Promise { + const { data } = await apiClient.put( + `/admin/accounts/${id}/claude-environment-profile/slot`, + request + ) + return data +} + export async function resetClaudeEnvironmentProfile(id: number): Promise { const { data } = await apiClient.post( `/admin/accounts/${id}/claude-environment-profile/reset` @@ -201,6 +214,17 @@ export async function updateCodexEnvironmentProfile( return data } +export async function updateCodexEnvironmentProfileSlot( + id: number, + request: CodexEnvironmentProfileSlotUpdateRequest +): Promise { + const { data } = await apiClient.put( + `/admin/accounts/${id}/codex-environment-profile/slot`, + request + ) + return data +} + export async function resetCodexEnvironmentProfile(id: number): Promise { const { data } = await apiClient.post( `/admin/accounts/${id}/codex-environment-profile/reset` @@ -845,9 +869,11 @@ export const accountsAPI = { update, updateClaudeEnvironmentProfileSettings, updateClaudeEnvironmentProfile, + updateClaudeEnvironmentProfileSlot, resetClaudeEnvironmentProfile, updateCodexEnvironmentProfileSettings, updateCodexEnvironmentProfile, + updateCodexEnvironmentProfileSlot, resetCodexEnvironmentProfile, checkMixedChannelRisk, delete: deleteAccount, diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 0c520a381..82e9d89cb 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1335,11 +1335,13 @@ :allow-learn="claudeEnvironmentAllowDesktopLearn" :family-preference="claudeEnvironmentFamilyPreference" :resetting="profileResetting" + :saving-slot="savingProfileSlot" @update:single-environment="claudeEnvironmentSingleEnabled = $event" @update:locked="claudeEnvironmentProfileLocked = $event" @update:allow-learn="claudeEnvironmentAllowDesktopLearn = $event" @update:family-preference="claudeEnvironmentFamilyPreference = $event" @reset="resetClaudeEnvironmentProfile" + @save-slot="saveClaudeEnvironmentProfileSlot" /> @@ -2420,6 +2424,7 @@ import type { OpenAICompactMode, OpenAIResponsesMode, OpenAIEndpointCapability, + EnvironmentClass, ClaudeEnvironmentProfile, ClaudeEnvironmentProfilePool, CodexEnvironmentProfile, @@ -3753,6 +3758,42 @@ const resetCodexEnvironmentProfile = async () => { } } +const savingProfileSlot = ref(null) + +const saveClaudeEnvironmentProfileSlot = async (slot: string, profile: Record) => { + if (!props.account) return + savingProfileSlot.value = slot + try { + const updatedAccount = await adminAPI.accounts.updateClaudeEnvironmentProfileSlot(props.account.id, { + slot: slot as EnvironmentClass, + profile: profile as Partial + }) + appStore.showSuccess(t('admin.accounts.environmentProfile.slotSaveSuccess')) + emit('updated', updatedAccount) + } catch (error: any) { + appStore.showError(error.message || t('admin.accounts.environmentProfile.slotSaveFailed')) + } finally { + savingProfileSlot.value = null + } +} + +const saveCodexEnvironmentProfileSlot = async (slot: string, profile: Record) => { + if (!props.account) return + savingProfileSlot.value = slot + try { + const updatedAccount = await adminAPI.accounts.updateCodexEnvironmentProfileSlot(props.account.id, { + slot: slot as EnvironmentClass, + profile: profile as Partial + }) + appStore.showSuccess(t('admin.accounts.environmentProfile.slotSaveSuccess')) + emit('updated', updatedAccount) + } catch (error: any) { + appStore.showError(error.message || t('admin.accounts.environmentProfile.slotSaveFailed')) + } finally { + savingProfileSlot.value = null + } +} + const submitUpdateAccount = async (accountID: number, updatePayload: Record) => { submitting.value = true try { diff --git a/frontend/src/components/account/EnvironmentProfileCard.vue b/frontend/src/components/account/EnvironmentProfileCard.vue index 22a576b9a..3847ea678 100644 --- a/frontend/src/components/account/EnvironmentProfileCard.vue +++ b/frontend/src/components/account/EnvironmentProfileCard.vue @@ -99,6 +99,62 @@ > {{ t('admin.accounts.environmentProfile.poolStatus', { count: boundSlotCount, capacity: poolCapacity }) }} + + +
+
+
+ + {{ slotLabel(slot.environment) }} + + +
+ +
+
+ + +

+ {{ slotFieldValue(slot, field.key) || '-' }} +

+
+
+ +
+
+

+ {{ t('admin.accounts.environmentProfile.noProfile') }} +

+
+