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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`),并支持重置与锁定

### 客户端真实性增强

Expand Down
20 changes: 16 additions & 4 deletions backend/internal/handler/admin/account_codex_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions backend/internal/handler/admin/account_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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"`
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions backend/internal/handler/admin/admin_service_stub_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/handler/gateway_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
24 changes: 19 additions & 5 deletions backend/internal/handler/gateway_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions backend/internal/handler/gateway_helper_hotpath_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/pkg/ctxkey/ctxkey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
4 changes: 2 additions & 2 deletions backend/internal/server/api_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/server/routes/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 25 additions & 10 deletions backend/internal/service/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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(关闭)处理。
Expand Down
11 changes: 10 additions & 1 deletion backend/internal/service/account_test_service_openai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading