diff --git a/README.md b/README.md index 11816646a..5bda8bc73 100644 --- a/README.md +++ b/README.md @@ -57,14 +57,14 @@ RUN_MODE=standard ### 环境 Profile 池 -近期任务已将 Claude 与 Codex 环境隔离升级为按并发槽位工作的 Profile 池: - -- 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 槽位。 -- 请求按 linux / windows / macos / desktop 环境绑定槽位;同环境请求优先复用匹配槽位,空槽首次绑定后不自动改绑。 -- 当前凭据冷却、限流或槽位耗尽时,调度可切换到下一个可用凭据的匹配环境槽位。 -- 管理员可在账号 UI 中查看、重置和锁定 Profile 池。 +Claude 与 Codex 环境画像已升级为 **3 OS 槽位冻结式 Profile 池(schema v2)**,对应单凭证多 device_id / 版本不自洽导致的封号风险: + +- 凭证预生成并冻结 **windows / macos / linux** 三个出口槽位,每槽 1 个固定 `device_id` 与自洽的 `(OS, CLI 版本, beta 能力集)` 三元组,终身不变;`desktop` 归并到 windows。 +- 请求按客户端来源 OS 路由到对应槽位;**并发请求复用同一槽位**(如 5 个 windows 请求都走 windows 槽),槽位是共享身份而非互斥资源。 +- 透传路径(真实 Claude Code 客户端)与 mimic 路径**统一收口**:`metadata.user_id.device_id` 与 `anthropic-beta` 强制按槽位冻结值重写,不再透传客户端原值,消除「UA=2.1.161 却声称 2.1.186 beta」的版本不自洽。 +- 学习链路彻底移除,profile 纯模拟生成(`source: simulated`)。 +- 旧账号策略:Claude 旧 schema / 旧 `claude_environment_profile` 账号**不改动**,回退现有逻辑;Codex 旧账号**统一升级迁移**为 v2。 +- 管理员可在账号 UI 中按槽位手工编辑 `device_id` / `client_id` / `UA` / `cli_version` / `beta_set` 等字段(Codex 另有 `originator` / `version` / `tls_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/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/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/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/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..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 @@ -2581,7 +2583,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 +2591,13 @@ func withDefaultEnvironmentProfilePool(platform, accountType string, concurrency for key, value := range extra { merged[key] = value } + // v2 pool 固定 3 OS 槽位冻结,容量与并发解耦,不再依赖 environmentProfileCapacity。 + _ = concurrency if platform == PlatformAnthropic { - merged[claudeEnvironmentProfilePoolKey] = newClaudeEnvironmentProfilePool(concurrency) + merged[claudeEnvironmentProfilePoolKey] = newFrozenClaudeEnvironmentProfilePool(claude.CLICurrentVersion) return merged } - merged[codexEnvironmentProfilePoolKey] = newCodexEnvironmentProfilePool(concurrency) + merged[codexEnvironmentProfilePoolKey] = newFrozenCodexEnvironmentProfilePool() return merged } @@ -2652,15 +2656,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, @@ -2936,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 { @@ -3029,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/admin_service_profile_pool_test.go b/backend/internal/service/admin_service_profile_pool_test.go index 1c85bc07b..2b60ccee8 100644 --- a/backend/internal/service/admin_service_profile_pool_test.go +++ b/backend/internal/service/admin_service_profile_pool_test.go @@ -21,7 +21,7 @@ func (r *createAccountProfilePoolRepo) Create(_ context.Context, account *Accoun } func TestAdminServiceCreateAccountDefaultEnvironmentProfilePool(t *testing.T) { - t.Run("anthropic oauth gets empty claude pool", func(t *testing.T) { + t.Run("anthropic oauth gets frozen v2 claude pool", func(t *testing.T) { repo := &createAccountProfilePoolRepo{} svc := &adminServiceImpl{accountRepo: repo} @@ -39,17 +39,21 @@ func TestAdminServiceCreateAccountDefaultEnvironmentProfilePool(t *testing.T) { pool, err := DecodeClaudeEnvironmentProfilePool(account.Extra[claudeEnvironmentProfilePoolKey]) require.NoError(t, err) require.NotNil(t, pool) - require.Equal(t, 5, pool.Capacity) - require.Len(t, pool.Slots, 5) + // v2: 固定 3 OS 槽位冻结,容量与并发解耦。 + require.True(t, pool.IsV2()) + require.Equal(t, 3, pool.Capacity) + require.Len(t, pool.Slots, 3) for _, slot := range pool.Slots { - require.Equal(t, EnvironmentProfileSlotEmpty, slot.State) - require.Nil(t, slot.Profile) + require.Equal(t, EnvironmentProfileSlotBound, slot.State) + require.NotNil(t, slot.Profile) + require.NotEmpty(t, slot.Profile.DeviceID) + require.Equal(t, claudeEnvironmentProfileSourceSimulated, slot.Profile.Source) } require.NotContains(t, account.Extra, claudeSingleEnvironmentKey) require.NotContains(t, account.Extra, claudeEnvironmentProfileLockedKey) }) - t.Run("openai oauth gets empty codex pool", func(t *testing.T) { + t.Run("openai oauth gets frozen v2 codex pool", func(t *testing.T) { repo := &createAccountProfilePoolRepo{} svc := &adminServiceImpl{accountRepo: repo} @@ -66,16 +70,42 @@ func TestAdminServiceCreateAccountDefaultEnvironmentProfilePool(t *testing.T) { pool, err := DecodeCodexEnvironmentProfilePool(account.Extra[codexEnvironmentProfilePoolKey]) require.NoError(t, err) require.NotNil(t, pool) + require.True(t, pool.IsV2()) require.Equal(t, 3, pool.Capacity) require.Len(t, pool.Slots, 3) for _, slot := range pool.Slots { - require.Equal(t, EnvironmentProfileSlotEmpty, slot.State) - require.Nil(t, slot.Profile) + require.Equal(t, EnvironmentProfileSlotBound, slot.State) + require.NotNil(t, slot.Profile) + require.NotEmpty(t, slot.Profile.SessionSeed) + require.Equal(t, codexEnvironmentProfileSourceSimulated, slot.Profile.Source) } require.NotContains(t, account.Extra, codexSingleEnvironmentKey) require.NotContains(t, account.Extra, codexEnvironmentProfileLockedKey) }) + t.Run("openai oauth codex tier still gets fixed 3 v2 slots", 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) + // v2: 即使高 tier 也固定 3 槽(容量与 tier 解耦)。 + require.True(t, pool.IsV2()) + require.Equal(t, 3, pool.Capacity) + require.Len(t, pool.Slots, 3) + }) + t.Run("preserves explicit disabled single environment", func(t *testing.T) { repo := &createAccountProfilePoolRepo{} svc := &adminServiceImpl{accountRepo: repo} @@ -96,7 +126,7 @@ func TestAdminServiceCreateAccountDefaultEnvironmentProfilePool(t *testing.T) { }) t.Run("does not overwrite existing pool", func(t *testing.T) { - existing := newCodexEnvironmentProfilePool(2) + existing := newFrozenCodexEnvironmentProfilePool() repo := &createAccountProfilePoolRepo{} svc := &adminServiceImpl{accountRepo: repo} 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_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/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..68ba8d6cd 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 @@ -35,9 +50,6 @@ func DecodeClaudeEnvironmentProfilePool(raw any) (*ClaudeEnvironmentProfilePool, if pool, ok := raw.(*ClaudeEnvironmentProfilePool); ok { return pool, nil } - if pool, ok := raw.(ClaudeEnvironmentProfilePool); ok { - return &pool, nil - } encoded, err := json.Marshal(raw) if err != nil { return nil, err @@ -251,6 +263,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 +348,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 +438,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..61d48a0ed --- /dev/null +++ b/backend/internal/service/claude_environment_profile_pool_test.go @@ -0,0 +1,181 @@ +package service + +import ( + "context" + "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(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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/codex_environment_profile.go b/backend/internal/service/codex_environment_profile.go index 50e489df0..a0dde9fbd 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 @@ -32,7 +33,6 @@ const ( CodexClientFamilyCLI CodexClientFamily = "cli" CodexClientFamilyDesktop CodexClientFamily = "desktop" CodexClientFamilyVSCode CodexClientFamily = "vscode" - CodexClientFamilyCustom CodexClientFamily = "custom" ) type CodexEnvironmentProfile struct { @@ -48,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"` } @@ -349,8 +350,6 @@ func normalizeCodexClientFamily(family CodexClientFamily) CodexClientFamily { return CodexClientFamilyDesktop case CodexClientFamilyVSCode: return CodexClientFamilyVSCode - case CodexClientFamilyCustom: - return CodexClientFamilyCustom default: return "" } @@ -362,7 +361,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") @@ -542,9 +541,7 @@ func logCodexEnvironmentFamilyMismatch(ctx context.Context, account *Account, pr } func codexProfileLogger(ctx context.Context) *zap.Logger { - if ctx == nil { - ctx = context.Background() - } + _ = ctx return zap.L().WithOptions(zap.AddCallerSkip(1)) } diff --git a/backend/internal/service/codex_environment_profile_pool.go b/backend/internal/service/codex_environment_profile_pool.go index 289ba05ae..1136a5797 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 @@ -35,9 +45,6 @@ func DecodeCodexEnvironmentProfilePool(raw any) (*CodexEnvironmentProfilePool, e if pool, ok := raw.(*CodexEnvironmentProfilePool); ok { return pool, nil } - if pool, ok := raw.(CodexEnvironmentProfilePool); ok { - return &pool, nil - } encoded, err := json.Marshal(raw) if err != nil { return nil, err @@ -261,6 +268,60 @@ 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 +} + func (s *OpenAIGatewayService) acquireCodexEnvironmentProfileForRequest(ctx context.Context, account *Account, headers http.Header) (*EnvironmentProfileSlotLease, *CodexEnvironmentProfile, error) { if s == nil { return nil, nil, nil @@ -310,24 +371,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/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..7ab4d652c 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 { @@ -231,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/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..be49a8015 100644 --- a/backend/internal/service/environment_profile_test.go +++ b/backend/internal/service/environment_profile_test.go @@ -111,7 +111,9 @@ func TestClaudeEnvironmentProfileCreatesDefaultOnce(t *testing.T) { stored, ok := repo.account.GetClaudeEnvironmentProfile() require.True(t, ok) require.Equal(t, profile.ClientID, stored.ClientID) - require.True(t, repo.account.Extra[claudeEnvironmentProfileLockedKey].(bool)) + locked, ok := repo.account.Extra[claudeEnvironmentProfileLockedKey].(bool) + require.True(t, ok) + require.True(t, locked) require.Equal(t, 1, repo.updateCount()) again, err := svc.getOrCreateClaudeEnvironmentProfile(context.Background(), account, http.Header{"User-Agent": []string{"Claude Desktop"}}, nil) @@ -125,7 +127,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 +152,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, @@ -220,7 +270,9 @@ func TestCodexEnvironmentProfileCreatesDefaultOnce(t *testing.T) { storedCodex, ok := repo.account.GetCodexEnvironmentProfile() require.True(t, ok) require.Equal(t, profile.SessionSeed, storedCodex.SessionSeed) - require.True(t, repo.account.Extra[codexEnvironmentProfileLockedKey].(bool)) + codexLocked, ok := repo.account.Extra[codexEnvironmentProfileLockedKey].(bool) + require.True(t, ok) + require.True(t, codexLocked) require.Equal(t, 1, repo.updateCount()) again, err := svc.getOrCreateCodexEnvironmentProfile(context.Background(), account, http.Header{"originator": []string{"codex_chatgpt_desktop"}}) diff --git a/backend/internal/service/gateway_context_management_test.go b/backend/internal/service/gateway_context_management_test.go index 51b12809e..20caab5d9 100644 --- a/backend/internal/service/gateway_context_management_test.go +++ b/backend/internal/service/gateway_context_management_test.go @@ -139,7 +139,7 @@ func newTestGatewayServiceForBeta(injectBetaForAPIKey bool) *GatewayService { func TestComputeFinalAnthropicBeta_OAuthMimic_NonHaiku_IncludesContextManagement(t *testing.T) { s := newTestGatewayServiceForBeta(false) - final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-4-6", http.Header{}, []byte(`{}`), nil) + final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-4-6", http.Header{}, []byte(`{}`), nil, nil) require.True(t, ok) require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement), "OAuth mimic non-haiku 必须注入完整 CC mimicry beta,含 context-management-2025-06-27") @@ -149,7 +149,7 @@ func TestComputeFinalAnthropicBeta_OAuthMimic_NonHaiku_IncludesContextManagement func TestComputeFinalAnthropicBeta_OAuthMimic_Haiku_ExcludesContextManagement(t *testing.T) { s := newTestGatewayServiceForBeta(false) - final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil) + final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil, nil) require.True(t, ok) require.False(t, anthropicBetaTokensContains(final, claude.BetaContextManagement), "OAuth mimic haiku 仅注入 oauth + interleaved-thinking,不含 context-management") @@ -162,7 +162,7 @@ func TestComputeFinalAnthropicBeta_OAuthMimic_IgnoresClientBeta(t *testing.T) { s := newTestGatewayServiceForBeta(false) hdr := http.Header{} hdr.Set("anthropic-beta", "custom-experimental-beta") - final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-4-6", hdr, []byte(`{}`), nil) + final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-4-6", hdr, []byte(`{}`), nil, nil) require.True(t, ok) require.False(t, strings.Contains(final, "custom-experimental-beta"), "mimic 路径必须忽略客户端 anthropic-beta header") @@ -173,7 +173,7 @@ func TestComputeFinalAnthropicBeta_OAuthTransparent_NonHaiku_PreservesClientCont s := newTestGatewayServiceForBeta(false) hdr := http.Header{} hdr.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,context-management-2025-06-27") - final, ok := s.computeFinalAnthropicBeta("oauth", false, "claude-sonnet-4-6", hdr, []byte(`{}`), nil) + final, ok := s.computeFinalAnthropicBeta("oauth", false, "claude-sonnet-4-6", hdr, []byte(`{}`), nil, nil) require.True(t, ok) require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement)) } @@ -184,7 +184,7 @@ func TestComputeFinalAnthropicBeta_OAuthTransparent_Haiku_RealCCPreservesContext s := newTestGatewayServiceForBeta(false) hdr := http.Header{} hdr.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,context-management-2025-06-27,interleaved-thinking-2025-05-14") - final, ok := s.computeFinalAnthropicBeta("oauth", false, "claude-haiku-4-5", hdr, []byte(`{}`), nil) + final, ok := s.computeFinalAnthropicBeta("oauth", false, "claude-haiku-4-5", hdr, []byte(`{}`), nil, nil) require.True(t, ok) require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement), "真 CC + haiku + 客户端带 context-management beta → 透传必须保留") @@ -194,7 +194,7 @@ func TestComputeFinalAnthropicBeta_APIKey_PassesClientBetaThroughDropSet(t *test s := newTestGatewayServiceForBeta(false) hdr := http.Header{} hdr.Set("anthropic-beta", "oauth-2025-04-20,custom-beta") - final, ok := s.computeFinalAnthropicBeta("apikey", false, "claude-sonnet-4-6", hdr, []byte(`{}`), nil) + final, ok := s.computeFinalAnthropicBeta("apikey", false, "claude-sonnet-4-6", hdr, []byte(`{}`), nil, nil) require.True(t, ok) require.True(t, anthropicBetaTokensContains(final, "oauth-2025-04-20")) require.True(t, anthropicBetaTokensContains(final, "custom-beta")) @@ -202,7 +202,7 @@ func TestComputeFinalAnthropicBeta_APIKey_PassesClientBetaThroughDropSet(t *test func TestComputeFinalAnthropicBeta_APIKey_NoClientBetaInjectOff_ShouldNotSet(t *testing.T) { s := newTestGatewayServiceForBeta(false) - final, ok := s.computeFinalAnthropicBeta("apikey", false, "claude-sonnet-4-6", http.Header{}, []byte(`{}`), nil) + final, ok := s.computeFinalAnthropicBeta("apikey", false, "claude-sonnet-4-6", http.Header{}, []byte(`{}`), nil, nil) require.False(t, ok, "API-key + 客户端未传 + InjectBetaForAPIKey 关 → 不应主动设置 anthropic-beta") require.Equal(t, "", final) } @@ -214,7 +214,7 @@ func TestComputeFinalAnthropicBeta_APIKey_NoClientBetaInjectOff_ShouldNotSet(t * func TestComputeFinalCountTokensAnthropicBeta_OAuthMimic_AlwaysIncludesContextManagement(t *testing.T) { // count_tokens 路径下 mimic 不按 haiku 排除:始终注入完整 mimicry beta s := newTestGatewayServiceForBeta(false) - final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", true, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil) + final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", true, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil, nil) require.True(t, ok) require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement), "count_tokens + mimic 即使 haiku 也注入 context-management beta(与 messages 不同)") @@ -230,7 +230,7 @@ func TestComputeFinalCountTokensAnthropicBeta_OAuthMimic_PreservesClientBeta(t * s := newTestGatewayServiceForBeta(false) hdr := http.Header{} hdr.Set("anthropic-beta", "custom-experimental-beta,context-1m-2025-08-07") - final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", true, "claude-haiku-4-5", hdr, []byte(`{}`), nil) + final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", true, "claude-haiku-4-5", hdr, []byte(`{}`), nil, nil) require.True(t, ok) require.True(t, anthropicBetaTokensContains(final, "custom-experimental-beta"), "count_tokens mimic 不同于 messages mimic:原代码会保留客户端透传的 beta") @@ -249,7 +249,7 @@ func TestComputeFinalAnthropicBeta_OAuthMimic_IgnoresClientBetaExplicit(t *testi s := newTestGatewayServiceForBeta(false) hdr := http.Header{} hdr.Set("anthropic-beta", "custom-experimental-beta") - final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-4-6", hdr, []byte(`{}`), nil) + final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-4-6", hdr, []byte(`{}`), nil, nil) require.True(t, ok) require.False(t, anthropicBetaTokensContains(final, "custom-experimental-beta"), "messages mimic 原代码跳过白名单透传 → 客户端 beta 不进入计算。"+ @@ -259,7 +259,7 @@ func TestComputeFinalAnthropicBeta_OAuthMimic_IgnoresClientBetaExplicit(t *testi func TestComputeFinalCountTokensAnthropicBeta_OAuthTransparent_NoClientBetaInjectsDefault(t *testing.T) { // 真 CC 客户端透传 + 客户端未传 anthropic-beta → 用 CountTokensBetaHeader 兜底 s := newTestGatewayServiceForBeta(false) - final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", false, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil) + final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", false, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil, nil) require.True(t, ok) require.Equal(t, claude.CountTokensBetaHeader, final) // CountTokensBetaHeader 不含 context-management beta @@ -270,7 +270,7 @@ func TestComputeFinalCountTokensAnthropicBeta_OAuthTransparent_AppendsBetaTokenC s := newTestGatewayServiceForBeta(false) hdr := http.Header{} hdr.Set("anthropic-beta", "oauth-2025-04-20,context-management-2025-06-27") - final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", false, "claude-sonnet-4-6", hdr, []byte(`{}`), nil) + final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", false, "claude-sonnet-4-6", hdr, []byte(`{}`), nil, nil) require.True(t, ok) require.True(t, anthropicBetaTokensContains(final, claude.BetaTokenCounting), "客户端未带 token-counting beta 时必须补齐") 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..e4a7a3535 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 { @@ -5474,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() @@ -6765,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 { @@ -6795,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 对称 @@ -7119,6 +7135,7 @@ func (s *GatewayService) computeFinalAnthropicBeta( clientHeaders http.Header, body []byte, effectiveDropSet map[string]struct{}, + slotProfile *ClaudeEnvironmentProfile, ) (string, bool) { clientBeta := "" if clientHeaders != nil { @@ -7135,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 } @@ -7170,6 +7192,7 @@ func (s *GatewayService) computeFinalCountTokensAnthropicBeta( clientHeaders http.Header, body []byte, effectiveDropSet map[string]struct{}, + slotProfile *ClaudeEnvironmentProfile, ) (string, bool) { clientBeta := "" if clientHeaders != nil { @@ -7185,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 } @@ -9865,7 +9896,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 { @@ -10297,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 { @@ -10317,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 对称 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/backend/internal/service/version_fetcher_service.go b/backend/internal/service/version_fetcher_service.go index dd1824cbc..75b2d32a5 100644 --- a/backend/internal/service/version_fetcher_service.go +++ b/backend/internal/service/version_fetcher_service.go @@ -169,7 +169,7 @@ func (s *VersionFetcherService) fetchCodexVersion(ctx context.Context) (string, if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("github API status %d", resp.StatusCode) @@ -205,7 +205,7 @@ func (s *VersionFetcherService) fetchNPMLatest(ctx context.Context, pkg string) if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("npm registry status %d", resp.StatusCode) @@ -242,7 +242,7 @@ func (s *VersionFetcherService) fetchClaudeCodeSDKDependency(ctx context.Context if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("npm package status %d", resp.StatusCode) 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/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..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" /> @@ -2344,9 +2348,8 @@ - + { @@ -2626,11 +2628,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 +3017,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 @@ -3756,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 fcb7c257f..3847ea678 100644 --- a/frontend/src/components/account/EnvironmentProfileCard.vue +++ b/frontend/src/components/account/EnvironmentProfileCard.vue @@ -99,19 +99,60 @@ > {{ t('admin.accounts.environmentProfile.poolStatus', { count: boundSlotCount, capacity: poolCapacity }) }} -
+ + +
-
- {{ slot.title }} - {{ slot.state }} +
+ + {{ slotLabel(slot.environment) }} + +
-
- {{ slot.detail }} + +
+
+ + +

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

+
+
+ +
+

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

@@ -148,9 +189,9 @@