diff --git a/.gitignore b/.gitignore index b187e7c42..82130f52c 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,8 @@ grok-build-main/ .superpowers/ CLAUDE.md .cursor -diagrams/ \ No newline at end of file +diagrams/ +# local run artifacts +/data/ +/codex2api_local +/server.log diff --git a/admin/account_analysis.go b/admin/account_analysis.go index 8223f619d..05980977c 100644 --- a/admin/account_analysis.go +++ b/admin/account_analysis.go @@ -314,7 +314,7 @@ func buildAccountQuotaAnalysis(items []*accountListSnapshotItem, window string) } totalUsed := 0.0 for _, item := range items { - if item.Status == "unauthorized" || item.Status == "error" || item.OpenAIResponses || (window == "5h" && !accountListSubscriptionPlan(item.PlanType)) { + if item.Status == "unauthorized" || item.Status == "error" || item.OpenAIResponses || (window == "5h" && !accountList5hQuotaEligible(item)) { continue } result.Total++ @@ -431,7 +431,7 @@ func buildAccountResetAnalysis(items []*accountListSnapshotItem, now time.Time) func accountRecoveryAt(item *accountListSnapshotItem, window string, now time.Time) (time.Time, bool) { if window == "5h" { - if accountListSubscriptionPlan(item.PlanType) && item.UsagePercent5hOK && item.UsagePercent5h >= 100 && item.Reset5hAt.After(now) { + if accountList5hQuotaEligible(item) && item.UsagePercent5hOK && item.UsagePercent5h >= 100 && item.Reset5hAt.After(now) { return item.Reset5hAt, false } if item.CooldownUntil.After(now) && accountAnalysisShortRateLimited(item) { @@ -463,7 +463,7 @@ func accountAnalysisShortRateLimited(item *accountListSnapshotItem) bool { func accountAnalysisWindowRateLimited(item *accountListSnapshotItem, window string) bool { if window == "5h" { - return (accountListSubscriptionPlan(item.PlanType) && item.UsagePercent5hOK && item.UsagePercent5h >= 100) || accountAnalysisShortRateLimited(item) + return (accountList5hQuotaEligible(item) && item.UsagePercent5hOK && item.UsagePercent5h >= 100) || accountAnalysisShortRateLimited(item) } status := strings.ToLower(item.Status) reason := strings.ToLower(item.CooldownReason) @@ -476,7 +476,7 @@ func accountAnalysisHasBurnPrediction(item *accountListSnapshotItem, window stri return false } if window == "5h" { - return accountListSubscriptionPlan(item.PlanType) && item.UsagePercent5hOK + return accountList5hQuotaEligible(item) && item.UsagePercent5hOK } return true } diff --git a/admin/account_groups.go b/admin/account_groups.go index 016b2b41f..8675f1c46 100644 --- a/admin/account_groups.go +++ b/admin/account_groups.go @@ -474,6 +474,9 @@ func accountRowGroupChannel(row *database.AccountRow) string { if row != nil && strings.EqualFold(strings.TrimSpace(row.GetCredential("upstream_type")), auth.UpstreamAntigravity) { return database.AccountGroupChannelAntigravity } + if row != nil && strings.EqualFold(strings.TrimSpace(row.GetCredential("upstream_type")), auth.UpstreamClaude) { + return database.AccountGroupChannelClaude + } if isGrokAccountRow(row) { return database.AccountGroupChannelGrok } @@ -521,6 +524,8 @@ func groupChannelDisplayName(channel string) string { return "Grok" case database.AccountGroupChannelAntigravity: return "Antigravity" + case database.AccountGroupChannelClaude: + return "Claude" default: return "Codex" } diff --git a/admin/account_response_builder.go b/admin/account_response_builder.go index 43d132b3b..47c1f22fc 100644 --- a/admin/account_response_builder.go +++ b/admin/account_response_builder.go @@ -62,6 +62,7 @@ func (h *Handler) buildAccountResponse( isOpenAIResponsesAccount := strings.EqualFold(upstreamType, auth.UpstreamOpenAIResponses) isGrokAccount := strings.EqualFold(upstreamType, auth.UpstreamGrok) isAntigravityAccount := strings.EqualFold(upstreamType, auth.UpstreamAntigravity) + isClaudeAccount := strings.EqualFold(upstreamType, auth.UpstreamClaude) antigravityAuthKind := "" if isAntigravityAccount { if strings.TrimSpace(row.GetCredential("api_key")) != "" { @@ -128,9 +129,16 @@ func (h *Handler) buildAccountResponse( } // 指纹收敛只作用于 Codex 官方出站路径,中转/Grok 账号不暴露该字段。 codexFingerprintMode := "" - if !isOpenAIResponsesAccount && !isGrokAccount && !isAntigravityAccount { + if !isOpenAIResponsesAccount && !isGrokAccount && !isAntigravityAccount && !isClaudeAccount { codexFingerprintMode = auth.NormalizeCodexFingerprintMode(row.GetCredential(auth.CodexFingerprintModeCredentialKey)) } + // Claude Code 指纹收敛模式 + 绑定时区,仅 Claude OAuth 账号暴露。 + claudeFingerprintMode := "" + accountTimezone := "" + if strings.EqualFold(strings.TrimSpace(row.GetCredential("upstream_type")), auth.UpstreamClaude) { + claudeFingerprintMode = auth.NormalizeClaudeFingerprintMode(row.GetCredential(auth.ClaudeFingerprintModeCredentialKey)) + accountTimezone = strings.TrimSpace(row.GetCredential("timezone")) + } ignoreUsageLimitStatusOverride := row.GetCredentialOptionalBool("ignore_usage_limit_status_override") ignoreUsageLimitStatusEffective := h.store.IgnoreUsageLimitStatus() if ignoreUsageLimitStatusOverride != nil { @@ -139,6 +147,7 @@ func (h *Handler) buildAccountResponse( modelMapping := "" var customHeaders map[string]string var allowedAPIKeyIDs []int64 + claudeUserAgent := "" // 工作区 ID 不是密钥:Team/K12 徽章悬停要显示空间 ID。当前页 // ListActiveByIDs 已带完整凭据;custom_headers 只用来算生效空间, // 摘要响应仍会剥掉原文。 @@ -148,7 +157,21 @@ func (h *Handler) buildAccountResponse( effectiveWorkspaceID := openaiidentity.EffectiveWorkspaceID(tokenWorkspaceID, headers) if includeDetails { modelMapping = row.GetCredential("model_mapping") - customHeaders = headers + if isClaudeAccount { + // Claude detail responses may be consumed by admin tooling, but must + // never expose arbitrary historical custom headers such as + // Authorization/Cookie/x-api-key. Keep only the provider identity + // headers needed to inspect the stable fingerprint. + customHeaders = claudeExportFingerprintHeaders(headers) + for name, value := range customHeaders { + if strings.EqualFold(strings.TrimSpace(name), "user-agent") { + claudeUserAgent = strings.TrimSpace(value) + break + } + } + } else { + customHeaders = headers + } allowedAPIKeyIDs = row.GetCredentialInt64Slice("allowed_api_key_ids") } resp := accountResponse{ @@ -165,7 +188,7 @@ func (h *Handler) buildAccountResponse( SubscriptionExpiresAt: row.GetCredential("subscription_expires_at"), Status: row.Status, ErrorMessage: row.ErrorMessage, - ATOnly: !isOpenAIResponsesAccount && !isGrokAccount && !isAntigravityAccount && row.GetCredential("refresh_token") == "" && row.GetCredential("access_token") != "", + ATOnly: !isOpenAIResponsesAccount && !isGrokAccount && !isAntigravityAccount && !isClaudeAccount && row.GetCredential("refresh_token") == "" && row.GetCredential("access_token") != "", CreditEnabled: row.CreditEnabled, CreditSkipUsageWindow: row.CreditSkipUsageWindow, SkipWarmTier: row.SkipWarmTier, @@ -174,6 +197,7 @@ func (h *Handler) buildAccountResponse( OpenAIResponsesAPI: isOpenAIResponsesAccount, GrokAPI: isGrokAccount, AntigravityAPI: isAntigravityAccount, + ClaudeAPI: isClaudeAccount, AntigravityAuthKind: antigravityAuthKind, AgentIdentity: isAgentIdentityCredentialRow(row), GrokAuthKind: grokAuthKind, @@ -191,6 +215,9 @@ func (h *Handler) buildAccountResponse( ModelMapping: modelMapping, CodexClientMetadataMode: codexClientMetadataMode, CodexFingerprintMode: codexFingerprintMode, + ClaudeFingerprintMode: claudeFingerprintMode, + ClaudeUserAgent: claudeUserAgent, + Timezone: accountTimezone, CustomHeaders: customHeaders, ProxyURL: row.ProxyURL, Enabled: row.Enabled, @@ -206,6 +233,8 @@ func (h *Handler) buildAccountResponse( UpdatedAt: row.UpdatedAt.Format(time.RFC3339), CodexUsageUpdatedAt: row.GetCredential("codex_usage_updated_at"), Codex5HUsageUpdatedAt: row.GetCredential("codex_5h_usage_updated_at"), + ClaudeUsageProbeAt: row.GetCredential(auth.ClaudeUsageProbeAtCredentialKey), + ClaudeUsageProbeError: row.GetCredential(auth.ClaudeUsageProbeErrorCredentialKey), UsageLimitOverride: ignoreUsageLimitStatusOverride, UsageLimitEffective: ignoreUsageLimitStatusEffective, } diff --git a/admin/accounts_paged.go b/admin/accounts_paged.go index 572a3b2d8..a4802fcd3 100644 --- a/admin/accounts_paged.go +++ b/admin/accounts_paged.go @@ -100,6 +100,9 @@ type accountListSnapshotItem struct { DynamicConcurrency int64 OpenAIResponses bool Antigravity bool + Claude bool + ClaudeUsageProbeAt string + ClaudeUsageProbeErr string SearchText string } @@ -213,8 +216,8 @@ func (h *Handler) resolveAccountOperationSelector(ctx context.Context, selector return nil, fmt.Errorf("selector is required") } channel := strings.ToLower(strings.TrimSpace(selector.Channel)) - if channel != database.UpstreamChannelCodex && channel != database.UpstreamChannelGrok && channel != database.UpstreamChannelAntigravity { - return nil, fmt.Errorf("selector channel must be codex, grok, or antigravity") + if channel != database.UpstreamChannelCodex && channel != database.UpstreamChannelGrok && channel != database.UpstreamChannelAntigravity && channel != database.UpstreamChannelClaude { + return nil, fmt.Errorf("selector channel must be codex, grok, antigravity, or claude") } snapshot, err := h.getAccountListSnapshot(ctx, channel) if err != nil { @@ -244,7 +247,7 @@ func (h *Handler) resolveAccountOperationSelector(ctx context.Context, selector continue } } - if selector.SubscriptionUnlocked && (!accountListSubscriptionPlan(item.PlanType) || item.Locked) { + if selector.SubscriptionUnlocked && !accountListSubscriptionUnlocked(item, channel) { continue } ids = append(ids, item.ID) @@ -573,6 +576,7 @@ func isAccountListDeletePath(method, path string) bool { // 的读路径会把变更前的统计卡/筛选计数原样返回给变更后的第一次刷新。 func (h *Handler) invalidateAccountSnapshotCaches() { h.accountCachesGen.Add(1) + h.claudeAccountCachesGen.Add(1) h.accountListCacheMu.Lock() h.accountListCache = nil h.accountListCacheMu.Unlock() @@ -629,6 +633,7 @@ func (h *Handler) pruneAccountsFromSnapshotCaches(ids []int64) { func (h *Handler) rebuildAccountListSnapshot(ctx context.Context, channel string) (*accountListSnapshot, error) { gen := h.accountCachesGen.Load() + claudeGen := h.claudeAccountCachesGen.Load() rows, err := h.db.ListAccountListProjection(ctx, channel) if err != nil { return nil, err @@ -658,16 +663,23 @@ func (h *Handler) rebuildAccountListSnapshot(ctx context.Context, channel string } snapshot.ExpiresAt = snapshot.BuiltAt.Add(snapshotTTL) snapshot.Summary, snapshot.Facets = summarizeAccountList(items, channel) - h.installAccountListSnapshot(channel, snapshot, gen) + h.installAccountListSnapshot(channel, snapshot, gen, claudeGen) return snapshot, nil } // installAccountListSnapshot 只在代数未漂移时入缓存:读库期间发生过账号 // 变更的快照可能早于变更,返回给当前调用方无妨,但不能留给后续请求。 -func (h *Handler) installAccountListSnapshot(channel string, snapshot *accountListSnapshot, gen uint64) { +func (h *Handler) installAccountListSnapshot(channel string, snapshot *accountListSnapshot, gen uint64, claudeGens ...uint64) { if h.accountCachesGen.Load() != gen { return } + claudeGen := h.claudeAccountCachesGen.Load() + if len(claudeGens) > 0 { + claudeGen = claudeGens[0] + } + if channel == database.UpstreamChannelClaude && h.claudeAccountCachesGen.Load() != claudeGen { + return + } h.accountListCacheMu.Lock() if h.accountListCache == nil { h.accountListCache = make(map[string]*accountListSnapshot) @@ -681,6 +693,7 @@ func (h *Handler) buildAccountListSnapshotItem(row *database.AccountRow, request isGrok := strings.EqualFold(upstreamType, auth.UpstreamGrok) isAntigravity := strings.EqualFold(upstreamType, auth.UpstreamAntigravity) isOpenAIResponses := strings.EqualFold(upstreamType, auth.UpstreamOpenAIResponses) + isClaude := strings.EqualFold(upstreamType, auth.UpstreamClaude) email := row.GetCredential("email") if isOpenAIResponses && email == "" { email = row.GetCredential("base_url") @@ -707,7 +720,9 @@ func (h *Handler) buildAccountListSnapshotItem(row *database.AccountRow, request Enabled: row.Enabled, Locked: row.Locked, PlanType: planType, GrokAuthKind: grokAuthKind, Email: email, EmailDomain: accountEmailDomain(email), Tags: append([]string(nil), row.Tags...), SchedulerPriority: valueOrZero(accountSchedulerPriority(row)), OpenAIResponses: isOpenAIResponses, - Antigravity: isAntigravity, + Antigravity: isAntigravity, Claude: isClaude, + ClaudeUsageProbeAt: row.GetCredential(auth.ClaudeUsageProbeAtCredentialKey), + ClaudeUsageProbeErr: row.GetCredential(auth.ClaudeUsageProbeErrorCredentialKey), } if row.CooldownUntil.Valid { item.CooldownUntil = row.CooldownUntil.Time @@ -786,6 +801,11 @@ func (h *Handler) buildAccountListSnapshotItem(row *database.AccountRow, request item.PlanType, item.GrokPlanCategory, row.ErrorMessage, row.ProxyURL, strings.Join(groupLabels, " ")) } else if isAntigravity { searchParts = append(searchParts, item.PlanType, row.GetCredential("project_id"), row.GetCredential("antigravity_sync_error"), strings.Join(groupLabels, " ")) + } else if isClaude { + searchParts = append(searchParts, + strings.Join(row.GetCredentialStringSlice("models"), " "), row.GetCredential("base_url"), + item.PlanType, row.GetCredential(auth.ClaudeUsageProbeErrorCredentialKey), row.ErrorMessage, + row.ProxyURL, strings.Join(groupLabels, " ")) } item.SearchText = strings.ToLower(strings.Join(searchParts, " ")) return item @@ -1046,6 +1066,14 @@ func (h *Handler) storeRequestCountCache(channel string, counts map[int64]*datab // expireAccountListSnapshot 把指定渠道的列表快照标记为过期,但保留内容: // 读路径仍按 stale-while-revalidate 先返回旧值,只是下一次读取会立刻触发重建。 func (h *Handler) expireAccountListSnapshot(channel string) { + // Invalidate in-flight rebuilds as well as the cached TTL. A probe may + // finish while an older projection query is still running; without a new + // generation that stale query could reinstall the pre-probe metadata. + if channel == database.UpstreamChannelClaude { + h.claudeAccountCachesGen.Add(1) + } else { + h.accountCachesGen.Add(1) + } h.accountListCacheMu.Lock() if cached := h.accountListCache[channel]; cached != nil { cached.ExpiresAt = time.Time{} @@ -1185,7 +1213,12 @@ func accountListUnsampled(item *accountListSnapshotItem) bool { return false } // k12 等 team 型工作区可能只返回 5h 窗口:任一窗口有数据即算已采样。 - return !item.UsagePercent5hOK && !item.UsagePercent7dOK + if item.UsagePercent5hOK || item.UsagePercent7dOK { + return false + } + // Claude 的 native Messages 端点可能合法地省略统一配额头;一次成功 + // 的 provider-native probe 仍代表账号已采样,只是配额未知。 + return item.ClaudeUsageProbeAt == "" || item.ClaudeUsageProbeErr != "" } func accountListNormal(item *accountListSnapshotItem) bool { @@ -1392,6 +1425,9 @@ func summarizeAccountList(items []*accountListSnapshotItem, channel string) (acc if item.GrokAuthKind == auth.GrokAuthKindAPIKey { summary.APIKey++ } + if item.Claude { + summary.OAuth++ + } if channel == database.UpstreamChannelCodex { if item.OpenAIResponses { summary.APIKey++ @@ -1399,7 +1435,7 @@ func summarizeAccountList(items []*accountListSnapshotItem, channel string) (acc summary.OAuth++ } } - if channel == database.UpstreamChannelCodex && accountListSubscriptionPlan(item.PlanType) && !item.Locked { + if accountListSubscriptionUnlocked(item, channel) { summary.SubscriptionUnlocked++ } if !item.LastUnauthorizedAt.IsZero() && now.Sub(item.LastUnauthorizedAt) <= 24*time.Hour { @@ -1482,3 +1518,42 @@ func accountListSubscriptionPlan(plan string) bool { return false } } + +// accountListSubscriptionUnlocked applies the subscription filter using the +// provider's own plan vocabulary. Codex and Claude expose different plan +// names, while relay/auxiliary providers have no subscription semantics in +// this list. Keeping the channel check here prevents a generic selector from +// accidentally treating another provider's plan as a Codex entitlement. +func accountListSubscriptionUnlocked(item *accountListSnapshotItem, channel string) bool { + if item == nil || item.Locked { + return false + } + switch channel { + case database.UpstreamChannelCodex: + return accountListSubscriptionPlan(item.PlanType) + case database.UpstreamChannelClaude: + return accountList5hQuotaEligible(item) + default: + return false + } +} + +// accountList5hQuotaEligible keeps provider-specific subscription semantics in +// one place. Claude OAuth plans (pro/max-5x/max-20x/team) expose a rolling 5h +// window even though they are not Codex plan names. +func accountList5hQuotaEligible(item *accountListSnapshotItem) bool { + if item == nil { + return false + } + if item.Claude || (item.Row != nil && strings.EqualFold(strings.TrimSpace(item.Row.GetCredential("upstream_type")), auth.UpstreamClaude)) { + plan := strings.ToLower(strings.TrimSpace(item.PlanType)) + switch plan { + case "claude", "pro", "max", "max-5x", "max-20x", "team", "enterprise", "business", + "claude-pro", "claude-max", "claude-max-5x", "claude-max-20x", "claude-team", "claude-enterprise", "claude-business": + return true + default: + return false + } + } + return accountListSubscriptionPlan(item.PlanType) +} diff --git a/admin/accounts_paged_test.go b/admin/accounts_paged_test.go index 094e4caac..e2d722c68 100644 --- a/admin/accounts_paged_test.go +++ b/admin/accounts_paged_test.go @@ -50,6 +50,9 @@ func newPagedAccountsHandler(t *testing.T) (*Handler, []int64, []int64) { if err := store.Init(ctx); err != nil { t.Fatalf("store.Init: %v", err) } + // Store.Init starts the scheduler outbox consumer. Stop it before the + // database cleanup so a late poll cannot outlive the test database. + t.Cleanup(func() { store.Stop() }) tokenCache := cache.NewMemory(1) t.Cleanup(func() { _ = tokenCache.Close() }) return NewHandler(store, db, tokenCache, nil, ""), codexIDs, grokIDs @@ -557,6 +560,26 @@ func TestBuildAccountQuotaAnalysisExcludesErrorFromUnsampled(t *testing.T) { } } +func TestBuildAccountQuotaAnalysisTreatsClaudePlanAsFiveHourEligible(t *testing.T) { + item := &accountListSnapshotItem{PlanType: "claude-max-5x", UsagePercent5h: 42, UsagePercent5hOK: true, + UsagePercent7d: 61, UsagePercent7dOK: true, Row: &database.AccountRow{Credentials: map[string]interface{}{"upstream_type": auth.UpstreamClaude}}} + got := buildAccountQuotaAnalysis([]*accountListSnapshotItem{item}, "5h") + if got.Total != 1 || got.Sampled != 1 || got.AverageUsed == nil || *got.AverageUsed != 42 { + t.Fatalf("Claude 5h quota = %+v, want sampled Claude account", got) + } +} + +func TestBuildAccountQuotaAnalysisDoesNotTreatClaudeFreeOrUnknownAsFiveHourEligible(t *testing.T) { + for _, plan := range []string{"free", "", "mystery-tier", "claude-free", "claude-unknown"} { + item := &accountListSnapshotItem{PlanType: plan, UsagePercent5h: 42, UsagePercent5hOK: true, + Row: &database.AccountRow{Credentials: map[string]interface{}{"upstream_type": auth.UpstreamClaude}}} + got := buildAccountQuotaAnalysis([]*accountListSnapshotItem{item}, "5h") + if got.Total != 0 || got.Sampled != 0 { + t.Fatalf("Claude plan %q incorrectly entered 5h analysis: %+v", plan, got) + } + } +} + func TestCombineAccountStatsState(t *testing.T) { if got := combineAccountStatsState("ready", "stale"); got != "stale" { t.Fatalf("ready+stale=%q", got) @@ -585,6 +608,114 @@ func TestAccountOperationSelectorNeverCrossesChannel(t *testing.T) { } } +func TestAccountListSubscriptionUnlockedIsProviderAware(t *testing.T) { + claudeRow := &database.AccountRow{Credentials: map[string]interface{}{"upstream_type": auth.UpstreamClaude}} + cases := []struct { + name string + item *accountListSnapshotItem + channel string + want bool + }{ + { + name: "codex paid plan", + item: &accountListSnapshotItem{PlanType: "plus"}, + channel: database.UpstreamChannelCodex, + want: true, + }, + { + name: "claude max plan", + item: &accountListSnapshotItem{Claude: true, PlanType: "max", Row: claudeRow}, + channel: database.UpstreamChannelClaude, + want: true, + }, + { + name: "claude free plan", + item: &accountListSnapshotItem{Claude: true, PlanType: "free", Row: claudeRow}, + channel: database.UpstreamChannelClaude, + want: false, + }, + { + name: "claude locked plan", + item: &accountListSnapshotItem{Claude: true, PlanType: "max", Locked: true, Row: claudeRow}, + channel: database.UpstreamChannelClaude, + want: false, + }, + { + name: "grok plan is not claude subscription", + item: &accountListSnapshotItem{PlanType: "supergrok", GrokAuthKind: auth.GrokAuthKindOAuth}, + channel: database.UpstreamChannelGrok, + want: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := accountListSubscriptionUnlocked(tc.item, tc.channel); got != tc.want { + t.Fatalf("accountListSubscriptionUnlocked() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestAccountOperationSelectorIncludesUnlockedClaudePlans(t *testing.T) { + handler, _, _ := newPagedAccountsHandler(t) + ctx := context.Background() + maxID, err := handler.db.InsertAccountWithUpstream(ctx, "claude-max", "anthropic", "oauth", map[string]interface{}{ + "upstream_type": "claude", + "refresh_token": "claude-max-refresh", + "plan_type": "max", + }, "") + if err != nil { + t.Fatalf("insert Claude max account: %v", err) + } + lockedID, err := handler.db.InsertAccountWithUpstream(ctx, "claude-locked", "anthropic", "oauth", map[string]interface{}{ + "upstream_type": "claude", + "refresh_token": "claude-locked-refresh", + "plan_type": "max-5x", + }, "") + if err != nil { + t.Fatalf("insert locked Claude account: %v", err) + } + if err := handler.db.SetAccountLocked(ctx, lockedID, true); err != nil { + t.Fatalf("lock Claude account: %v", err) + } + freeID, err := handler.db.InsertAccountWithUpstream(ctx, "claude-free", "anthropic", "oauth", map[string]interface{}{ + "upstream_type": "claude", + "refresh_token": "claude-free-refresh", + "plan_type": "free", + }, "") + if err != nil { + t.Fatalf("insert Claude free account: %v", err) + } + + selected, err := handler.resolveAccountOperationSelector(ctx, &accountOperationSelector{ + Channel: database.UpstreamChannelClaude, + SubscriptionUnlocked: true, + }) + if err != nil { + t.Fatalf("resolve Claude selector: %v", err) + } + if len(selected) != 1 || selected[0] != maxID { + t.Fatalf("Claude subscription selector ids = %v, want [%d] (locked=%d free=%d)", selected, maxID, lockedID, freeID) + } +} + +func TestClaudeAccountSnapshotExpiryDoesNotInvalidateOtherChannelGeneration(t *testing.T) { + h := &Handler{accountListCache: make(map[string]*accountListSnapshot)} + globalBefore := h.accountCachesGen.Load() + claudeBefore := h.claudeAccountCachesGen.Load() + h.expireAccountListSnapshot(database.UpstreamChannelClaude) + if h.accountCachesGen.Load() != globalBefore { + t.Fatal("Claude snapshot expiry should not bump the global account cache generation") + } + if h.claudeAccountCachesGen.Load() != claudeBefore+1 { + t.Fatal("Claude snapshot expiry should bump its channel generation") + } + h.expireAccountListSnapshot(database.UpstreamChannelCodex) + if h.accountCachesGen.Load() != globalBefore+1 { + t.Fatal("non-Claude snapshot expiry should retain the global invalidation behavior") + } +} + func TestAccountOperationSelectorSupportsAntigravity(t *testing.T) { handler, codexIDs, grokIDs := newPagedAccountsHandler(t) ctx := context.Background() @@ -1063,3 +1194,53 @@ func TestCodexAuthKindFilterSplitsOAuthAndResponsesAPI(t *testing.T) { t.Fatalf("summary = %+v, want OAuth=1 APIKey=1", summary) } } + +func TestClaudeAccountListPreservesProviderSearchAndOAuthSummary(t *testing.T) { + row := &database.AccountRow{ + ID: 901, + Name: "claude-account", + Status: "active", + Enabled: true, + Tags: []string{"claude"}, + Credentials: map[string]interface{}{ + "upstream_type": auth.UpstreamClaude, + "email": "claude@example.com", + "plan_type": "claude-max-5x", + "models": []string{"claude-sonnet-4-5"}, + "claude_usage_probe_error": "temporary upstream failure", + }, + } + store := auth.NewStore(nil, nil, nil) + defer store.Stop() + store.AddAccount(&auth.Account{DBID: 901, UpstreamType: auth.UpstreamClaude, GroupIDs: []int64{7}}) + item := (&Handler{store: store}).buildAccountListSnapshotItem(row, nil, nil, map[int64]string{7: "Claude Team"}, map[int64]string{7: "0007"}) + if !item.Claude { + t.Fatal("Claude list item must retain provider marker") + } + for _, needle := range []string{"claude-sonnet-4-5", "claude-max-5x", "temporary upstream failure", "claude team"} { + if !strings.Contains(item.SearchText, needle) { + t.Fatalf("SearchText %q does not contain %q", item.SearchText, needle) + } + } + if !accountListItemMatches(item, accountPageQuery{AuthKind: "oauth", Search: "claude-sonnet-4-5"}, database.UpstreamChannelClaude) { + t.Fatal("Claude OAuth filter/search should match") + } + if accountListItemMatches(item, accountPageQuery{AuthKind: "api_key"}, database.UpstreamChannelClaude) { + t.Fatal("Claude OAuth account must not match api_key filter") + } + summary, _ := summarizeAccountList([]*accountListSnapshotItem{item}, database.UpstreamChannelClaude) + if summary.OAuth != 1 || summary.APIKey != 0 { + t.Fatalf("Claude summary = %+v, want oauth=1 api_key=0", summary) + } +} + +func TestClaudeAccountListSuccessfulProbeCountsAsSampledWithoutQuotaHeaders(t *testing.T) { + row := &database.AccountRow{ID: 902, Status: "active", Enabled: true, Credentials: map[string]interface{}{ + "upstream_type": auth.UpstreamClaude, + auth.ClaudeUsageProbeAtCredentialKey: "2026-08-29T05:00:00Z", + }} + item := (&Handler{}).buildAccountListSnapshotItem(row, nil, nil, nil, nil) + if !item.Claude || accountListUnsampled(item) { + t.Fatalf("Claude successful probe should be sampled: item=%+v", item) + } +} diff --git a/admin/claude_accounts.go b/admin/claude_accounts.go new file mode 100644 index 000000000..2a3215f9b --- /dev/null +++ b/admin/claude_accounts.go @@ -0,0 +1,610 @@ +package admin + +// Claude Code(Anthropic)OAuth 账号的后台导入端点。 +// +// 提供两条导入路径: +// 1. 网页 OAuth 两步式: +// POST /accounts/claude/oauth/auth-url → 返回授权 URL + state +// POST /accounts/claude/oauth/exchange-code → 用 state+code 换 token 并入库 +// 服务端用一个带 TTL 的内存表按 state 暂存 verifier。 +// 2. CLI 直导: +// POST /accounts/claude/import → 直接吃 cmd/claude_login -out 产出的 +// token JSON(access_token/refresh_token/...)入库,无需服务端 OAuth 往返。 + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/codex2api/auth" + "github.com/codex2api/database" + "github.com/codex2api/security" + "github.com/gin-gonic/gin" +) + +// claudeOAuthPending 暂存一次登录的 state→verifier(带 TTL)。 +type claudeOAuthPending struct { + verifier string + createdAt time.Time +} + +var ( + claudeOAuthMu sync.Mutex + claudeOAuthPendMap = map[string]claudeOAuthPending{} +) + +const claudeOAuthSessionTTL = 15 * time.Minute + +func claudeOAuthPut(state, verifier string) { + claudeOAuthMu.Lock() + defer claudeOAuthMu.Unlock() + // 顺带清理过期项,避免内存无限增长。 + now := time.Now() + for k, v := range claudeOAuthPendMap { + if now.Sub(v.createdAt) > claudeOAuthSessionTTL { + delete(claudeOAuthPendMap, k) + } + } + claudeOAuthPendMap[state] = claudeOAuthPending{verifier: verifier, createdAt: now} +} + +func claudeOAuthTake(state string) (string, bool) { + claudeOAuthMu.Lock() + defer claudeOAuthMu.Unlock() + p, ok := claudeOAuthPendMap[state] + if !ok { + return "", false + } + delete(claudeOAuthPendMap, state) + if time.Since(p.createdAt) > claudeOAuthSessionTTL { + return "", false + } + return p.verifier, true +} + +// GenerateClaudeAuthURL 发起一次 Claude OAuth 登录,返回授权 URL 与 state。 +func (h *Handler) GenerateClaudeAuthURL(c *gin.Context) { + session, err := auth.StartClaudeLogin() + if err != nil { + writeInternalError(c, err) + return + } + claudeOAuthPut(session.State, session.Verifier) + c.JSON(http.StatusOK, gin.H{ + "auth_url": session.AuthURL, + "state": session.State, + }) +} + +type exchangeClaudeCodeReq struct { + State string `json:"state"` + Code string `json:"code"` + Name string `json:"name"` + // ProxyURL 指定固定代理;留空且 UseProxyPool=true 时从代理池自动取一个。 + ProxyURL string `json:"proxy_url"` + UseProxyPool bool `json:"use_proxy_pool"` + // Timezone 账号绑定的 IANA 时区(如 Asia/Shanghai),用于指纹一致性;空=不指定。 + Timezone string `json:"timezone"` +} + +// resolveClaudeLoginProxy 决定本次登录/导入使用并固定到账号的代理: +// 显式 proxy_url 优先;否则若 use_proxy_pool=true 则从代理池轮询取一个。 +// 返回的代理会同时用于 OAuth 交换、后续刷新与推理出站,保证 IP 一致(防风控)。 +func (h *Handler) resolveClaudeLoginProxy(rawURL string, usePool bool) (string, error) { + rawURL = strings.TrimSpace(rawURL) + if rawURL != "" { + if err := security.ValidateProxyURL(rawURL); err != nil { + return "", err + } + return rawURL, nil + } + if usePool && h.store != nil { + return strings.TrimSpace(h.store.NextProxy()), nil + } + return "", nil +} + +// ExchangeClaudeOAuthCode 用 state+code 换取 token 并把账号写入池子。 +func (h *Handler) ExchangeClaudeOAuthCode(c *gin.Context) { + var req exchangeClaudeCodeReq + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, http.StatusBadRequest, "请求格式错误") + return + } + req.Name = security.SanitizeInput(req.Name) + req.ProxyURL = security.SanitizeInput(req.ProxyURL) + req.State = strings.TrimSpace(req.State) + req.Code = strings.TrimSpace(req.Code) + if req.State == "" || req.Code == "" { + writeError(c, http.StatusBadRequest, "state 与 code 均为必填") + return + } + proxyURL, err := h.resolveClaudeLoginProxy(req.ProxyURL, req.UseProxyPool) + if err != nil { + writeError(c, http.StatusBadRequest, "代理URL无效") + return + } + verifier, ok := claudeOAuthTake(req.State) + if !ok { + writeError(c, http.StatusBadRequest, "登录会话已过期或不存在,请重新获取授权 URL") + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second) + defer cancel() + + client := auth.NewClaudeAuth(proxyURL) + td, err := client.ExchangeCode(ctx, req.Code, req.State, verifier) + if err != nil { + writeError(c, http.StatusBadGateway, "换取 token 失败: "+err.Error()) + return + } + h.insertClaudeAccount(c, ctx, req.Name, proxyURL, req.Timezone, td, "manual_claude_oauth") +} + +type importClaudeTokenReq struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Email string `json:"email"` + AccountID string `json:"account_id"` + ExpiresAt string `json:"expires_at"` + Name string `json:"name"` + ProxyURL string `json:"proxy_url"` + UseProxyPool bool `json:"use_proxy_pool"` + Timezone string `json:"timezone"` +} + +// ImportClaudeToken 直接吃 cmd/claude_login -out 产出的 token JSON 入库。 +func (h *Handler) ImportClaudeToken(c *gin.Context) { + if c.Request.Body == nil { + writeError(c, http.StatusBadRequest, "请求格式错误") + return + } + raw, err := io.ReadAll(io.LimitReader(c.Request.Body, claudeCredentialExportMaxBytes+1)) + if err != nil { + writeError(c, http.StatusBadRequest, "读取凭据失败") + return + } + documents, err := parseClaudeImportDocuments(raw) + if err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + // Keep the legacy single-document response shape while allowing a portable + // JSON array / {accounts:[...]} bundle to use the same endpoint. + ctx, cancel := context.WithTimeout(c.Request.Context(), claudeImportTimeout(len(documents))) + defer cancel() + items := make([]claudeImportResultItem, 0, len(documents)) + for _, document := range documents { + item := claudeImportResultItem{} + proxyURL := strings.TrimSpace(document.ProxyURL) + if proxyURL == "" || document.UseProxyPool { + proxyURL, err = h.resolveClaudeLoginProxy(proxyURL, document.UseProxyPool) + if err != nil { + item.Error = "代理URL无效" + item.status = http.StatusBadRequest + items = append(items, item) + continue + } + } + expiresAt := time.Now().Add(30 * time.Minute) + if rawExpires := strings.TrimSpace(document.ExpiresAt); rawExpires != "" { + if parsed, parseErr := time.Parse(time.RFC3339, rawExpires); parseErr == nil { + expiresAt = parsed + } + } + name := security.SanitizeInput(document.Name) + resolvedGroupIDs, missingGroups, groupErr := h.resolveClaudeGroupRefs(ctx, document.GroupRefs) + if groupErr != nil { + item.Error = "分组映射失败: " + groupErr.Error() + item.status = http.StatusInternalServerError + items = append(items, item) + continue + } + td := &auth.ClaudeTokenData{ + AccessToken: document.AccessToken, + RefreshToken: document.RefreshToken, + Email: document.Email, + AccountUUID: document.AccountID, + PlanType: document.PlanType, + ExpiresAt: expiresAt, + } + created, createErr := h.createClaudeAccount(ctx, name, proxyURL, document.Timezone, td, "manual_claude_import", &claudeAccountImportOptions{ + Models: document.Models, + PlanType: document.PlanType, + FingerprintMode: document.ClaudeFingerprintMode, + FingerprintHeaders: document.FingerprintHeaders, + Tags: document.Tags, + GroupRefs: document.GroupRefs, + ResolvedGroupIDs: resolvedGroupIDs, + SkipModelFetch: len(documents) > 1, + Enabled: document.Enabled, + }) + if createErr != nil { + item.Error = createErr.Error() + if typedErr, ok := createErr.(*claudeAccountCreateError); ok { + item.status = typedErr.Status + } + items = append(items, item) + continue + } + item.OK = true + item.ID = created.ID + item.Email = created.Email + item.Warnings = append(item.Warnings, created.Warnings...) + security.SecurityAuditLog("CLAUDE_ACCOUNT_IMPORTED", fmt.Sprintf("account_id=%d ip=%s", created.ID, c.ClientIP())) + if len(missingGroups) > 0 { + item.Warnings = append(item.Warnings, "部分分组未找到: "+strings.Join(missingGroups, ", ")) + } + items = append(items, item) + } + if len(documents) == 1 { + item := items[0] + if !item.OK { + status := item.status + if status <= 0 { + status = http.StatusInternalServerError + if strings.Contains(item.Error, "已存在") || strings.Contains(item.Error, "duplicate") { + status = http.StatusConflict + } + } + writeError(c, status, item.Error) + return + } + response := gin.H{"message": "成功添加 Claude 账号", "id": item.ID, "email": item.Email} + if len(item.Warnings) > 0 { + response["warnings"] = item.Warnings + } + c.JSON(http.StatusOK, response) + return + } + imported := 0 + for _, item := range items { + if item.OK { + imported++ + } + } + c.JSON(http.StatusOK, gin.H{ + "total": len(documents), "imported": imported, "failed": len(documents) - imported, "items": items, + }) +} + +// RefreshClaudeModels 重新拉取指定 Claude 账号真实可用的模型并落库(动态维护, +// 不用重新导入)。路由 POST /accounts/:id/claude/models。 +func (h *Handler) RefreshClaudeModels(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + writeError(c, http.StatusBadRequest, "无效的账号 ID") + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + row, err := h.db.GetAccountByID(ctx, id) + if err != nil { + writeError(c, http.StatusNotFound, "账号不存在") + return + } + if !strings.EqualFold(strings.TrimSpace(row.GetCredential("upstream_type")), auth.UpstreamClaude) { + writeError(c, http.StatusBadRequest, "该账号不是 Claude 账号") + return + } + accessToken := strings.TrimSpace(row.GetCredential("access_token")) + if accessToken == "" { + writeError(c, http.StatusBadRequest, "账号缺少 access_token,请先刷新或重新导入") + return + } + models, ferr := auth.NewClaudeAuth(h.resolveClaudeModelProxy(id, row.ProxyURL)).FetchModels(ctx, accessToken) + if ferr != nil { + writeError(c, http.StatusBadGateway, "拉取可用模型失败: "+ferr.Error()) + return + } + if len(models) == 0 { + writeError(c, http.StatusBadGateway, "未拉到任何可用模型") + return + } + if err := h.db.UpdateCredentials(ctx, id, map[string]interface{}{"models": models}); err != nil { + writeInternalError(c, err) + return + } + // 直接更新内存账号的 Models,即时生效(LoadAccountByID 对已存在账号是 no-op)。 + if h.store != nil { + if acc := h.store.FindByID(id); acc != nil { + acc.Mu().Lock() + acc.Models = append([]string(nil), models...) + acc.Mu().Unlock() + } + } + h.invalidateClaudeCatalogCaches() + c.JSON(http.StatusOK, gin.H{"message": "已更新可用模型", "models": models, "count": len(models)}) +} + +// RefreshAllClaudeModels 为所有 Claude 账号重新拉取真实可用模型(定价页"模型目录"用)。 +// 路由 POST /accounts/claude/models/refresh。 +func (h *Handler) RefreshAllClaudeModels(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second) + defer cancel() + rows, err := h.db.ListActiveByChannel(ctx, database.UpstreamChannelClaude) + if err != nil { + writeInternalError(c, err) + return + } + refreshed, failed := 0, 0 + allModels := map[string]struct{}{} + for _, row := range rows { + accessToken := strings.TrimSpace(row.GetCredential("access_token")) + if accessToken == "" { + failed++ + continue + } + models, ferr := auth.NewClaudeAuth(h.resolveClaudeModelProxy(row.ID, row.ProxyURL)).FetchModels(ctx, accessToken) + if ferr != nil || len(models) == 0 { + failed++ + continue + } + if err := h.db.UpdateCredentials(ctx, row.ID, map[string]interface{}{"models": models}); err != nil { + failed++ + continue + } + if h.store != nil { + if acc := h.store.FindByID(row.ID); acc != nil { + acc.Mu().Lock() + acc.Models = append([]string(nil), models...) + acc.Mu().Unlock() + } + } + for _, m := range models { + allModels[m] = struct{}{} + } + refreshed++ + } + if refreshed > 0 { + h.invalidateClaudeCatalogCaches() + } + c.JSON(http.StatusOK, gin.H{ + "message": "已刷新 Claude 账号可用模型", + "refreshed": refreshed, + "failed": failed, + "model_count": len(allModels), + }) +} + +// resolveClaudeModelProxy mirrors the request path's proxy precedence for +// control-plane model discovery: an account-level/managed group proxy wins, +// then the row's persisted proxy is used as a safe fallback when the account +// is not currently present in the runtime store. +func (h *Handler) resolveClaudeModelProxy(id int64, fallback string) string { + if h != nil && h.store != nil { + if account := h.store.FindByID(id); account != nil { + if resolved := strings.TrimSpace(h.store.ResolveProxyForAccount(account)); resolved != "" { + return resolved + } + } + } + return strings.TrimSpace(fallback) +} + +func (h *Handler) invalidateClaudeCatalogCaches() { + if h == nil { + return + } + h.expireAccountListSnapshot(database.UpstreamChannelClaude) + h.accountAnalysisCacheMu.Lock() + if h.accountAnalysisCache != nil { + delete(h.accountAnalysisCache, database.UpstreamChannelClaude) + } + h.accountAnalysisCacheMu.Unlock() +} + +// insertClaudeAccount 把一份 Claude token 落库并加载进运行时池子(去重按 account_id)。 +// timezone 为空时不指定时区。会为该账号生成一套稳定的 Claude Code 指纹并随凭据落库, +// 之后每次上游请求原样套用(见 proxy/claude_upstream.go)。 +// claudePlanOrDefault 取 profile 推导的档位,空则回退通用 "claude"。 +func claudePlanOrDefault(plan string) string { + if p := strings.TrimSpace(plan); p != "" { + return p + } + return "claude" +} + +func shouldScheduleClaudeImportWarmup(opts *claudeAccountImportOptions) bool { + return opts == nil || opts.Enabled == nil || *opts.Enabled +} + +func (h *Handler) insertClaudeAccount(c *gin.Context, ctx context.Context, name, proxyURL, timezone string, td *auth.ClaudeTokenData, source string) { + created, err := h.createClaudeAccount(ctx, name, proxyURL, timezone, td, source, nil) + if err != nil { + status := http.StatusInternalServerError + if createErr, ok := err.(*claudeAccountCreateError); ok && createErr.Status > 0 { + status = createErr.Status + } + writeError(c, status, err.Error()) + return + } + security.SecurityAuditLog("CLAUDE_ACCOUNT_ADDED", fmt.Sprintf("account_id=%d ip=%s", created.ID, c.ClientIP())) + response := gin.H{ + "message": "成功添加 Claude 账号", + "id": created.ID, + "email": created.Email, + } + if len(created.Warnings) > 0 { + response["warnings"] = created.Warnings + } + c.JSON(http.StatusOK, response) +} + +// createClaudeAccount is the shared insertion path for OAuth and portable +// credential imports. It never writes token values to logs or response bodies. +func (h *Handler) createClaudeAccount(ctx context.Context, name, proxyURL, timezone string, td *auth.ClaudeTokenData, source string, opts *claudeAccountImportOptions) (claudeAccountCreateResult, error) { + if h == nil || h.db == nil || td == nil { + return claudeAccountCreateResult{}, &claudeAccountCreateError{Status: http.StatusInternalServerError, Message: "Claude 账号存储未初始化"} + } + email := strings.TrimSpace(td.Email) + accountUUID := strings.TrimSpace(td.AccountUUID) + + proxyURL = strings.TrimSpace(proxyURL) + if proxyURL != "" { + if err := security.ValidateProxyURL(proxyURL); err != nil { + return claudeAccountCreateResult{}, &claudeAccountCreateError{Status: http.StatusBadRequest, Message: "代理URL无效"} + } + } + if name == "" { + name = email + } + if name == "" { + name = "claude" + } + + // 未显式指定时区时,回退到 ClaudeCode 全局默认(系统设置里配置)。 + if strings.TrimSpace(timezone) == "" && h.store != nil { + timezone = h.store.ClaudeDefaultTimezone() + } + if err := validateAccountTimezone(timezone); err != nil { + return claudeAccountCreateResult{}, &claudeAccountCreateError{Status: http.StatusBadRequest, Message: err.Error()} + } + + // 生成稳定指纹(UA / x-app / x-stainless-*),存进 custom_headers 供请求期套用。 + fingerprint := auth.GenerateClaudeFingerprint(timezone) + customHeaders := fingerprint.Headers() + if opts != nil && len(opts.FingerprintHeaders) > 0 { + normalized, err := normalizeClaudeFingerprintHeaders(opts.FingerprintHeaders) + if err != nil { + return claudeAccountCreateResult{}, &claudeAccountCreateError{Status: http.StatusBadRequest, Message: err.Error()} + } + for key, value := range normalized { + customHeaders[key] = value + } + } + + // 动态拉取该账号**真实可用**的模型(Anthropic /v1/models),存进 credentials.models; + // 失败不阻断导入(DefaultClaudeModelIDsForAccount 会回退到内置兜底集)。 + var claudeModels []string + if opts != nil && len(opts.Models) > 0 { + models, modelErr := normalizeClaudeImportModels(opts.Models) + if modelErr != nil { + return claudeAccountCreateResult{}, &claudeAccountCreateError{Status: http.StatusBadRequest, Message: modelErr.Error()} + } + claudeModels = models + } else if opts != nil && opts.SkipModelFetch { + // A large bundle should not serialize one upstream /v1/models request per + // account. Leave the catalog empty so the normal default Claude model set + // is used; operators can refresh the catalog explicitly after import. + } else if models, ferr := auth.NewClaudeAuth(proxyURL).FetchModels(ctx, td.AccessToken); ferr == nil && len(models) > 0 { + claudeModels = models + } else if ferr != nil { + log.Printf("拉取 Claude 账号可用模型失败(将用兜底集): %v", ferr) + } + planType := claudePlanOrDefault(td.PlanType) + if opts != nil && strings.TrimSpace(opts.PlanType) != "" { + planType = claudePlanOrDefault(opts.PlanType) + } + fingerprintMode := "" + if opts != nil && strings.TrimSpace(opts.FingerprintMode) != "" { + if !auth.IsValidClaudeFingerprintMode(opts.FingerprintMode) { + return claudeAccountCreateResult{}, &claudeAccountCreateError{Status: http.StatusBadRequest, Message: "claude_fingerprint_mode must be preserve, force, or empty"} + } + fingerprintMode = auth.NormalizeClaudeFingerprintMode(opts.FingerprintMode) + } + + credentials := map[string]interface{}{ + "upstream_type": auth.UpstreamClaude, + "access_token": td.AccessToken, + "refresh_token": td.RefreshToken, + "expires_at": td.ExpiresAt.Format(time.RFC3339), + "email": email, + "account_id": accountUUID, + "plan_type": planType, + "custom_headers": customHeaders, + "timezone": strings.TrimSpace(timezone), + } + if fingerprintMode != "" { + credentials[auth.ClaudeFingerprintModeCredentialKey] = fingerprintMode + } + if len(claudeModels) > 0 { + credentials["models"] = claudeModels + } + // 查重与插入置于同一临界区,避免并发导入同一账号各插一条(TOCTOU)。 + // 复用 antigravity/grok 相同的合并去重锁,跨 provider 一致。 + h.mergeDuplicateMu.Lock() + rows, listErr := h.db.ListActiveByChannel(ctx, database.UpstreamChannelClaude) + if listErr != nil { + h.mergeDuplicateMu.Unlock() + return claudeAccountCreateResult{}, &claudeAccountCreateError{Status: http.StatusInternalServerError, Message: "查询 Claude 账号失败: " + listErr.Error()} + } + for _, row := range rows { + if accountUUID != "" && strings.EqualFold(strings.TrimSpace(row.GetCredential("account_id")), accountUUID) { + h.mergeDuplicateMu.Unlock() + return claudeAccountCreateResult{}, &claudeAccountCreateError{Status: http.StatusConflict, Message: fmt.Sprintf("Claude 账号已存在 (id=%d)", row.ID)} + } + // A refresh token is itself a stable credential identity. Check it even + // when the provider also supplied an account_id; providers may rotate or + // omit that identifier while leaving the same refresh token valid. + if refreshToken := strings.TrimSpace(td.RefreshToken); refreshToken != "" && + strings.TrimSpace(row.GetCredential("refresh_token")) == refreshToken { + h.mergeDuplicateMu.Unlock() + return claudeAccountCreateResult{}, &claudeAccountCreateError{Status: http.StatusConflict, Message: fmt.Sprintf("Claude 凭据已存在 (id=%d)", row.ID)} + } + } + id, err := h.db.InsertAccountWithUpstream(ctx, name, "anthropic", auth.UpstreamClaude, credentials, proxyURL) + h.mergeDuplicateMu.Unlock() + if err != nil { + return claudeAccountCreateResult{}, &claudeAccountCreateError{Status: http.StatusInternalServerError, Message: "保存 Claude 账号失败: " + err.Error()} + } + + if h.store != nil { + h.store.AddAccount(&auth.Account{ + DBID: id, + ProxyURL: proxyURL, + HealthTier: auth.HealthTierHealthy, + UpstreamType: auth.UpstreamClaude, + AccessToken: td.AccessToken, + RefreshToken: td.RefreshToken, + ExpiresAt: td.ExpiresAt, + AccountID: accountUUID, + Email: email, + PlanType: planType, + ClaudeFingerprintMode: fingerprintMode, + CustomHeaders: customHeaders, + Models: claudeModels, + }) + } + warnings := make([]string, 0, 2) + if opts != nil { + if len(opts.Tags) > 0 { + if err := h.db.UpdateAccountTags(ctx, id, opts.Tags); err != nil { + log.Printf("Claude 账号 %d 标签保存失败: %v", id, err) + warnings = append(warnings, "标签保存失败") + } else if h.store != nil { + h.store.ApplyAccountTags(id, opts.Tags) + } + } + if len(opts.ResolvedGroupIDs) > 0 { + if err := h.bindImportedAccountGroups(ctx, []int64{id}, opts.ResolvedGroupIDs); err != nil { + log.Printf("Claude 账号 %d 分组绑定失败: %v", id, err) + warnings = append(warnings, "分组绑定失败") + } + } + if opts.Enabled != nil && !*opts.Enabled { + if err := h.db.SetAccountEnabled(ctx, id, false); err != nil { + log.Printf("Claude 账号 %d 启用状态保存失败: %v", id, err) + warnings = append(warnings, "启用状态保存失败") + } else if h.store != nil { + h.store.ApplyAccountEnabled(id, false) + } + } + } + + h.db.InsertAccountEventAsync(id, "added", source) + // Keep Claude imports on the bounded warmup queue. ProbeUsageSnapshot routes + // this account to Anthropic Messages and never to WHAM/Responses. + if h.store != nil && shouldScheduleClaudeImportWarmup(opts) { + h.scheduleImportedAccountWarmup(h.store.FindByID(id), id, source) + } + return claudeAccountCreateResult{ID: id, Email: email, Warnings: warnings}, nil +} diff --git a/admin/claude_accounts_test.go b/admin/claude_accounts_test.go new file mode 100644 index 000000000..95ca19396 --- /dev/null +++ b/admin/claude_accounts_test.go @@ -0,0 +1,81 @@ +package admin + +import ( + "testing" + + "github.com/codex2api/auth" + "github.com/codex2api/database" +) + +func TestValidateAccountModelsForClaude(t *testing.T) { + claude := &auth.Account{UpstreamType: auth.UpstreamClaude} + if err := validateAccountModelsForAccount(claude, []string{"claude-sonnet-4-5", "claude-haiku-4-5"}); err != nil { + t.Fatalf("valid Claude models rejected: %v", err) + } + if err := validateAccountModelsForAccount(claude, []string{"gpt-5.4"}); err == nil { + t.Fatal("non-Claude model must be rejected for Claude account") + } + if err := validateAccountModelsForAccount(claude, nil); err != nil { + t.Fatalf("empty Claude allowlist should clear the override: %v", err) + } + if err := validateAccountModelsForAccount(&auth.Account{UpstreamType: auth.UpstreamOpenAIResponses}, []string{"gpt-5.4"}); err != nil { + t.Fatalf("non-Claude account model list changed semantics: %v", err) + } +} + +func TestBuildAccountResponseMarksClaudeProvider(t *testing.T) { + row := &database.AccountRow{ + ID: 901, + Name: "claude-test", + Status: "active", + Enabled: true, + Credentials: map[string]interface{}{ + "upstream_type": auth.UpstreamClaude, + "access_token": "claude-token", + "plan_type": "claude", + "codex_fingerprint_mode": "full", + auth.ClaudeUsageProbeAtCredentialKey: "2026-08-29T05:00:00Z", + auth.ClaudeUsageProbeErrorCredentialKey: "", + }, + } + response := (&Handler{store: auth.NewStore(nil, nil, nil)}).buildAccountResponse(row, nil, nil, nil, nil, false) + if !response.ClaudeAPI { + t.Fatal("Claude account response must carry claude_api=true") + } + if response.ATOnly { + t.Fatal("Claude account must not be mislabeled as Codex AT-only") + } + if response.CodexFingerprintMode != "" { + t.Fatalf("Claude account leaked Codex fingerprint mode %q", response.CodexFingerprintMode) + } + if response.ClaudeUsageProbeAt != "2026-08-29T05:00:00Z" || response.ClaudeUsageProbeError != "" { + t.Fatalf("Claude sampling metadata = at=%q error=%q", response.ClaudeUsageProbeAt, response.ClaudeUsageProbeError) + } +} + +func TestClaudeImportedProbeDoesNotEnterCodexIdentityMerge(t *testing.T) { + if shouldMergeImportedIdentity(&auth.Account{UpstreamType: auth.UpstreamClaude, AccessToken: "claude"}) { + t.Fatal("Claude imports must not enter Codex workspace duplicate merge") + } + if !shouldMergeImportedIdentity(&auth.Account{UpstreamType: auth.UpstreamOpenAIResponses, AccessToken: "relay"}) { + t.Fatal("non-Claude, non-Agent imports should retain identity merge behavior") + } +} + +func TestClaudeOAuthPutTake_OneTimeUse(t *testing.T) { + claudeOAuthPut("state-a", "verifier-a") + v, ok := claudeOAuthTake("state-a") + if !ok || v != "verifier-a" { + t.Fatalf("首次 take 应成功返回 verifier, got=(%q,%v)", v, ok) + } + // 一次性:再次 take 应失败。 + if _, ok := claudeOAuthTake("state-a"); ok { + t.Fatal("同一 state 不应被 take 两次") + } +} + +func TestClaudeOAuthTake_Missing(t *testing.T) { + if _, ok := claudeOAuthTake("no-such-state"); ok { + t.Fatal("不存在的 state 应返回 false") + } +} diff --git a/admin/claude_config.go b/admin/claude_config.go new file mode 100644 index 000000000..384c1434b --- /dev/null +++ b/admin/claude_config.go @@ -0,0 +1,98 @@ +package admin + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/codex2api/auth" + "github.com/gin-gonic/gin" +) + +// claudeGlobalConfigDTO 是 ClaudeCode 全局配置的读写载体(系统设置里的独立模块)。 +// 全体 Claude 账号默认遵守;个体账号可在「编辑账号」里覆盖。 +type claudeGlobalConfigDTO struct { + FingerprintMode string `json:"fingerprint_mode"` // preserve / force(空=preserve) + DefaultTimezone string `json:"default_timezone"` // 导入 Claude 账号的默认 IANA 时区 + SessionWindowLimit int64 `json:"session_window_limit"` // 默认并发会话窗口数(0=跟随全局) + auth.ClaudeSecurityConfig +} + +// GetClaudeConfig 返回当前 ClaudeCode 全局配置(取自运行时 Store 访问器)。 +func (h *Handler) GetClaudeConfig(c *gin.Context) { + security := h.store.ClaudeSecurityConfig() + c.JSON(http.StatusOK, claudeGlobalConfigDTO{ + FingerprintMode: h.store.ClaudeFingerprintModeDefault(), + DefaultTimezone: h.store.ClaudeDefaultTimezone(), + SessionWindowLimit: h.store.ClaudeSessionWindowLimit(), + ClaudeSecurityConfig: security, + }) +} + +// UpdateClaudeConfig 校验并持久化 ClaudeCode 全局配置,同时热更新运行时 Store。 +func (h *Handler) UpdateClaudeConfig(c *gin.Context) { + var req claudeGlobalConfigDTO + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + mode := auth.NormalizeClaudeFingerprintMode(req.FingerprintMode) + if !auth.IsValidClaudeFingerprintMode(req.FingerprintMode) { + c.JSON(http.StatusBadRequest, gin.H{"error": "fingerprint_mode must be one of: preserve, force"}) + return + } + tz := strings.TrimSpace(req.DefaultTimezone) + if tz != "" { + if _, err := time.LoadLocation(tz); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "default_timezone must be a valid IANA timezone, e.g. Asia/Shanghai"}) + return + } + } + window := req.SessionWindowLimit + if window < 0 { + window = 0 + } + if window > 1000 { + window = 1000 + } + security := auth.NormalizeClaudeSecurityConfig(req.ClaudeSecurityConfig) + + cfg := auth.ClaudeConfig{ + FingerprintMode: mode, + DefaultTimezone: tz, + SessionWindowLimit: window, + ClaudeSecurityConfig: security, + } + raw, err := json.Marshal(cfg) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode config"}) + return + } + if err := h.db.UpdateClaudeConfig(c.Request.Context(), string(raw)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to persist config"}) + return + } + + // 热更新运行时 Store,无需重启即生效。 + h.store.SetClaudeFingerprintModeDefault(mode) + h.store.SetClaudeDefaultTimezone(tz) + h.store.SetClaudeSessionWindowLimit(window) + h.store.SetClaudeSecurityConfig(security) + + c.JSON(http.StatusOK, gin.H{ + "message": "已保存 ClaudeCode 全局配置", + "fingerprint_mode": mode, + "default_timezone": tz, + "session_window_limit": window, + "allow_service_tier": security.AllowServiceTier, + "allow_inference_geo": security.AllowInferenceGeo, + "allow_speed": security.AllowSpeed, + "allow_safety_identifier": security.AllowSafetyIdentifier, + "allowed_beta_headers": security.AllowedBetaHeaders, + "max_output_tokens": security.MaxOutputTokens, + "max_tool_count": security.MaxToolCount, + "max_tool_schema_bytes": security.MaxToolSchemaBytes, + }) +} diff --git a/admin/claude_config_test.go b/admin/claude_config_test.go new file mode 100644 index 000000000..ecc82f6ef --- /dev/null +++ b/admin/claude_config_test.go @@ -0,0 +1,55 @@ +package admin + +import ( + "context" + "net/http/httptest" + "strings" + "testing" + + "github.com/codex2api/auth" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +func TestGetClaudeConfigReturnsSecurityDefaults(t *testing.T) { + store := auth.NewStore(nil, nil, nil) + defer store.Stop() + h := &Handler{store: store} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + h.GetClaudeConfig(c) + if recorder.Code != 200 { + t.Fatalf("status = %d", recorder.Code) + } + if got := gjson.GetBytes(recorder.Body.Bytes(), "max_output_tokens").Int(); got != 0 { + t.Fatalf("max_output_tokens = %d, want 0 (unlimited application cap)", got) + } + if got := gjson.GetBytes(recorder.Body.Bytes(), "max_tool_count").Int(); got != 0 { + t.Fatalf("max_tool_count = %d, want 0 (unlimited application cap)", got) + } + if got := gjson.GetBytes(recorder.Body.Bytes(), "allow_service_tier").Bool(); got { + t.Fatal("service_tier should be denied by default") + } +} + +func TestUpdateClaudeConfigPersistsSecurityPolicy(t *testing.T) { + db := newTestAdminDB(t) + store := auth.NewStore(db, nil, nil) + defer store.Stop() + h := &Handler{store: store, db: db} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest("PUT", "/api/admin/settings/claude-config", strings.NewReader(`{"fingerprint_mode":"force","max_output_tokens":4096,"max_tool_count":4,"max_tool_schema_bytes":65536,"allowed_beta_headers":["approved-beta"],"allow_service_tier":true}`)) + h.UpdateClaudeConfig(c) + if recorder.Code != 200 { + t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String()) + } + security := store.ClaudeSecurityConfig() + if !security.AllowServiceTier || security.MaxOutputTokens != 4096 || security.MaxToolCount != 4 || security.MaxToolSchemaBytes != 65536 || len(security.AllowedBetaHeaders) != 1 || security.AllowedBetaHeaders[0] != "approved-beta" { + t.Fatalf("runtime Claude security config = %+v", security) + } + settings, err := db.GetSystemSettings(context.Background()) + if err != nil || !strings.Contains(settings.ClaudeConfig, `"allow_service_tier":true`) { + t.Fatalf("persisted Claude config = %q err=%v", settings.ClaudeConfig, err) + } +} diff --git a/admin/claude_export.go b/admin/claude_export.go new file mode 100644 index 000000000..b559bf362 --- /dev/null +++ b/admin/claude_export.go @@ -0,0 +1,950 @@ +package admin + +// Claude OAuth credential export/import primitives. +// +// Claude credentials are intentionally kept out of the generic Codex export +// endpoint. This file defines a provider-specific, versioned document that +// can be moved between Codex2API installations without exposing arbitrary +// request headers or instance-local group IDs. + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "path" + "regexp" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/codex2api/auth" + "github.com/codex2api/database" + "github.com/codex2api/security" + "github.com/gin-gonic/gin" +) + +const ( + claudeCredentialExportVersion = 1 + claudeCredentialExportMaxBytes = 8 << 20 + claudeCredentialImportMaxEntries = 500 +) + +func claudeImportTimeout(entries int) time.Duration { + if entries < 1 { + entries = 1 + } + // Account creation can perform a bounded upstream model discovery when an + // old token document has no models. Scale the request budget without making + // a large bundle unbounded. + timeout := 20*time.Second + time.Duration(entries)*2*time.Second + if timeout > 10*time.Minute { + return 10 * time.Minute + } + return timeout +} + +// claudeGroupRef is portable across installations. Numeric group IDs are +// deliberately not exported because IDs are instance-local and could bind an +// imported account to an unrelated production group. +type claudeGroupRef struct { + Name string `json:"name"` + Channel string `json:"channel"` +} + +// claudeExportEntry is the stable, secret-bearing Claude credential document. +// Operational counters/cooldowns/locks are intentionally omitted; they are +// local runtime state and must be re-established by the destination instance. +type claudeExportEntry struct { + Type string `json:"type"` + Version int `json:"version"` + AuthKind string `json:"auth_kind"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + AccountID string `json:"account_id,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + PlanType string `json:"plan_type,omitempty"` + Models []string `json:"models,omitempty"` + ProxyURL string `json:"proxy_url,omitempty"` + Timezone string `json:"timezone,omitempty"` + ClaudeFingerprintMode string `json:"claude_fingerprint_mode,omitempty"` + FingerprintHeaders map[string]string `json:"fingerprint_headers,omitempty"` + Tags []string `json:"tags,omitempty"` + GroupRefs []claudeGroupRef `json:"group_refs,omitempty"` + Enabled bool `json:"enabled"` + + // exportFileName is only used as a ZIP member name and never serialized. + exportFileName string `json:"-"` +} + +// claudeImportDocument is the validated internal representation accepted by +// the Claude import endpoint. Enabled is a pointer so legacy documents that +// omit it keep the historical default (enabled=true). +type claudeImportDocument struct { + Type string + Version int + AuthKind string + Email string + Name string + AccessToken string + RefreshToken string + AccountID string + ExpiresAt string + PlanType string + Models []string + ProxyURL string + UseProxyPool bool + Timezone string + ClaudeFingerprintMode string + FingerprintHeaders map[string]string + Tags []string + GroupRefs []claudeGroupRef + Enabled *bool +} + +// claudeAccountImportOptions carries metadata that is not part of +// auth.ClaudeTokenData. It is consumed by the common account creation path. +type claudeAccountImportOptions struct { + Models []string + PlanType string + FingerprintMode string + FingerprintHeaders map[string]string + Tags []string + GroupRefs []claudeGroupRef + ResolvedGroupIDs []int64 + SkipModelFetch bool + Enabled *bool +} + +type claudeImportResultItem struct { + ID int64 `json:"id,omitempty"` + Email string `json:"email,omitempty"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Warnings []string `json:"warnings,omitempty"` + status int `json:"-"` +} + +type claudeAccountCreateError struct { + Status int + Message string +} + +type claudeAccountCreateResult struct { + ID int64 + Email string + Warnings []string +} + +func (e *claudeAccountCreateError) Error() string { + if e == nil { + return "" + } + return e.Message +} + +func marshalClaudeExportEntry(entry claudeExportEntry) ([]byte, error) { + return json.MarshalIndent(entry, "", " ") +} + +var claudeExportUnsafeFileChars = regexp.MustCompile(`[^A-Za-z0-9@._-]`) + +func claudeExportFileName(email, name string, id int64) string { + for _, candidate := range []string{email, name} { + safe := claudeExportUnsafeFileChars.ReplaceAllString(strings.TrimSpace(candidate), "") + safe = strings.TrimLeft(safe, ".") + if safe != "" { + return safe + ".json" + } + } + return fmt.Sprintf("account-%d.json", id) +} + +func buildClaudeExportZIP(entries []claudeExportEntry) ([]byte, error) { + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + used := make(map[string]int, len(entries)) + for index, entry := range entries { + baseName := entry.exportFileName + if baseName == "" { + baseName = claudeExportFileName(entry.Email, entry.Name, int64(index+1)) + } + name := baseName + if seen := used[baseName]; seen > 0 { + ext := path.Ext(name) + name = fmt.Sprintf("%s-%d%s", strings.TrimSuffix(name, ext), seen+1, ext) + } + used[baseName]++ + member, err := writer.Create(name) + if err != nil { + _ = writer.Close() + return nil, err + } + encoded, err := marshalClaudeExportEntry(entry) + if err != nil { + _ = writer.Close() + return nil, err + } + if _, err := member.Write(encoded); err != nil { + _ = writer.Close() + return nil, err + } + } + if err := writer.Close(); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +// normalizeClaudeFingerprintHeaders accepts only the identity headers used by +// Claude Code. Authorization, Cookie, API keys, and arbitrary custom headers +// must never cross an export boundary. +func normalizeClaudeFingerprintHeaders(headers map[string]string) (map[string]string, error) { + if len(headers) == 0 { + return nil, nil + } + allowed := make(map[string]struct{}, len(auth.ClaudeIdentityHeaderNames)) + for _, name := range auth.ClaudeIdentityHeaderNames { + allowed[strings.ToLower(strings.TrimSpace(name))] = struct{}{} + } + out := make(map[string]string, len(headers)) + for rawName, rawValue := range headers { + name := strings.TrimSpace(rawName) + lower := strings.ToLower(name) + if _, ok := allowed[lower]; !ok { + return nil, fmt.Errorf("fingerprint_headers contains unsupported header %q", name) + } + value := strings.TrimSpace(rawValue) + if value == "" { + continue + } + if strings.ContainsAny(value, "\r\n") { + return nil, fmt.Errorf("fingerprint_headers.%s cannot contain newlines", name) + } + if len(value) > 8192 { + return nil, fmt.Errorf("fingerprint_headers.%s exceeds 8192 bytes", name) + } + canonical := http.CanonicalHeaderKey(name) + if previous, exists := out[canonical]; exists && previous != value { + return nil, fmt.Errorf("fingerprint_headers contains conflicting duplicate header %q", canonical) + } + out[canonical] = value + } + if len(out) == 0 { + return nil, nil + } + return out, nil +} + +// prepareClaudeTimezoneCredentialUpdate keeps only approved tracing headers +// while replacing the generated Claude Code identity headers. Timezone is an +// account-level fingerprint boundary, so the credential and runtime header +// snapshot must be updated together instead of only changing a display field. +func prepareClaudeTimezoneCredentialUpdate(row *database.AccountRow, timezone string, updates map[string]interface{}) error { + _, err := prepareClaudeTimezoneCredentialUpdateWithHeaders(row, timezone, updates, nil) + return err +} + +func prepareClaudeTimezoneCredentialUpdateWithHeaders(row *database.AccountRow, timezone string, updates map[string]interface{}, requestedHeaders map[string]string) (bool, error) { + if row == nil || updates == nil { + return false, nil + } + if !strings.EqualFold(strings.TrimSpace(row.Platform), "anthropic") && + !strings.EqualFold(strings.TrimSpace(row.GetCredential("upstream_type")), auth.UpstreamClaude) { + return false, nil + } + timezone = strings.TrimSpace(timezone) + if err := validateAccountTimezone(timezone); err != nil { + return false, err + } + identity := make(map[string]struct{}, len(auth.ClaudeIdentityHeaderNames)) + for _, name := range auth.ClaudeIdentityHeaderNames { + identity[strings.ToLower(strings.TrimSpace(name))] = struct{}{} + } + baseHeaders := row.GetCredentialStringMap("custom_headers") + if requestedHeaders != nil { + baseHeaders = requestedHeaders + } + merged := make(map[string]string) + keepIdentity := requestedHeaders == nil && strings.EqualFold(strings.TrimSpace(row.GetCredential("timezone")), timezone) + for name, value := range auth.GenerateClaudeFingerprint(timezone).Headers() { + merged[name] = value + } + for name, value := range baseHeaders { + lowerName := strings.ToLower(strings.TrimSpace(name)) + if _, isIdentity := identity[lowerName]; isIdentity { + // Keep a complete existing fingerprint stable when the operator + // saves the same timezone again; a timezone change (or explicit + // header patch) intentionally rotates the identity snapshot. + if keepIdentity { + merged[name] = value + } + continue + } + if isClaudeSafeOperationalHeader(name) { + merged[name] = value + } + } + normalized, err := normalizeCustomHeaders(merged) + if err != nil { + return false, err + } + updates["custom_headers"] = normalized + updates["timezone"] = timezone + return true, nil +} + +// Only a small, explicit set of tracing headers may survive a Claude +// fingerprint rebuild. This prevents historical Authorization/Cookie/API-key +// values (or arbitrary operator headers) from being copied into a credential +// update merely because they happened to be present in custom_headers. +func isClaudeSafeOperationalHeader(name string) bool { + switch strings.ToLower(strings.TrimSpace(name)) { + case "traceparent", "tracestate", "x-request-id", "x-client-request-id", "x-correlation-id", "x-trace-id": + return true + default: + return false + } +} + +func claudeExportFingerprintHeaders(headers map[string]string) map[string]string { + allowed := make(map[string]struct{}, len(auth.ClaudeIdentityHeaderNames)) + for _, name := range auth.ClaudeIdentityHeaderNames { + allowed[strings.ToLower(strings.TrimSpace(name))] = struct{}{} + } + out := make(map[string]string) + for name, value := range headers { + if _, ok := allowed[strings.ToLower(strings.TrimSpace(name))]; !ok { + continue + } + if strings.TrimSpace(value) == "" { + continue + } + out[http.CanonicalHeaderKey(strings.TrimSpace(name))] = strings.TrimSpace(value) + } + if len(out) == 0 { + return nil + } + return out +} + +func claudeAccountRowToExportEntry(row *database.AccountRow, groupRefs []claudeGroupRef) (claudeExportEntry, bool) { + if row == nil { + return claudeExportEntry{}, false + } + if !strings.EqualFold(strings.TrimSpace(row.Platform), "anthropic") && + !strings.EqualFold(strings.TrimSpace(row.GetCredential("upstream_type")), auth.UpstreamClaude) { + return claudeExportEntry{}, false + } + accessToken := strings.TrimSpace(row.GetCredential("access_token")) + refreshToken := strings.TrimSpace(row.GetCredential("refresh_token")) + if accessToken == "" || refreshToken == "" { + return claudeExportEntry{}, false + } + entry := claudeExportEntry{ + Type: "claude", + Version: claudeCredentialExportVersion, + AuthKind: "oauth", + Email: strings.TrimSpace(row.GetCredential("email")), + Name: row.Name, + AccessToken: accessToken, + RefreshToken: refreshToken, + AccountID: strings.TrimSpace(row.GetCredential("account_id")), + ExpiresAt: strings.TrimSpace(row.GetCredential("expires_at")), + PlanType: strings.TrimSpace(row.GetCredential("plan_type")), + Models: row.GetCredentialStringSlice("models"), + ProxyURL: strings.TrimSpace(row.ProxyURL), + Timezone: strings.TrimSpace(row.GetCredential("timezone")), + ClaudeFingerprintMode: auth.NormalizeClaudeFingerprintMode(row.GetCredential(auth.ClaudeFingerprintModeCredentialKey)), + FingerprintHeaders: claudeExportFingerprintHeaders(row.GetCredentialStringMap("custom_headers")), + Tags: append([]string(nil), row.Tags...), + GroupRefs: append([]claudeGroupRef(nil), groupRefs...), + Enabled: row.Enabled, + } + entry.exportFileName = claudeExportFileName(entry.Email, entry.Name, row.ID) + return entry, true +} + +func parseClaudeExportIDSet(raw string, present bool) (map[int64]bool, error) { + if !present { + return nil, nil + } + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.New("ids must contain at least one positive account ID") + } + ids := make(map[int64]bool) + for _, value := range strings.Split(raw, ",") { + id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil || id <= 0 { + return nil, errors.New("ids must contain only positive account IDs") + } + ids[id] = true + } + return ids, nil +} + +// resolveClaudeGroupRefs maps portable references to this instance's IDs. A +// non-Claude or missing reference is reported in missing rather than guessed. +func (h *Handler) resolveClaudeGroupRefs(ctx context.Context, refs []claudeGroupRef) ([]int64, []string, error) { + if len(refs) == 0 { + return nil, nil, nil + } + groups, err := h.db.ListAccountGroups(ctx) + if err != nil { + return nil, nil, err + } + index := make(map[string]int64, len(groups)) + for _, group := range groups { + channel := database.NormalizeAccountGroupChannel(group.Channel) + key := channel + "\x00" + strings.ToLower(strings.TrimSpace(group.Name)) + if strings.TrimSpace(group.Name) != "" { + index[key] = group.ID + } + } + ids := make([]int64, 0, len(refs)) + missing := make([]string, 0) + seen := make(map[int64]struct{}, len(refs)) + for _, ref := range refs { + name := strings.TrimSpace(ref.Name) + channel := strings.TrimSpace(ref.Channel) + if channel == "" { + channel = database.AccountGroupChannelClaude + } else { + channel = database.NormalizeAccountGroupChannel(channel) + } + if name == "" || channel != database.AccountGroupChannelClaude { + if name != "" { + missing = append(missing, name) + } + continue + } + id, ok := index[channel+"\x00"+strings.ToLower(name)] + if !ok { + missing = append(missing, name) + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + return ids, missing, nil +} + +// claudeImportWire is intentionally permissive about unknown future fields so +// a newer exporter can still be consumed by an older gateway. Validation below +// rejects unsupported provider/auth shapes and unsafe values. +type claudeImportWire struct { + Type string `json:"type"` + UpstreamType string `json:"upstream_type"` + Version int `json:"version"` + AuthKind string `json:"auth_kind"` + Email string `json:"email"` + Name string `json:"name"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + AccountID string `json:"account_id"` + ExpiresAt string `json:"expires_at"` + PlanType string `json:"plan_type"` + Models []string `json:"models"` + ProxyURL string `json:"proxy_url"` + UseProxyPool bool `json:"use_proxy_pool"` + Timezone string `json:"timezone"` + ClaudeFingerprintMode string `json:"claude_fingerprint_mode"` + FingerprintHeaders map[string]string `json:"fingerprint_headers"` + CustomHeaders map[string]string `json:"custom_headers"` + Tags []string `json:"tags"` + GroupRefs []claudeGroupRef `json:"group_refs"` + Groups []claudeGroupRef `json:"groups"` + Enabled *bool `json:"enabled"` + Credentials *claudeImportCredentialWire `json:"credentials"` +} + +type claudeImportCredentialWire struct { + Type string `json:"type"` + UpstreamType string `json:"upstream_type"` + Version int `json:"version"` + AuthKind string `json:"auth_kind"` + Email string `json:"email"` + Name string `json:"name"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + AccountID string `json:"account_id"` + ExpiresAt string `json:"expires_at"` + PlanType string `json:"plan_type"` + Models []string `json:"models"` + ProxyURL string `json:"proxy_url"` + UseProxyPool bool `json:"use_proxy_pool"` + Timezone string `json:"timezone"` + ClaudeFingerprintMode string `json:"claude_fingerprint_mode"` + FingerprintHeaders map[string]string `json:"fingerprint_headers"` + CustomHeaders map[string]string `json:"custom_headers"` + Tags []string `json:"tags"` + GroupRefs []claudeGroupRef `json:"group_refs"` + Groups []claudeGroupRef `json:"groups"` + Enabled *bool `json:"enabled"` +} + +func mergeClaudeImportWire(root claudeImportWire, nested *claudeImportCredentialWire) claudeImportWire { + if nested == nil { + return root + } + if root.Type == "" { + root.Type = nested.Type + } + if root.UpstreamType == "" { + root.UpstreamType = nested.UpstreamType + } + if root.Version == 0 { + root.Version = nested.Version + } + if root.AuthKind == "" { + root.AuthKind = nested.AuthKind + } + if root.Email == "" { + root.Email = nested.Email + } + if root.Name == "" { + root.Name = nested.Name + } + if root.AccessToken == "" { + root.AccessToken = nested.AccessToken + } + if root.RefreshToken == "" { + root.RefreshToken = nested.RefreshToken + } + if root.AccountID == "" { + root.AccountID = nested.AccountID + } + if root.ExpiresAt == "" { + root.ExpiresAt = nested.ExpiresAt + } + if root.PlanType == "" { + root.PlanType = nested.PlanType + } + if len(root.Models) == 0 { + root.Models = nested.Models + } + if root.ProxyURL == "" { + root.ProxyURL = nested.ProxyURL + } + if !root.UseProxyPool { + root.UseProxyPool = nested.UseProxyPool + } + if root.Timezone == "" { + root.Timezone = nested.Timezone + } + if root.ClaudeFingerprintMode == "" { + root.ClaudeFingerprintMode = nested.ClaudeFingerprintMode + } + if len(root.FingerprintHeaders) == 0 { + root.FingerprintHeaders = nested.FingerprintHeaders + } + if len(root.CustomHeaders) == 0 { + root.CustomHeaders = nested.CustomHeaders + } + if len(root.Tags) == 0 { + root.Tags = nested.Tags + } + if len(root.GroupRefs) == 0 { + root.GroupRefs = nested.GroupRefs + } + if len(root.Groups) == 0 { + root.Groups = nested.Groups + } + if root.Enabled == nil { + root.Enabled = nested.Enabled + } + return root +} + +func normalizeClaudeImportTags(tags []string) ([]string, error) { + if len(tags) == 0 { + return nil, nil + } + seen := make(map[string]struct{}, len(tags)) + out := make([]string, 0, len(tags)) + for _, raw := range tags { + value := strings.TrimSpace(raw) + if value == "" { + continue + } + if err := validateClaudeImportMetadata(value, "tags", 40); err != nil { + return nil, err + } + key := strings.ToLower(value) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, value) + } + if len(out) > 32 { + return nil, errors.New("tags contains more than 32 items") + } + return out, nil +} + +func validateClaudeImportMetadata(value, field string, maxRunes int) error { + if !utf8.ValidString(value) { + return fmt.Errorf("%s must be valid UTF-8", field) + } + if maxRunes > 0 && utf8.RuneCountInString(value) > maxRunes { + return fmt.Errorf("%s exceeds %d characters", field, maxRunes) + } + for _, r := range value { + if unicode.IsControl(r) || r == 0x7f { + return fmt.Errorf("%s contains a control character", field) + } + } + return nil +} + +func normalizeClaudeImportModels(models []string) ([]string, error) { + if len(models) == 0 { + return nil, nil + } + seen := make(map[string]struct{}, len(models)) + out := make([]string, 0, len(models)) + for _, raw := range models { + model := strings.TrimSpace(raw) + if model == "" { + continue + } + if !strings.HasPrefix(strings.ToLower(model), "claude-") { + return nil, fmt.Errorf("models contains non-Claude model %q", model) + } + if err := security.ValidateModelName(model); err != nil { + return nil, fmt.Errorf("invalid Claude model %q: %w", model, err) + } + key := strings.ToLower(model) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, model) + } + return out, nil +} + +func normalizeClaudeGroupRefs(refs []claudeGroupRef) ([]claudeGroupRef, error) { + if len(refs) == 0 { + return nil, nil + } + if len(refs) > 32 { + return nil, errors.New("group_refs contains more than 32 items") + } + seen := make(map[string]struct{}, len(refs)) + out := make([]claudeGroupRef, 0, len(refs)) + for _, ref := range refs { + name := strings.TrimSpace(ref.Name) + if name == "" { + continue + } + if err := validateClaudeImportMetadata(name, "group_refs.name", 80); err != nil { + return nil, err + } + channel := strings.TrimSpace(ref.Channel) + if channel == "" { + channel = database.AccountGroupChannelClaude + } else { + channel = database.NormalizeAccountGroupChannel(channel) + } + if channel != database.AccountGroupChannelClaude { + return nil, fmt.Errorf("group_refs channel must be claude, got %q", ref.Channel) + } + key := channel + "\x00" + strings.ToLower(name) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, claudeGroupRef{Name: name, Channel: channel}) + } + return out, nil +} + +func claudeImportDocumentFromWire(raw claudeImportWire) (claudeImportDocument, error) { + raw = mergeClaudeImportWire(raw, raw.Credentials) + if raw.Type != "" && !strings.EqualFold(strings.TrimSpace(raw.Type), "claude") && !strings.EqualFold(strings.TrimSpace(raw.Type), "anthropic") { + return claudeImportDocument{}, fmt.Errorf("unsupported credential type %q", raw.Type) + } + if raw.UpstreamType != "" && !strings.EqualFold(strings.TrimSpace(raw.UpstreamType), auth.UpstreamClaude) { + return claudeImportDocument{}, fmt.Errorf("unsupported upstream_type %q", raw.UpstreamType) + } + if raw.Version < 0 || raw.Version > claudeCredentialExportVersion { + return claudeImportDocument{}, fmt.Errorf("unsupported Claude credential version %d", raw.Version) + } + authKind := strings.ToLower(strings.TrimSpace(raw.AuthKind)) + if authKind != "" && authKind != "oauth" { + return claudeImportDocument{}, errors.New("Claude credential auth_kind must be oauth") + } + accessToken := strings.TrimSpace(raw.AccessToken) + refreshToken := strings.TrimSpace(raw.RefreshToken) + if accessToken == "" || refreshToken == "" { + return claudeImportDocument{}, errors.New("Claude credential requires access_token and refresh_token") + } + for _, metadata := range []struct { + field string + value string + maxRunes int + }{ + {field: "email", value: strings.TrimSpace(raw.Email), maxRunes: 320}, + {field: "name", value: strings.TrimSpace(raw.Name), maxRunes: 120}, + {field: "account_id", value: strings.TrimSpace(raw.AccountID), maxRunes: 128}, + {field: "plan_type", value: strings.TrimSpace(raw.PlanType), maxRunes: 80}, + } { + if err := validateClaudeImportMetadata(metadata.value, metadata.field, metadata.maxRunes); err != nil { + return claudeImportDocument{}, err + } + } + timezone := strings.TrimSpace(raw.Timezone) + if err := validateAccountTimezone(timezone); err != nil { + return claudeImportDocument{}, err + } + fingerprintMode := auth.NormalizeClaudeFingerprintMode(raw.ClaudeFingerprintMode) + if !auth.IsValidClaudeFingerprintMode(raw.ClaudeFingerprintMode) { + return claudeImportDocument{}, errors.New("claude_fingerprint_mode must be preserve, force, or empty") + } + headers := raw.FingerprintHeaders + if len(headers) == 0 { + headers = raw.CustomHeaders + } + normalizedHeaders, err := normalizeClaudeFingerprintHeaders(headers) + if err != nil { + return claudeImportDocument{}, err + } + models, err := normalizeClaudeImportModels(raw.Models) + if err != nil { + return claudeImportDocument{}, err + } + tags, err := normalizeClaudeImportTags(raw.Tags) + if err != nil { + return claudeImportDocument{}, err + } + refs := raw.GroupRefs + if len(refs) == 0 { + refs = raw.Groups + } + refs, err = normalizeClaudeGroupRefs(refs) + if err != nil { + return claudeImportDocument{}, err + } + proxyURL := strings.TrimSpace(raw.ProxyURL) + if proxyURL != "" { + if err := security.ValidateProxyURL(proxyURL); err != nil { + return claudeImportDocument{}, errors.New("proxy_url is invalid") + } + } + if expires := strings.TrimSpace(raw.ExpiresAt); expires != "" { + if _, err := time.Parse(time.RFC3339, expires); err != nil { + return claudeImportDocument{}, errors.New("expires_at must be an RFC3339 timestamp") + } + } + return claudeImportDocument{ + Type: strings.TrimSpace(raw.Type), Version: raw.Version, AuthKind: authKind, + Email: strings.TrimSpace(raw.Email), Name: strings.TrimSpace(raw.Name), + AccessToken: accessToken, RefreshToken: refreshToken, AccountID: strings.TrimSpace(raw.AccountID), + ExpiresAt: strings.TrimSpace(raw.ExpiresAt), PlanType: strings.TrimSpace(raw.PlanType), + Models: models, ProxyURL: proxyURL, UseProxyPool: raw.UseProxyPool, + Timezone: timezone, ClaudeFingerprintMode: fingerprintMode, FingerprintHeaders: normalizedHeaders, + Tags: tags, GroupRefs: refs, Enabled: raw.Enabled, + }, nil +} + +func parseClaudeImportDocuments(raw []byte) ([]claudeImportDocument, error) { + if len(raw) == 0 { + return nil, errors.New("credential content is empty") + } + if len(raw) > claudeCredentialExportMaxBytes { + return nil, fmt.Errorf("credential content exceeds %d bytes", claudeCredentialExportMaxBytes) + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, fmt.Errorf("parse credential JSON: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return nil, errors.New("credential content must contain exactly one JSON document") + } + documents := make([]claudeImportDocument, 0, 1) + var collect func(any) error + collect = func(item any) error { + if len(documents) >= claudeCredentialImportMaxEntries { + return fmt.Errorf("credential bundle contains more than %d entries", claudeCredentialImportMaxEntries) + } + switch typed := item.(type) { + case []any: + for _, child := range typed { + if err := collect(child); err != nil { + return err + } + } + return nil + case map[string]any: + if accounts, ok := typed["accounts"]; ok { + if list, ok := accounts.([]any); ok { + for _, child := range list { + if err := collect(child); err != nil { + return err + } + } + return nil + } + return errors.New("accounts must be an array") + } + encoded, err := json.Marshal(typed) + if err != nil { + return err + } + var wire claudeImportWire + if err := json.Unmarshal(encoded, &wire); err != nil { + return fmt.Errorf("invalid Claude credential object: %w", err) + } + document, err := claudeImportDocumentFromWire(wire) + if err != nil { + return err + } + documents = append(documents, document) + return nil + default: + return fmt.Errorf("unsupported credential JSON type %T", item) + } + } + if err := collect(value); err != nil { + return nil, err + } + if len(documents) == 0 { + return nil, errors.New("credential content contains no Claude credentials") + } + return documents, nil +} + +// ExportClaudeAccounts downloads one JSON credential document or a ZIP with +// one document per account. The endpoint is admin-authenticated by the route +// group and deliberately uses secret download headers. +func (h *Handler) ExportClaudeAccounts(c *gin.Context) { + filter := strings.ToLower(strings.TrimSpace(c.DefaultQuery("filter", "all"))) + if filter != "all" && filter != "healthy" { + writeError(c, http.StatusBadRequest, "filter must be all or healthy") + return + } + format := strings.ToLower(strings.TrimSpace(c.DefaultQuery("format", "auto"))) + if format != "auto" && format != "json" && format != "zip" { + writeError(c, http.StatusBadRequest, "format must be auto, json, or zip") + return + } + idSet, err := parseClaudeExportIDSet(c.Query("ids"), c.Request.URL.Query().Has("ids")) + if err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + rows, err := h.db.ListActiveByChannel(ctx, database.UpstreamChannelClaude) + if err != nil { + writeInternalError(c, err) + return + } + if filter == "healthy" && h.store == nil { + writeError(c, http.StatusNotFound, "no exportable Claude accounts") + return + } + runtimeByID := make(map[int64]*auth.Account) + if filter == "healthy" { + for _, account := range h.store.Accounts() { + runtimeByID[account.DBID] = account + } + } + accountIDs := make([]int64, 0, len(rows)) + for _, row := range rows { + if idSet != nil && !idSet[row.ID] { + continue + } + if filter == "healthy" { + account, ok := runtimeByID[row.ID] + if !ok || !account.IsAvailable() { + continue + } + } + accountIDs = append(accountIDs, row.ID) + } + memberships, err := h.db.ListAccountGroupMembershipsByAccountIDs(ctx, accountIDs) + if err != nil { + writeInternalError(c, err) + return + } + groups, err := h.db.ListAccountGroups(ctx) + if err != nil { + writeInternalError(c, err) + return + } + groupByID := make(map[int64]database.AccountGroup, len(groups)) + for _, group := range groups { + groupByID[group.ID] = group + } + entries := make([]claudeExportEntry, 0, len(accountIDs)) + for _, row := range rows { + if idSet != nil && !idSet[row.ID] { + continue + } + if filter == "healthy" { + account, ok := runtimeByID[row.ID] + if !ok || !account.IsAvailable() { + continue + } + } + refs := make([]claudeGroupRef, 0, len(memberships[row.ID])) + for _, groupID := range memberships[row.ID] { + if group, ok := groupByID[groupID]; ok && database.NormalizeAccountGroupChannel(group.Channel) == database.AccountGroupChannelClaude { + refs = append(refs, claudeGroupRef{Name: strings.TrimSpace(group.Name), Channel: database.AccountGroupChannelClaude}) + } + } + if entry, ok := claudeAccountRowToExportEntry(row, refs); ok { + entries = append(entries, entry) + } + } + if len(entries) == 0 { + writeError(c, http.StatusNotFound, "no exportable Claude accounts") + return + } + // Record only aggregate, non-secret audit metadata. Never include an email, + // account ID, token, proxy URL, or serialized response body. + security.SecurityAuditLog("CLAUDE_ACCOUNT_EXPORTED", fmt.Sprintf("count=%d filter=%s format=%s ip=%s", len(entries), filter, format, c.ClientIP())) + useJSON := format == "json" || (format == "auto" && len(entries) == 1) + if useJSON { + var encoded []byte + if len(entries) == 1 { + encoded, err = marshalClaudeExportEntry(entries[0]) + } else { + encoded, err = json.MarshalIndent(entries, "", " ") + } + if err != nil { + writeInternalError(c, err) + return + } + writeSecretDownloadHeaders(c, fmt.Sprintf("codex2api-claude-%s-%d.json", time.Now().UTC().Format("20060102-150405"), len(entries))) + c.Header("X-Export-Count", strconv.Itoa(len(entries))) + c.Data(http.StatusOK, "application/json; charset=utf-8", encoded) + return + } + archive, err := buildClaudeExportZIP(entries) + if err != nil { + writeInternalError(c, err) + return + } + writeSecretDownloadHeaders(c, fmt.Sprintf("codex2api-claude-%s-%d.zip", time.Now().UTC().Format("20060102-150405"), len(entries))) + c.Header("X-Export-Count", strconv.Itoa(len(entries))) + c.Data(http.StatusOK, "application/zip", archive) +} diff --git a/admin/claude_export_test.go b/admin/claude_export_test.go new file mode 100644 index 000000000..d1c10f37b --- /dev/null +++ b/admin/claude_export_test.go @@ -0,0 +1,661 @@ +package admin + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/codex2api/auth" + "github.com/codex2api/database" + "github.com/gin-gonic/gin" +) + +func TestClaudeAccountRowToExportEntryIncludesPortableMetadataAndAllowlistedFingerprint(t *testing.T) { + row := &database.AccountRow{ + ID: 42, Name: "Claude operator", Platform: "anthropic", Enabled: false, + Tags: []string{"prod", "claude"}, + Credentials: map[string]interface{}{ + "upstream_type": auth.UpstreamClaude, + "email": "claude@example.com", + "account_id": "account-42", + "access_token": "access-secret", + "refresh_token": "refresh-secret", + "expires_at": "2026-09-01T00:00:00Z", + "plan_type": "max-5x", + "models": []string{"claude-sonnet-4-5"}, + "timezone": "Asia/Shanghai", + auth.ClaudeFingerprintModeCredentialKey: "force", + "custom_headers": map[string]string{ + "User-Agent": "claude-cli/test", + "X-Stainless-OS": "MacOS", + "Authorization": "must-not-export", + "X-Api-Key": "must-not-export", + "X-Internal-Operator": "must-not-export", + }, + }, + } + + entry, ok := claudeAccountRowToExportEntry(row, []claudeGroupRef{{Name: "Claude", Channel: "claude"}}) + if !ok { + t.Fatal("Claude OAuth row should be exportable") + } + if entry.Type != "claude" || entry.Version != claudeCredentialExportVersion || entry.AuthKind != "oauth" { + t.Fatalf("export identity = %+v", entry) + } + if entry.Email != "claude@example.com" || entry.AccountID != "account-42" || entry.Name != "Claude operator" { + t.Fatalf("export metadata = %+v", entry) + } + if entry.Enabled { + t.Fatal("disabled state must be preserved in an export") + } + if entry.ClaudeFingerprintMode != "force" || entry.Timezone != "Asia/Shanghai" { + t.Fatalf("fingerprint metadata = %+v", entry) + } + if len(entry.FingerprintHeaders) != 2 || entry.FingerprintHeaders["Authorization"] != "" || entry.FingerprintHeaders["X-Api-Key"] != "" { + t.Fatalf("secret/non-identity headers leaked: %+v", entry.FingerprintHeaders) + } + if len(entry.Tags) != 2 || len(entry.GroupRefs) != 1 || entry.GroupRefs[0].Name != "Claude" { + t.Fatalf("portable metadata = %+v", entry) + } + encoded, err := marshalClaudeExportEntry(entry) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"must-not-export", "Authorization", "X-Api-Key", "X-Internal-Operator"} { + if strings.Contains(string(encoded), forbidden) { + t.Fatalf("export contains forbidden value %q: %s", forbidden, encoded) + } + } +} + +func TestBuildAccountResponseClaudeRedactsNonIdentityHeaders(t *testing.T) { + store := auth.NewStore(nil, nil, nil) + t.Cleanup(store.Stop) + row := &database.AccountRow{ + ID: 7, Name: "claude", Platform: "anthropic", Status: "active", Enabled: true, + Credentials: map[string]interface{}{ + "upstream_type": auth.UpstreamClaude, + "access_token": "access-secret", + "refresh_token": "refresh-secret", + "custom_headers": map[string]string{ + "User-Agent": "claude-cli/test", + "Authorization": "must-not-leak", + "Cookie": "must-not-leak", + "X-Operator": "must-not-leak", + }, + }, + } + response := (&Handler{store: store}).buildAccountResponse(row, nil, nil, nil, nil, true) + if response.ClaudeUserAgent != "claude-cli/test" { + t.Fatalf("identity User-Agent missing from safe detail field: %q", response.ClaudeUserAgent) + } + if response.CustomHeaders["User-Agent"] != "claude-cli/test" { + t.Fatalf("identity header missing from detail response: %+v", response.CustomHeaders) + } + for _, forbidden := range []string{"Authorization", "Cookie", "X-Operator"} { + if _, ok := response.CustomHeaders[forbidden]; ok { + t.Fatalf("Claude detail response leaked %s: %+v", forbidden, response.CustomHeaders) + } + } +} + +func TestClaudeImportParserAcceptsArrayAndRejectsNonOAuth(t *testing.T) { + raw := `[{"type":"claude","version":1,"auth_kind":"oauth","name":"one","access_token":"at-1","refresh_token":"rt-1","account_id":"acct-1","models":["claude-sonnet-4-5"],"timezone":"Asia/Shanghai","tags":["prod"],"group_refs":[{"name":"Claude","channel":"claude"}],"enabled":false},{"upstream_type":"claude","access_token":"at-2","refresh_token":"rt-2"}]` + docs, err := parseClaudeImportDocuments([]byte(raw)) + if err != nil { + t.Fatalf("parse array: %v", err) + } + if len(docs) != 2 || docs[0].Name != "one" || docs[0].Enabled == nil || *docs[0].Enabled { + t.Fatalf("parsed documents = %+v", docs) + } + if docs[0].Models[0] != "claude-sonnet-4-5" || docs[0].Timezone != "Asia/Shanghai" { + t.Fatalf("parsed metadata = %+v", docs[0]) + } + if _, err := parseClaudeImportDocuments([]byte(`{"type":"claude","auth_kind":"api_key","access_token":"at","refresh_token":"rt"}`)); err == nil { + t.Fatal("API-key auth_kind must be rejected") + } +} + +func TestClaudeImportParserRoundTripsExportAndRejectsSecretHeaders(t *testing.T) { + entry := claudeExportEntry{ + Type: "claude", Version: claudeCredentialExportVersion, AuthKind: "oauth", + Name: "round-trip", Email: "round@example.com", AccountID: "acct-round", + AccessToken: "at-round", RefreshToken: "rt-round", ExpiresAt: "2026-09-01T00:00:00Z", + Models: []string{"claude-haiku-4-5"}, Timezone: "UTC", ClaudeFingerprintMode: "preserve", + FingerprintHeaders: map[string]string{"User-Agent": "claude-cli/test"}, + Tags: []string{"one"}, GroupRefs: []claudeGroupRef{{Name: "Claude", Channel: "claude"}}, Enabled: true, + } + raw, err := marshalClaudeExportEntry(entry) + if err != nil { + t.Fatal(err) + } + docs, err := parseClaudeImportDocuments(raw) + if err != nil { + t.Fatalf("round-trip parse: %v", err) + } + if len(docs) != 1 || docs[0].AccountID != entry.AccountID || docs[0].RefreshToken != entry.RefreshToken { + t.Fatalf("round-trip document = %+v", docs) + } + if _, err := parseClaudeImportDocuments([]byte(`{"type":"claude","auth_kind":"oauth","access_token":"at","refresh_token":"rt","fingerprint_headers":{"Authorization":"blocked"}}`)); err == nil { + t.Fatal("secret identity headers must be rejected rather than silently imported") + } +} + +func TestResolveClaudeGroupRefsMapsByNameAndChannel(t *testing.T) { + db := newTestAdminDB(t) + h := &Handler{db: db} + ctx := context.Background() + claudeID, err := db.CreateAccountGroup(ctx, "Claude", "", "", 0, 0, database.OptionalNullInt64{}.Value) + if err != nil { + t.Fatal(err) + } + channel := database.AccountGroupChannelClaude + if err := db.UpdateAccountGroup(ctx, claudeID, nil, nil, nil, &database.UpdateAccountGroupOpts{Channel: &channel}); err != nil { + t.Fatal(err) + } + if _, err := db.CreateAccountGroup(ctx, "Codex", "", "", 0, 0, database.OptionalNullInt64{}.Value); err != nil { + t.Fatal(err) + } + ids, missing, err := h.resolveClaudeGroupRefs(ctx, []claudeGroupRef{ + {Name: "claude", Channel: "claude"}, + {Name: "Codex", Channel: "codex"}, + {Name: "missing", Channel: "claude"}, + }) + if err != nil { + t.Fatal(err) + } + if len(ids) != 1 || ids[0] != claudeID || len(missing) != 2 { + t.Fatalf("resolved ids=%v missing=%v", ids, missing) + } +} + +func TestBuildClaudeExportZIPUsesSafeNames(t *testing.T) { + entries := []claudeExportEntry{ + {Email: "../../a@example.com", AccessToken: "at-a", RefreshToken: "rt-a", Type: "claude", Version: 1, AuthKind: "oauth"}, + {Email: "a@example.com", AccessToken: "at-b", RefreshToken: "rt-b", Type: "claude", Version: 1, AuthKind: "oauth"}, + } + archive, err := buildClaudeExportZIP(entries) + if err != nil { + t.Fatal(err) + } + reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil || len(reader.File) != 2 { + t.Fatalf("zip files=%d err=%v", len(reader.File), err) + } + for _, member := range reader.File { + if strings.Contains(member.Name, "/") || strings.Contains(member.Name, "\\") { + t.Fatalf("unsafe member %q", member.Name) + } + body, err := member.Open() + if err != nil { + t.Fatal(err) + } + data, _ := io.ReadAll(body) + _ = body.Close() + var decoded claudeExportEntry + if err := json.Unmarshal(data, &decoded); err != nil || decoded.Type != "claude" { + t.Fatalf("member %q decode err=%v body=%s", member.Name, err, data) + } + } +} + +func TestExportClaudeAccountsSingleSetsSecretDownloadHeaders(t *testing.T) { + db := newTestAdminDB(t) + ctx := context.Background() + id, err := db.InsertAccountWithUpstream(ctx, "claude", "anthropic", auth.UpstreamClaude, map[string]interface{}{ + "upstream_type": auth.UpstreamClaude, "email": "single@example.com", "account_id": "single-acct", + "access_token": "single-at", "refresh_token": "single-rt", "expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + }, "") + if err != nil { + t.Fatal(err) + } + h := &Handler{db: db, store: auth.NewStore(db, nil, nil)} + t.Cleanup(h.store.Stop) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/accounts/claude/export?ids="+strconv.FormatInt(id, 10), nil) + h.ExportClaudeAccounts(c) + if recorder.Code != http.StatusOK || recorder.Header().Get("Content-Type") != "application/json; charset=utf-8" { + t.Fatalf("status=%d content-type=%q body=%s", recorder.Code, recorder.Header().Get("Content-Type"), recorder.Body.String()) + } + if recorder.Header().Get("Cache-Control") != "no-store, max-age=0" || recorder.Header().Get("Pragma") != "no-cache" || recorder.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Fatalf("secret headers = %#v", recorder.Header()) + } + if recorder.Header().Get("X-Export-Count") != "1" || !strings.Contains(recorder.Header().Get("Content-Disposition"), "attachment") { + t.Fatalf("download headers = %#v", recorder.Header()) + } +} + +func TestExportClaudeAccountsSupportsHealthyFilterAndRejectsWrongSelection(t *testing.T) { + db := newTestAdminDB(t) + ctx := context.Background() + first, err := db.InsertAccountWithUpstream(ctx, "healthy", "anthropic", auth.UpstreamClaude, map[string]interface{}{ + "upstream_type": auth.UpstreamClaude, "account_id": "healthy-acct", "access_token": "healthy-at", "refresh_token": "healthy-rt", + }, "") + if err != nil { + t.Fatal(err) + } + second, err := db.InsertAccountWithUpstream(ctx, "error", "anthropic", auth.UpstreamClaude, map[string]interface{}{ + "upstream_type": auth.UpstreamClaude, "account_id": "error-acct", "access_token": "error-at", "refresh_token": "error-rt", + }, "") + if err != nil { + t.Fatal(err) + } + wrongChannel, err := db.InsertAccountWithUpstream(ctx, "grok", "xai", auth.UpstreamGrok, map[string]interface{}{ + "upstream_type": auth.UpstreamGrok, "access_token": "grok-at", "refresh_token": "grok-rt", + }, "") + if err != nil { + t.Fatal(err) + } + store := auth.NewStore(db, nil, nil) + t.Cleanup(store.Stop) + store.AddAccount(&auth.Account{DBID: first, UpstreamType: auth.UpstreamClaude, AccessToken: "healthy-at", RefreshToken: "healthy-rt", Status: auth.StatusReady}) + store.AddAccount(&auth.Account{DBID: second, UpstreamType: auth.UpstreamClaude, AccessToken: "error-at", RefreshToken: "error-rt", Status: auth.StatusError}) + h := &Handler{db: db, store: store} + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/accounts/claude/export?filter=healthy", nil) + h.ExportClaudeAccounts(c) + if recorder.Code != http.StatusOK || recorder.Header().Get("X-Export-Count") != "1" { + t.Fatalf("healthy export status=%d count=%q body=%s", recorder.Code, recorder.Header().Get("X-Export-Count"), recorder.Body.String()) + } + var healthy claudeExportEntry + if err := json.Unmarshal(recorder.Body.Bytes(), &healthy); err != nil || healthy.AccountID != "healthy-acct" { + t.Fatalf("healthy export = %+v err=%v", healthy, err) + } + + wrong := httptest.NewRecorder() + c, _ = gin.CreateTestContext(wrong) + c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/accounts/claude/export?ids="+strconv.FormatInt(wrongChannel, 10), nil) + h.ExportClaudeAccounts(c) + if wrong.Code != http.StatusNotFound || strings.Contains(wrong.Body.String(), "grok-rt") { + t.Fatalf("wrong-channel export status=%d body=%s", wrong.Code, wrong.Body.String()) + } + + invalid := httptest.NewRecorder() + c, _ = gin.CreateTestContext(invalid) + c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/accounts/claude/export?ids=bad", nil) + h.ExportClaudeAccounts(c) + if invalid.Code != http.StatusBadRequest { + t.Fatalf("invalid ids status=%d body=%s", invalid.Code, invalid.Body.String()) + } +} + +func TestExportClaudeAccountsFormatSelection(t *testing.T) { + db := newTestAdminDB(t) + ctx := context.Background() + for _, suffix := range []string{"one", "two"} { + _, err := db.InsertAccountWithUpstream(ctx, suffix, "anthropic", auth.UpstreamClaude, map[string]interface{}{ + "upstream_type": auth.UpstreamClaude, "account_id": "format-" + suffix, + "access_token": "at-format-" + suffix, "refresh_token": "rt-format-" + suffix, + }, "") + if err != nil { + t.Fatal(err) + } + } + h := &Handler{db: db, store: auth.NewStore(db, nil, nil)} + t.Cleanup(h.store.Stop) + + jsonRecorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(jsonRecorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/accounts/claude/export?format=json", nil) + h.ExportClaudeAccounts(c) + if jsonRecorder.Code != http.StatusOK || jsonRecorder.Header().Get("Content-Type") != "application/json; charset=utf-8" || jsonRecorder.Header().Get("X-Export-Count") != "2" { + t.Fatalf("json export status=%d headers=%#v body=%s", jsonRecorder.Code, jsonRecorder.Header(), jsonRecorder.Body.String()) + } + var documents []claudeExportEntry + if err := json.Unmarshal(jsonRecorder.Body.Bytes(), &documents); err != nil || len(documents) != 2 { + t.Fatalf("json export documents=%d err=%v", len(documents), err) + } + + invalidRecorder := httptest.NewRecorder() + c, _ = gin.CreateTestContext(invalidRecorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/accounts/claude/export?format=csv", nil) + h.ExportClaudeAccounts(c) + if invalidRecorder.Code != http.StatusBadRequest { + t.Fatalf("invalid format status=%d body=%s", invalidRecorder.Code, invalidRecorder.Body.String()) + } + + zipRecorder := httptest.NewRecorder() + c, _ = gin.CreateTestContext(zipRecorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/accounts/claude/export?ids=1&format=zip", nil) + h.ExportClaudeAccounts(c) + if zipRecorder.Code != http.StatusOK || zipRecorder.Header().Get("Content-Type") != "application/zip" { + t.Fatalf("forced zip status=%d content-type=%q", zipRecorder.Code, zipRecorder.Header().Get("Content-Type")) + } +} + +func TestPrepareClaudeTimezoneCredentialUpdateRegeneratesOnlyClaudeIdentityHeaders(t *testing.T) { + row := &database.AccountRow{Credentials: map[string]interface{}{ + "upstream_type": auth.UpstreamClaude, + "timezone": "Asia/Shanghai", + "custom_headers": map[string]string{ + "User-Agent": "claude-cli/old", + "X-Stainless-OS": "Linux", + "X-Request-Id": "keep-me", + "X-Operator-Tag": "must-be-removed", + }, + }} + updates := map[string]interface{}{} + if err := prepareClaudeTimezoneCredentialUpdate(row, "America/New_York", updates); err != nil { + t.Fatalf("prepare timezone update: %v", err) + } + raw, ok := updates["custom_headers"].(map[string]string) + if !ok { + t.Fatalf("custom_headers update type = %T, want map[string]string", updates["custom_headers"]) + } + if raw["X-Request-Id"] != "keep-me" { + t.Fatalf("non-identity custom header was not preserved: %+v", raw) + } + if _, exists := raw["X-Operator-Tag"]; exists { + t.Fatalf("unapproved non-identity header was preserved: %+v", raw) + } + if raw["User-Agent"] == "claude-cli/old" || strings.TrimSpace(raw["User-Agent"]) == "" { + t.Fatalf("identity fingerprint was not regenerated: %+v", raw) + } + for _, secretHeader := range []string{"Authorization", "X-Api-Key", "Cookie"} { + if _, exists := raw[secretHeader]; exists { + t.Fatalf("secret header unexpectedly present: %q", secretHeader) + } + } +} + +func TestPrepareClaudeTimezoneCredentialUpdateDoesNotRotateUnchangedFingerprint(t *testing.T) { + row := &database.AccountRow{Platform: "anthropic", Credentials: map[string]interface{}{ + "upstream_type": auth.UpstreamClaude, + "timezone": "Asia/Shanghai", + "custom_headers": map[string]string{ + "User-Agent": "claude-cli/stable", + "X-App": "cli", + "X-Stainless-Lang": "js", + "X-Stainless-Package-Version": "0.60.0", + "X-Stainless-OS": "Linux", + "X-Stainless-Arch": "x64", + "X-Stainless-Runtime": "node", + "X-Stainless-Runtime-Version": "v20.18.1", + }, + }} + updates := map[string]interface{}{} + if err := prepareClaudeTimezoneCredentialUpdate(row, "Asia/Shanghai", updates); err != nil { + t.Fatalf("prepare unchanged timezone: %v", err) + } + headers, ok := updates["custom_headers"].(map[string]string) + if !ok || headers["User-Agent"] != "claude-cli/stable" { + t.Fatalf("unchanged timezone rotated fingerprint: %+v", updates["custom_headers"]) + } +} + +func TestUpdateAccountSchedulerTimezoneUsesSafeExplicitHeadersAndSyncsRuntime(t *testing.T) { + db := newTestAdminDB(t) + id, err := db.InsertAccountWithUpstream(context.Background(), "timezone", "anthropic", auth.UpstreamClaude, map[string]interface{}{ + "upstream_type": auth.UpstreamClaude, + "access_token": "at-timezone", "refresh_token": "rt-timezone", "account_id": "acct-timezone", + "timezone": "Asia/Shanghai", + "custom_headers": map[string]string{"User-Agent": "claude-cli/old", "X-Request-Id": "old"}, + }, "") + if err != nil { + t.Fatal(err) + } + store := auth.NewStore(db, nil, nil) + t.Cleanup(store.Stop) + runtimeAccount := &auth.Account{DBID: id, UpstreamType: auth.UpstreamClaude, AccessToken: "at-timezone", RefreshToken: "rt-timezone", CustomHeaders: map[string]string{"User-Agent": "claude-cli/old", "X-Request-Id": "old"}} + store.AddAccount(runtimeAccount) + h := &Handler{db: db, store: store} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Params = gin.Params{{Key: "id", Value: strconv.FormatInt(id, 10)}} + c.Request = httptest.NewRequest(http.MethodPatch, "/api/admin/accounts/"+strconv.FormatInt(id, 10)+"/scheduler", strings.NewReader(`{"timezone":"America/New_York","custom_headers":{"User-Agent":"client-supplied","X-Request-Id":"new-safe","Authorization":"must-drop"}}`)) + h.UpdateAccountScheduler(c) + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + row, err := db.GetAccountByID(context.Background(), id) + if err != nil { + t.Fatal(err) + } + persisted := row.GetCredentialStringMap("custom_headers") + if row.GetCredential("timezone") != "America/New_York" { + t.Fatalf("persisted timezone=%q", row.GetCredential("timezone")) + } + if persisted["X-Request-Id"] != "new-safe" || persisted["Authorization"] != "" { + t.Fatalf("persisted safe/secret headers = %+v", persisted) + } + if persisted["User-Agent"] == "client-supplied" || persisted["User-Agent"] == "claude-cli/old" { + t.Fatalf("timezone did not rebuild identity headers: %+v", persisted) + } + runtime := runtimeAccount.GetCustomHeaders() + if runtime["X-Request-Id"] != "new-safe" || runtime["Authorization"] != "" || runtime["User-Agent"] != persisted["User-Agent"] { + t.Fatalf("runtime headers=%+v persisted=%+v", runtime, persisted) + } +} + +func TestImportClaudeTokenArrayPreservesMetadataAndDeduplicates(t *testing.T) { + db := newTestAdminDB(t) + groupID, err := db.CreateAccountGroup(context.Background(), "Claude production", "", "", 0, 0, database.OptionalNullInt64{}.Value) + if err != nil { + t.Fatal(err) + } + channel := database.AccountGroupChannelClaude + if err := db.UpdateAccountGroup(context.Background(), groupID, nil, nil, nil, &database.UpdateAccountGroupOpts{Channel: &channel}); err != nil { + t.Fatal(err) + } + h := &Handler{db: db} + body := `[{"type":"claude","version":1,"auth_kind":"oauth","name":"one","email":"one@example.com","account_id":"acct-one","access_token":"at-one","refresh_token":"rt-one","models":["claude-haiku-4-5"],"timezone":"Asia/Shanghai","claude_fingerprint_mode":"force","tags":["prod"],"group_refs":[{"name":"Claude production","channel":"claude"}],"enabled":false},{"type":"claude","version":1,"auth_kind":"oauth","name":"two","account_id":"acct-two","access_token":"at-two","refresh_token":"rt-two","models":["claude-sonnet-4-5"]}]` + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/admin/accounts/claude/import", strings.NewReader(body)) + h.ImportClaudeToken(c) + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + var result struct { + Total int `json:"total"` + Imported int `json:"imported"` + Failed int `json:"failed"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.Total != 2 || result.Imported != 2 || result.Failed != 0 { + t.Fatalf("import result = %+v", result) + } + rows, err := db.ListActiveByChannel(context.Background(), database.UpstreamChannelClaude) + if err != nil || len(rows) != 2 { + t.Fatalf("Claude rows=%d err=%v", len(rows), err) + } + var disabledRow *database.AccountRow + for _, row := range rows { + if row.GetCredential("account_id") == "acct-one" { + disabledRow = row + } + } + if disabledRow == nil || disabledRow.Enabled { + t.Fatalf("disabled metadata not preserved: %+v", disabledRow) + } + if len(disabledRow.Tags) != 1 || disabledRow.Tags[0] != "prod" { + t.Fatalf("tags not preserved: %v", disabledRow.Tags) + } + if disabledRow.GetCredential(auth.ClaudeFingerprintModeCredentialKey) != auth.ClaudeFingerprintModeForce { + t.Fatalf("fingerprint mode not preserved: %q", disabledRow.GetCredential(auth.ClaudeFingerprintModeCredentialKey)) + } + groups, err := db.GetAccountGroupIDs(context.Background(), disabledRow.ID) + if err != nil || len(groups) != 1 || groups[0] != groupID { + t.Fatalf("group mapping = %v err=%v", groups, err) + } + + duplicate := httptest.NewRecorder() + c, _ = gin.CreateTestContext(duplicate) + c.Request = httptest.NewRequest(http.MethodPost, "/api/admin/accounts/claude/import", strings.NewReader(`{"type":"claude","access_token":"at-one-new","refresh_token":"rt-one-new","account_id":"acct-one","models":["claude-haiku-4-5"]}`)) + h.ImportClaudeToken(c) + if duplicate.Code != http.StatusConflict { + t.Fatalf("duplicate status=%d body=%s", duplicate.Code, duplicate.Body.String()) + } + + // The provider account identifier can change independently of a refresh + // token. The token must still prevent a second active account from being + // created under a different account_id. + sameRefreshToken := httptest.NewRecorder() + c, _ = gin.CreateTestContext(sameRefreshToken) + c.Request = httptest.NewRequest(http.MethodPost, "/api/admin/accounts/claude/import", strings.NewReader(`{"type":"claude","access_token":"at-two-new","refresh_token":"rt-two","account_id":"acct-two-rotated","models":["claude-sonnet-4-5"]}`)) + h.ImportClaudeToken(c) + if sameRefreshToken.Code != http.StatusConflict { + t.Fatalf("same refresh token status=%d body=%s", sameRefreshToken.Code, sameRefreshToken.Body.String()) + } +} + +func TestClaudeImportPartialFingerprintHeadersAreCompleted(t *testing.T) { + db := newTestAdminDB(t) + h := &Handler{db: db} + body := `{"type":"claude","version":1,"auth_kind":"oauth","account_id":"partial-fp","access_token":"at-partial","refresh_token":"rt-partial","models":["claude-haiku-4-5"],"fingerprint_headers":{"User-Agent":"claude-cli/custom"}}` + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/admin/accounts/claude/import", strings.NewReader(body)) + h.ImportClaudeToken(c) + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + rows, err := db.ListActiveByChannel(context.Background(), database.UpstreamChannelClaude) + if err != nil || len(rows) != 1 { + t.Fatalf("rows=%d err=%v", len(rows), err) + } + headers := rows[0].GetCredentialStringMap("custom_headers") + if headers["User-Agent"] != "claude-cli/custom" { + t.Fatalf("provided User-Agent was not preserved: %+v", headers) + } + for _, name := range auth.ClaudeIdentityHeaderNames { + found := "" + for key, value := range headers { + if strings.EqualFold(key, name) { + found = value + break + } + } + if strings.TrimSpace(found) == "" { + t.Fatalf("partial fingerprint was not completed: missing %s in %+v", name, headers) + } + } +} + +func TestClaudeImportPreservesFingerprintModeInRuntime(t *testing.T) { + db := newTestAdminDB(t) + store := auth.NewStore(db, nil, nil) + t.Cleanup(store.Stop) + h := &Handler{db: db, store: store} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/admin/accounts/claude/import", strings.NewReader(`{"type":"claude","version":1,"auth_kind":"oauth","account_id":"runtime-fp","access_token":"at-runtime-fp","refresh_token":"rt-runtime-fp","models":["claude-haiku-4-5"],"claude_fingerprint_mode":"force"}`)) + h.ImportClaudeToken(c) + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + rows, err := db.ListActiveByChannel(context.Background(), database.UpstreamChannelClaude) + if err != nil || len(rows) != 1 { + t.Fatalf("rows=%d err=%v", len(rows), err) + } + if got := rows[0].GetCredential(auth.ClaudeFingerprintModeCredentialKey); got != auth.ClaudeFingerprintModeForce { + t.Fatalf("persisted fingerprint mode=%q", got) + } + account := store.FindByID(rows[0].ID) + if account == nil || account.ClaudeFingerprintMode != auth.ClaudeFingerprintModeForce { + t.Fatalf("runtime account fingerprint mode=%v", account) + } +} + +func TestClaudeBatchImportCanSkipPerAccountModelFetch(t *testing.T) { + db := newTestAdminDB(t) + h := &Handler{db: db} + created, err := h.createClaudeAccount(context.Background(), "batch-no-probe", "", "UTC", &auth.ClaudeTokenData{ + AccessToken: "at-batch-no-probe", RefreshToken: "rt-batch-no-probe", AccountUUID: "acct-batch-no-probe", ExpiresAt: time.Now().Add(time.Hour), + }, "test", &claudeAccountImportOptions{SkipModelFetch: true}) + if err != nil { + t.Fatalf("create without model probe: %v", err) + } + row, err := db.GetAccountByID(context.Background(), created.ID) + if err != nil { + t.Fatal(err) + } + if models := row.GetCredentialStringSlice("models"); len(models) != 0 { + t.Fatalf("skip-model-fetch unexpectedly persisted upstream models: %v", models) + } +} + +func TestClaudeImportWarmupSkipsExplicitlyDisabledAccounts(t *testing.T) { + disabled := false + if shouldScheduleClaudeImportWarmup(&claudeAccountImportOptions{Enabled: &disabled}) { + t.Fatal("explicitly disabled Claude imports must not schedule a warmup probe") + } + if !shouldScheduleClaudeImportWarmup(&claudeAccountImportOptions{}) { + t.Fatal("legacy/unspecified Claude imports should retain warmup behavior") + } +} + +func TestNormalizeClaudeImportTagsRejectsControlCharacters(t *testing.T) { + for _, value := range []string{"line\nbreak", "null\x00byte", "unit\x1fsep"} { + if _, err := normalizeClaudeImportTags([]string{value}); err == nil { + t.Fatalf("tags value %q with control character was accepted", value) + } + } +} + +func TestClaudeImportMetadataRejectsControlCharactersAndOversizedValues(t *testing.T) { + base := `{"type":"claude","access_token":"at-meta","refresh_token":"rt-meta","models":["claude-haiku-4-5"]}` + for field, value := range map[string]string{ + "email": "bad\nemail@example.com", + "account_id": "acct\x00bad", + "plan_type": "plan\x1fbad", + } { + raw := strings.TrimSuffix(base, "}") + ",\"" + field + "\":\"" + value + "\"}" + if _, err := parseClaudeImportDocuments([]byte(raw)); err == nil { + t.Fatalf("metadata field %s accepted control character", field) + } + } + oversized := strings.TrimSuffix(base, "}") + ",\"plan_type\":\"" + strings.Repeat("x", 81) + "\"}" + if _, err := parseClaudeImportDocuments([]byte(oversized)); err == nil { + t.Fatal("oversized plan_type was accepted") + } +} + +func TestClaudeCreateMetadataFailureReturnsCommittedWarning(t *testing.T) { + db := newTestAdminDB(t) + h := &Handler{db: db} + codexGroupID, groupErr := db.CreateAccountGroup(context.Background(), "codex-only", "", "", 0, 0, database.OptionalNullInt64{}.Value) + if groupErr != nil { + t.Fatal(groupErr) + } + created, err := h.createClaudeAccount(context.Background(), "warning", "", "UTC", &auth.ClaudeTokenData{ + AccessToken: "at-warning", RefreshToken: "rt-warning", AccountUUID: "acct-warning", ExpiresAt: time.Now().Add(time.Hour), + }, "test", &claudeAccountImportOptions{ + Models: []string{"claude-haiku-4-5"}, + ResolvedGroupIDs: []int64{codexGroupID}, // force post-insert channel binding failure + }) + if err != nil { + t.Fatalf("committed metadata warning should not be returned as fatal: %v", err) + } + if created.ID <= 0 || len(created.Warnings) == 0 { + t.Fatalf("create result = %+v, want committed id and warning", created) + } + if _, err := db.GetAccountByID(context.Background(), created.ID); err != nil { + t.Fatalf("account should remain recoverable after metadata warning: %v", err) + } +} + +func TestImportClaudeTokenSinglePreservesCreateErrorStatus(t *testing.T) { + db := newTestAdminDB(t) + h := &Handler{db: db} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/admin/accounts/claude/import", strings.NewReader(`{"type":"claude","access_token":"at-bad-model","refresh_token":"rt-bad-model","models":["gpt-5"]}`)) + h.ImportClaudeToken(c) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s, want 400 from provider validation", recorder.Code, recorder.Body.String()) + } +} diff --git a/admin/grok_export.go b/admin/grok_export.go index 546fb36b3..c2cd8b731 100644 --- a/admin/grok_export.go +++ b/admin/grok_export.go @@ -267,12 +267,17 @@ func grokExportDownloadName(count int, ext string) string { } // accountRowToExportEntry 按平台分派导出形态:Grok/xAI 账号走 Grok CLI 超集形态, -// 其余走 CPA(codex) 形态。 +// 传统 Codex 账号走 CPA 形态。Claude OAuth 不进入这个通用导出端点:其 token +// 不是 Codex auth.json,误导出为 type:"codex" 会导致回灌协议错误并扩大凭据暴露面。 // // 通用导出端点原先对所有账号硬编码 type:"codex",Grok 账号既被标错类型、又丢掉 // client_id / token_endpoint / oidc_issuer / principal_* —— 导出的文件回灌必然失败 // (导入侧对 client_id 是硬要求)。这里按平台分派修掉该问题。 func accountRowToExportEntry(row *database.AccountRow) (any, bool) { + if row != nil && (strings.EqualFold(strings.TrimSpace(row.Platform), "anthropic") || + strings.EqualFold(strings.TrimSpace(row.GetCredential("upstream_type")), auth.UpstreamClaude)) { + return nil, false + } if isGrokAccountRow(row) { entry, ok := grokAccountRowToExportEntry(row) if !ok { diff --git a/admin/grok_export_test.go b/admin/grok_export_test.go index 9ac3390b1..15f1426a3 100644 --- a/admin/grok_export_test.go +++ b/admin/grok_export_test.go @@ -91,6 +91,20 @@ func TestGrokAccountRowToExportEntryOAuth(t *testing.T) { } } +func TestAccountRowToExportEntrySkipsClaudeOAuth(t *testing.T) { + row := &database.AccountRow{ + Platform: "anthropic", + Credentials: map[string]interface{}{ + "upstream_type": auth.UpstreamClaude, + "access_token": "claude-access", + "refresh_token": "claude-refresh", + }, + } + if entry, ok := accountRowToExportEntry(row); ok || entry != nil { + t.Fatalf("generic Codex export must skip Claude OAuth, entry=%#v ok=%v", entry, ok) + } +} + // TestGrokExportRoundTripsThroughImporter 是补字段这个决策的验证点: // 导出的文件必须能被 ParseGrokAuthJSON 解回来,且 client_id 不依赖 access_token // 的 JWT claims —— AT 过期或缺失时也要能凭 refresh_token 继续刷新。 diff --git a/admin/handler.go b/admin/handler.go index aa3579dae..db0c59590 100644 --- a/admin/handler.go +++ b/admin/handler.go @@ -45,22 +45,25 @@ import ( // Handler 管理后台 API 处理器 type Handler struct { - store *auth.Store - cache cache.TokenCache - db *database.DB - cacheCfgStore responseCacheSettingsStore - rateLimiter *proxy.RateLimiter - systemUpdate *systemUpdater - systemUpdateOnce sync.Once - refreshAccount func(context.Context, int64) error - probeUsage func(context.Context, *auth.Account) error - activate5hWindow func(context.Context, *auth.Account) error - executeUsageProbe usageProbeRequestFunc - syncAccountPlanOnReset func(context.Context, *auth.Account) error - queryResetCredits func(context.Context, *auth.Account, string) (*proxy.WhamResetCreditsList, *http.Response, error) - consumeResetCredit func(context.Context, *auth.Account, string, string) (*proxy.WhamResetResult, *http.Response, error) - queryWhamDailyUsage func(context.Context, *auth.Account, string, string, string) (*proxy.WhamDailyUsageResponse, *http.Response, error) - sendCodexInvite func(context.Context, *auth.Account, string, string, string, []string) (*proxy.CodexInviteResult, error) + store *auth.Store + cache cache.TokenCache + db *database.DB + cacheCfgStore responseCacheSettingsStore + rateLimiter *proxy.RateLimiter + systemUpdate *systemUpdater + systemUpdateOnce sync.Once + refreshAccount func(context.Context, int64) error + probeUsage func(context.Context, *auth.Account) error + // executeClaudeUsageProbe is injectable for tests; production uses the + // provider-native Anthropic Messages request directly. + executeClaudeUsageProbe func(context.Context, *auth.Account, []byte) (*http.Response, error) + activate5hWindow func(context.Context, *auth.Account) error + executeUsageProbe usageProbeRequestFunc + syncAccountPlanOnReset func(context.Context, *auth.Account) error + queryResetCredits func(context.Context, *auth.Account, string) (*proxy.WhamResetCreditsList, *http.Response, error) + consumeResetCredit func(context.Context, *auth.Account, string, string) (*proxy.WhamResetResult, *http.Response, error) + queryWhamDailyUsage func(context.Context, *auth.Account, string, string, string) (*proxy.WhamDailyUsageResponse, *http.Response, error) + sendCodexInvite func(context.Context, *auth.Account, string, string, string, []string) (*proxy.CodexInviteResult, error) // 列表 page-stats 发现当前页缺少官方结算快照时,按账号做即时回补; // last/in-flight 避免翻页或前端重试把同一号打爆上游,failedAt 给持续 // 失败的账号更长的冷却,syncedOnce 记录「成功同步过但上游没有数据」 @@ -129,6 +132,9 @@ type Handler struct { // accountCachesGen 在账号变更时递增;重建协程安装快照前校验代数, // 防止变更前就开始读库的在途重建把旧数据写回缓存。 accountCachesGen atomic.Uint64 + // Claude 用量采样只改变 Claude 列表投影;独立代数避免频繁采样让 + // Codex/Grok/Antigravity 的大池快照无谓失效。 + claudeAccountCachesGen atomic.Uint64 // 分析图表使用固定大小的聚合结果,避免把完整号池传给浏览器。与账号 // 快照分开缓存,只有展开分析区或 Dashboard runway 时才会构建。 @@ -324,8 +330,10 @@ func (h *Handler) probeImportedAccountUsage(ctx context.Context, accountID int64 log.Printf("导入账号 %d 用量采样失败 (%s): %v", accountID, source, err) return } - // Agent Identity 无 OAuth 身份合并需求(无 RT/AT),探针后直接返回。 - if account.IsCodexAgentIdentity() { + // Agent Identity 无 OAuth 身份合并需求(无 RT/AT),Claude 也使用 + // Anthropic account UUID 而非 ChatGPT workspace 身份;两者都不能进入 + // Codex 的 email+workspace 查重链。 + if !shouldMergeImportedIdentity(account) { return } // AT / codex_at 账号的 OAuth 身份(email + 有效工作区)在插入时无法从 @@ -337,6 +345,10 @@ func (h *Handler) probeImportedAccountUsage(ctx context.Context, accountID int64 h.mergeRefreshedDuplicateIntoExistingContext(ctx, accountID, source) } +func shouldMergeImportedIdentity(account *auth.Account) bool { + return account != nil && !account.IsCodexAgentIdentity() && !account.IsClaudeOAuth() +} + func (h *Handler) startDBBackgroundTask(task func(context.Context)) bool { if h == nil || task == nil { return false @@ -932,6 +944,8 @@ func parseUsageChannel(c *gin.Context) string { return database.UpstreamChannelGrok case database.UpstreamChannelAntigravity: return database.UpstreamChannelAntigravity + case database.UpstreamChannelClaude: + return database.UpstreamChannelClaude } return "" } @@ -1053,6 +1067,12 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { api.POST("/accounts/grok/import", h.BatchImportGrokAccounts) api.POST("/accounts/grok/oauth/auth-url", h.GenerateGrokAuthURL) // 兼容旧客户端 api.POST("/accounts/grok/oauth/exchange-code", h.ExchangeGrokOAuthCode) // 兼容旧客户端 + api.POST("/accounts/claude/oauth/auth-url", h.GenerateClaudeAuthURL) + api.POST("/accounts/claude/oauth/exchange-code", h.ExchangeClaudeOAuthCode) + api.POST("/accounts/claude/import", h.ImportClaudeToken) + api.GET("/accounts/claude/export", h.ExportClaudeAccounts) + api.POST("/accounts/:id/claude/models", h.RefreshClaudeModels) + api.POST("/accounts/claude/models/refresh", h.RefreshAllClaudeModels) api.POST("/accounts/antigravity", h.AddAntigravityAccount) api.POST("/accounts/antigravity/models", h.FetchAntigravityModels) api.POST("/accounts/antigravity/batch-models", h.BatchUpdateAntigravityModels) @@ -1159,6 +1179,8 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { api.GET("/ops/errors/summary", h.GetOpsErrorSummary) api.GET("/settings", h.GetSettings) api.PUT("/settings", h.UpdateSettings) + api.GET("/settings/claude-config", h.GetClaudeConfig) + api.PUT("/settings/claude-config", h.UpdateClaudeConfig) api.GET("/settings/observed-instructions", h.GetObservedInstructions) api.GET("/settings/invite-guide", h.GetInviteGuideSettings) api.PUT("/settings/invite-guide", h.UpdateInviteGuideSettings) @@ -1383,6 +1405,7 @@ func summarizeDashboardAccounts(rows []*database.AccountRow, runtimeAccounts []* database.UpstreamChannelCodex: {}, database.UpstreamChannelGrok: {}, database.UpstreamChannelAntigravity: {}, + database.UpstreamChannelClaude: {}, } counts.total = len(rows) for _, row := range rows { @@ -1397,6 +1420,8 @@ func summarizeDashboardAccounts(rows []*database.AccountRow, runtimeAccounts []* channel = database.UpstreamChannelGrok } else if strings.EqualFold(upstreamType, auth.UpstreamAntigravity) { channel = database.UpstreamChannelAntigravity + } else if strings.EqualFold(upstreamType, auth.UpstreamClaude) { + channel = database.UpstreamChannelClaude } usingCredits := false acc := runtimeByID[row.ID] @@ -1410,6 +1435,8 @@ func summarizeDashboardAccounts(rows []*database.AccountRow, runtimeAccounts []* usingCredits = acc.UsingCredits() if acc.IsGrokAPI() { channel = database.UpstreamChannelGrok + } else if acc.IsClaudeOAuth() { + channel = database.UpstreamChannelClaude } } perChannel := channelCounts[channel] @@ -1451,6 +1478,11 @@ func isDashboardUnsampledAccount(row *database.AccountRow, acc *auth.Account) bo if status == "unauthorized" || status == "error" { return false } + if acc.IsClaudeOAuth() && row != nil && + strings.TrimSpace(row.GetCredential(auth.ClaudeUsageProbeAtCredentialKey)) != "" && + strings.TrimSpace(row.GetCredential(auth.ClaudeUsageProbeErrorCredentialKey)) == "" { + return false + } return !snapshot.UsagePercent5hValid && !snapshot.UsagePercent7dValid } if row == nil { @@ -1466,6 +1498,11 @@ func isDashboardUnsampledAccount(row *database.AccountRow, acc *auth.Account) bo if status == "unauthorized" || status == "error" { return false } + if strings.EqualFold(upstreamType, auth.UpstreamClaude) && + strings.TrimSpace(row.GetCredential(auth.ClaudeUsageProbeAtCredentialKey)) != "" && + strings.TrimSpace(row.GetCredential(auth.ClaudeUsageProbeErrorCredentialKey)) == "" { + return false + } return true } @@ -1509,6 +1546,7 @@ type accountResponse struct { OpenAIResponsesAPI bool `json:"openai_responses_api,omitempty"` GrokAPI bool `json:"grok_api,omitempty"` AntigravityAPI bool `json:"antigravity_api,omitempty"` + ClaudeAPI bool `json:"claude_api,omitempty"` AntigravityAuthKind string `json:"antigravity_auth_kind,omitempty"` AgentIdentity bool `json:"agent_identity,omitempty"` GrokAuthKind string `json:"grok_auth_kind,omitempty"` @@ -1528,6 +1566,9 @@ type accountResponse struct { ModelMapping string `json:"model_mapping,omitempty"` CodexClientMetadataMode string `json:"codex_client_metadata_mode,omitempty"` CodexFingerprintMode string `json:"codex_fingerprint_mode,omitempty"` + ClaudeFingerprintMode string `json:"claude_fingerprint_mode,omitempty"` + ClaudeUserAgent string `json:"claude_user_agent,omitempty"` + Timezone string `json:"timezone,omitempty"` CustomHeaders map[string]string `json:"custom_headers,omitempty"` HealthTier string `json:"health_tier"` SchedulerScore float64 `json:"scheduler_score"` @@ -1542,6 +1583,8 @@ type accountResponse struct { UpdatedAt string `json:"updated_at"` CodexUsageUpdatedAt string `json:"codex_usage_updated_at,omitempty"` Codex5HUsageUpdatedAt string `json:"codex_5h_usage_updated_at,omitempty"` + ClaudeUsageProbeAt string `json:"claude_usage_probe_at,omitempty"` + ClaudeUsageProbeError string `json:"claude_usage_probe_error,omitempty"` ActiveRequests int64 `json:"active_requests"` OccupiedRequests int64 `json:"occupied_requests"` SessionSlotBufferEnabled bool `json:"session_slot_buffer_enabled"` @@ -1892,6 +1935,7 @@ type accountLiteResponse struct { ATOnly bool `json:"at_only"` OpenAIResponsesAPI bool `json:"openai_responses_api"` GrokAPI bool `json:"grok_api"` + ClaudeAPI bool `json:"claude_api"` AgentIdentity bool `json:"agent_identity"` GrokAuthKind string `json:"grok_auth_kind,omitempty"` } @@ -1915,6 +1959,7 @@ func (h *Handler) listAccountsLite(c *gin.Context, ctx context.Context) { upstreamType := strings.TrimSpace(row.GetCredential("upstream_type")) isOpenAIResponsesAccount := strings.EqualFold(upstreamType, auth.UpstreamOpenAIResponses) isGrokAccount := strings.EqualFold(upstreamType, auth.UpstreamGrok) + isClaudeAccount := strings.EqualFold(upstreamType, auth.UpstreamClaude) grokAuthKind := "" if isGrokAccount { if strings.TrimSpace(row.GetCredential("api_key")) != "" { @@ -1943,9 +1988,10 @@ func (h *Handler) listAccountsLite(c *gin.Context, ctx context.Context) { Status: status, Enabled: row.Enabled, ProxyURL: row.ProxyURL, - ATOnly: !isOpenAIResponsesAccount && !isGrokAccount && row.GetCredential("refresh_token") == "" && row.GetCredential("access_token") != "", + ATOnly: !isOpenAIResponsesAccount && !isGrokAccount && !isClaudeAccount && row.GetCredential("refresh_token") == "" && row.GetCredential("access_token") != "", OpenAIResponsesAPI: isOpenAIResponsesAccount, GrokAPI: isGrokAccount, + ClaudeAPI: isClaudeAccount, AgentIdentity: isAgentIdentityCredentialRow(row), GrokAuthKind: grokAuthKind, }) @@ -1970,6 +2016,8 @@ type updateAccountSchedulerReq struct { ProxyURL json.RawMessage `json:"proxy_url"` CustomHeaders json.RawMessage `json:"custom_headers"` CodexFingerprintMode json.RawMessage `json:"codex_fingerprint_mode"` + ClaudeFingerprintMode json.RawMessage `json:"claude_fingerprint_mode"` + Timezone json.RawMessage `json:"timezone"` } type accountSchedulerUpdate struct { @@ -1989,6 +2037,8 @@ type accountSchedulerUpdate struct { ProxyURL database.OptionalString CustomHeaders optionalCustomHeaders CodexFingerprintMode database.OptionalString + ClaudeFingerprintMode database.OptionalString + Timezone database.OptionalString CredentialUpdates map[string]interface{} } @@ -2060,6 +2110,17 @@ func parseAccountSchedulerUpdate(req updateAccountSchedulerReq) (accountSchedule if err != nil { return accountSchedulerUpdate{}, err } + claudeFingerprintMode, err := parseOptionalStringField(req.ClaudeFingerprintMode, "claude_fingerprint_mode", validateClaudeFingerprintMode) + if err != nil { + return accountSchedulerUpdate{}, err + } + if claudeFingerprintMode.Set { + claudeFingerprintMode.Value = auth.NormalizeClaudeFingerprintMode(claudeFingerprintMode.Value) + } + timezoneField, err := parseOptionalStringField(req.Timezone, "timezone", validateAccountTimezone) + if err != nil { + return accountSchedulerUpdate{}, err + } if codexFingerprintMode.Set { codexFingerprintMode.Value = auth.NormalizeCodexFingerprintMode(codexFingerprintMode.Value) } @@ -2070,6 +2131,12 @@ func parseAccountSchedulerUpdate(req updateAccountSchedulerReq) (accountSchedule if codexFingerprintMode.Set { credentialUpdates[auth.CodexFingerprintModeCredentialKey] = codexFingerprintMode.Value } + if claudeFingerprintMode.Set { + credentialUpdates[auth.ClaudeFingerprintModeCredentialKey] = claudeFingerprintMode.Value + } + if timezoneField.Set { + credentialUpdates["timezone"] = strings.TrimSpace(timezoneField.Value) + } if autoPause5hThreshold.Set { credentialUpdates["auto_pause_5h_threshold"] = autoPause5hThreshold.Value } @@ -2124,10 +2191,32 @@ func parseAccountSchedulerUpdate(req updateAccountSchedulerReq) (accountSchedule ProxyURL: proxyURL, CustomHeaders: customHeaders, CodexFingerprintMode: codexFingerprintMode, + ClaudeFingerprintMode: claudeFingerprintMode, + Timezone: timezoneField, CredentialUpdates: credentialUpdates, }, nil } +// validateClaudeFingerprintMode 允许空串(=跟随全局默认),其余必须是 preserve/force。 +func validateClaudeFingerprintMode(value string) error { + if auth.IsValidClaudeFingerprintMode(value) { + return nil + } + return fmt.Errorf("claude_fingerprint_mode must be one of: preserve, force") +} + +// validateAccountTimezone 允许空串(=清除);非空必须是可加载的 IANA 时区。 +func validateAccountTimezone(value string) error { + v := strings.TrimSpace(value) + if v == "" { + return nil + } + if _, err := time.LoadLocation(v); err != nil { + return fmt.Errorf("timezone must be a valid IANA timezone, e.g. Asia/Shanghai") + } + return nil +} + // validateCodexFingerprintMode 允许空串(等价于默认档 off),其余必须是已知档位。 func validateCodexFingerprintMode(value string) error { if value == "" || auth.IsValidCodexFingerprintMode(value) { @@ -2152,7 +2241,9 @@ func (u accountSchedulerUpdate) hasChanges() bool { u.SchedulerPriority.Set || u.ProxyURL.Set || u.CustomHeaders.Set || - u.CodexFingerprintMode.Set + u.CodexFingerprintMode.Set || + u.ClaudeFingerprintMode.Set || + u.Timezone.Set } func optionalBoolFromPtr(value *bool) database.OptionalBool { @@ -2280,6 +2371,38 @@ func (h *Handler) UpdateAccountScheduler(c *gin.Context) { } } } + if update.Timezone.Set { + if update.CredentialUpdates == nil { + update.CredentialUpdates = make(map[string]interface{}) + } + row, err := h.db.GetAccountByID(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(c, http.StatusNotFound, "账号不存在") + return + } + writeError(c, http.StatusInternalServerError, "查询账号失败: "+err.Error()) + return + } + applied, err := prepareClaudeTimezoneCredentialUpdateWithHeaders(row, update.Timezone.Value, update.CredentialUpdates, func() map[string]string { + if update.CustomHeaders.Set { + return update.CustomHeaders.Values + } + return nil + }()) + if err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + if applied { + if headers, ok := update.CredentialUpdates["custom_headers"].(map[string]string); ok { + // The timezone path owns the final safe identity snapshot even + // when the request also supplied custom_headers; use that same + // snapshot for duplicate checks and immediate runtime updates. + update.CustomHeaders = optionalCustomHeaders{Set: true, Values: headers} + } + } + } if update.CustomHeaders.Set { h.mergeDuplicateMu.Lock() @@ -2383,6 +2506,16 @@ func (h *Handler) applyAccountSchedulerRuntimeUpdate(id int64, update accountSch } if update.CustomHeaders.Set { h.store.ApplyAccountCustomHeaders(id, update.CustomHeaders.Values) + } else if update.Timezone.Set { + // A Claude timezone edit rebuilds the restricted identity headers in + // CredentialUpdates; publish the same snapshot immediately instead of + // waiting for the scheduler outbox/restart to refresh runtime state. + if headers, ok := update.CredentialUpdates["custom_headers"].(map[string]string); ok { + h.store.ApplyAccountCustomHeaders(id, headers) + } + } + if update.ClaudeFingerprintMode.Set { + h.store.ApplyAccountClaudeFingerprintMode(id, update.ClaudeFingerprintMode.Value) } if update.CodexFingerprintMode.Set { h.store.ApplyAccountCodexFingerprintMode(id, update.CodexFingerprintMode.Value) @@ -4051,8 +4184,9 @@ type updateAccountModelsRequest struct { Models []string `json:"models"` } -// UpdateAccountModels 设置 Codex OAuth 账号的支持模型白名单。 -// 空数组 = 清空白名单,放行全部模型;非空时调度器只会把白名单内模型的请求派给该账号。 +// UpdateAccountModels 设置 OAuth 账号的支持模型白名单。 +// Claude 账号仅接受 claude-* 原生模型;空数组 = 清空白名单,放行全部模型; +// 非空时调度器只会把白名单内模型的请求派给该账号。 func (h *Handler) UpdateAccountModels(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { @@ -4081,7 +4215,11 @@ func (h *Handler) UpdateAccountModels(c *gin.Context) { writeError(c, http.StatusNotFound, "账号不在运行时池中") return } - if account.IsRelayStyle() { + if err := validateAccountModelsForAccount(account, models); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + if account.IsRelayStyle() && !account.IsClaudeOAuth() { writeError(c, http.StatusBadRequest, "中转/Grok 账号请在账号设置中编辑模型列表") return } @@ -4097,6 +4235,24 @@ func (h *Handler) UpdateAccountModels(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"models": models}) } +// validateAccountModelsForAccount keeps provider-specific model namespaces +// out of the shared account-model endpoint. An empty list intentionally clears +// the override; a non-empty Claude allowlist must contain only native +// claude-* IDs so a stale Codex/Grok entry can never make a Claude account +// appear routable for an incompatible protocol. +func validateAccountModelsForAccount(account *auth.Account, models []string) error { + if account == nil || !account.IsClaudeOAuth() { + return nil + } + for _, model := range models { + model = strings.TrimSpace(model) + if !strings.HasPrefix(strings.ToLower(model), "claude-") { + return fmt.Errorf("Claude 账号模型必须使用 claude-* 原生模型: %s", model) + } + } + return nil +} + // SyncAccountUpstreamModels 用账号自身凭据实时拉取上游模型清单, // 返回该账号真实可用的模型 slug 列表。只读不落库,由管理端确认后再保存为白名单。 func (h *Handler) SyncAccountUpstreamModels(c *gin.Context) { @@ -4129,6 +4285,18 @@ func (h *Handler) SyncAccountUpstreamModels(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"models": result.Models, "state": result.State, "errors": result.Errors}) return } + if account.IsClaudeOAuth() { + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + models, fetchErr := auth.NewClaudeAuth(h.store.ResolveProxyForAccount(account)).FetchModels(ctx, account.GetAccessToken()) + if fetchErr != nil { + writeError(c, http.StatusBadGateway, fmt.Sprintf("拉取 Claude 上游模型清单失败: %s", fetchErr.Error())) + return + } + models = auth.NormalizeAccountModels(models) + c.JSON(http.StatusOK, gin.H{"models": models}) + return + } if account.IsOpenAIResponsesAPI() { writeError(c, http.StatusBadRequest, "OpenAI Responses API 账号请使用账号设置中的模型同步") return @@ -5552,6 +5720,19 @@ func (h *Handler) RefreshAccountUsage(c *gin.Context) { if t := account.GetResetSparkAt(); !t.IsZero() { resp["reset_spark_at"] = t.Format(time.RFC3339) } + if account.IsClaudeOAuth() && h.db != nil { + // The Claude probe records its attempt metadata in credentials. Read the + // merged row back so the caller gets the durable timestamp/error even + // when the response carried no quota headers. + if row, readErr := h.db.GetAccountByID(ctx, id); readErr == nil { + if value := row.GetCredential(auth.ClaudeUsageProbeAtCredentialKey); value != "" { + resp["claude_usage_probe_at"] = value + } + if value := row.GetCredential(auth.ClaudeUsageProbeErrorCredentialKey); value != "" { + resp["claude_usage_probe_error"] = value + } + } + } c.JSON(http.StatusOK, resp) } @@ -5570,7 +5751,7 @@ type batchUpdateAccountsReq struct { func (h *Handler) accountOperationIdentity(id int64) (string, string) { h.accountListCacheMu.RLock() - for _, channel := range []string{database.UpstreamChannelCodex, database.UpstreamChannelGrok} { + for _, channel := range []string{database.UpstreamChannelCodex, database.UpstreamChannelGrok, database.UpstreamChannelAntigravity, database.UpstreamChannelClaude} { snapshot := h.accountListCache[channel] if snapshot == nil { continue @@ -5636,6 +5817,7 @@ type recycleBinAccountResponse struct { ATOnly bool `json:"at_only"` AccessTokenType string `json:"access_token_type,omitempty"` OpenAIResponsesAPI bool `json:"openai_responses_api"` + ClaudeAPI bool `json:"claude_api"` BaseURL string `json:"base_url,omitempty"` Models []string `json:"models,omitempty"` CreatedAt string `json:"created_at"` @@ -5658,7 +5840,9 @@ func (h *Handler) ListRecycleBinAccounts(c *gin.Context) { accounts := make([]recycleBinAccountResponse, 0, len(rows)) for _, row := range rows { - isOpenAIResponsesAccount := strings.EqualFold(strings.TrimSpace(row.GetCredential("upstream_type")), auth.UpstreamOpenAIResponses) + upstreamType := strings.TrimSpace(row.GetCredential("upstream_type")) + isOpenAIResponsesAccount := strings.EqualFold(upstreamType, auth.UpstreamOpenAIResponses) + isClaudeAccount := strings.EqualFold(upstreamType, auth.UpstreamClaude) email := row.GetCredential("email") baseURL := row.GetCredential("base_url") if isOpenAIResponsesAccount && email == "" { @@ -5673,9 +5857,10 @@ func (h *Handler) ListRecycleBinAccounts(c *gin.Context) { Name: row.Name, Email: email, PlanType: planType, - ATOnly: !isOpenAIResponsesAccount && row.GetCredential("refresh_token") == "" && row.GetCredential("access_token") != "", + ATOnly: !isOpenAIResponsesAccount && !isClaudeAccount && row.GetCredential("refresh_token") == "" && row.GetCredential("access_token") != "", AccessTokenType: accountAccessTokenType(row), OpenAIResponsesAPI: isOpenAIResponsesAccount, + ClaudeAPI: isClaudeAccount, BaseURL: baseURL, Models: row.GetCredentialStringSlice("models"), CreatedAt: row.CreatedAt.Format(time.RFC3339), @@ -8201,6 +8386,10 @@ var knownAPIKeyPlanFilters = map[string]struct{}{ "api": {}, "supergrok": {}, "x_basic": {}, "x_premium": {}, "x_premium_plus": {}, "supergrok_heavy": {}, "supergrok_lite": {}, "supergrok_plus": {}, + // Claude OAuth profile tiers. Keep these independent from Codex/Grok + // labels so a Claude-bound key's plan gate survives normalization. + "claude": {}, "max": {}, "max-5x": {}, "max-20x": {}, + "enterprise": {}, "business": {}, } // cleanPlanAllow 归一账号套餐白名单:小写去空白、丢弃未知值并去重。 @@ -11702,6 +11891,11 @@ func (h *Handler) ListModels(c *gin.Context) { catalog, _ := proxy.ListModelCatalog(c.Request.Context(), h.db) catalog.GrokModels = h.grokChannelModels() catalog.AntigravityModels = h.antigravityChannelModels() + // The request-facing catalog must not advertise models contributed only by + // disabled/banned accounts or models currently marked credits_required. + // Keep claudeChannelModels for pricing/history, where those entries remain + // useful to operators. + catalog.ClaudeModels = h.claudeAvailableChannelModels() c.JSON(http.StatusOK, catalog) } diff --git a/admin/handler_test.go b/admin/handler_test.go index 41c0f8a08..7abe758f4 100644 --- a/admin/handler_test.go +++ b/admin/handler_test.go @@ -12,6 +12,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "slices" "strings" "sync/atomic" "testing" @@ -129,6 +130,71 @@ func TestSummarizeDashboardAccountsMatchesAccountPageBuckets(t *testing.T) { } } +func TestSummarizeDashboardAccountsIncludesClaudeChannel(t *testing.T) { + row := &database.AccountRow{ID: 99, Status: "active", Enabled: true, Credentials: map[string]interface{}{"upstream_type": auth.UpstreamClaude}} + acc := &auth.Account{DBID: 99, UpstreamType: auth.UpstreamClaude, AccessToken: "claude", Status: auth.StatusReady, UsagePercent7dValid: true} + _, channels := summarizeDashboardAccounts([]*database.AccountRow{row}, []*auth.Account{acc}) + got, ok := channels[database.UpstreamChannelClaude] + if !ok { + t.Fatalf("dashboard channels missing Claude: %#v", channels) + } + if got.total != 1 || got.normal != 1 { + t.Fatalf("Claude dashboard counts = %+v, want total=1 normal=1", got) + } +} + +func TestSummarizeDashboardAccountsTreatsSuccessfulClaudeProbeWithoutQuotaHeadersAsSampled(t *testing.T) { + row := &database.AccountRow{ID: 100, Status: "active", Enabled: true, Credentials: map[string]interface{}{ + "upstream_type": auth.UpstreamClaude, + auth.ClaudeUsageProbeAtCredentialKey: "2026-08-29T05:00:00Z", + }} + acc := &auth.Account{DBID: 100, UpstreamType: auth.UpstreamClaude, AccessToken: "claude", Status: auth.StatusReady} + got, channels := summarizeDashboardAccounts([]*database.AccountRow{row}, []*auth.Account{acc}) + if got.normal != 1 || got.rateLimited != 0 || got.abnormal != 0 { + t.Fatalf("dashboard counts = %+v, want successful Claude probe counted as normal", got) + } + if channels[database.UpstreamChannelClaude].normal != 1 { + t.Fatalf("Claude channel counts = %+v", channels[database.UpstreamChannelClaude]) + } +} + +func TestClaudeChannelModelsReturnsAccountCatalog(t *testing.T) { + store := auth.NewStore(nil, nil, nil) + defer store.Stop() + store.AddAccount(&auth.Account{DBID: 100, UpstreamType: auth.UpstreamClaude, AccessToken: "claude", Models: []string{"claude-sonnet-4-5", "claude-opus-4-5"}}) + h := &Handler{store: store} + models := h.claudeChannelModels() + if len(models) != 2 || !slices.Contains(models, "claude-sonnet-4-5") || !slices.Contains(models, "claude-opus-4-5") { + t.Fatalf("Claude model catalog = %v, want account models", models) + } +} + +func TestClaudeAvailableChannelModelsFiltersDisabledAndModelCooldown(t *testing.T) { + store := auth.NewStore(nil, nil, nil) + defer store.Stop() + enabled := &auth.Account{ + DBID: 101, + UpstreamType: auth.UpstreamClaude, + AccessToken: "claude-enabled", + Models: []string{"claude-fable-5", "claude-sonnet-5"}, + } + enabled.SetModelCooldownUntil("claude-fable-5", "credits_required", time.Now().Add(time.Hour)) + disabled := &auth.Account{ + DBID: 102, + UpstreamType: auth.UpstreamClaude, + AccessToken: "claude-disabled", + Models: []string{"claude-fable-5"}, + } + atomic.StoreInt32(&disabled.DispatchPaused, 1) + store.AddAccount(enabled) + store.AddAccount(disabled) + h := &Handler{store: store} + models := h.claudeAvailableChannelModels() + if len(models) != 1 || models[0] != "claude-sonnet-5" { + t.Fatalf("request-facing Claude models = %v, want only enabled cooldown-free model", models) + } +} + // 积分顶着限流的账号 RuntimeStatus 仍是 rate_limited(用量窗口客观上打满了), // 但它照常参与调度,仪表盘该把它算进「可用」而不是「限流」。 func TestSummarizeDashboardAccountsCountsCreditBackedAsNormal(t *testing.T) { diff --git a/admin/model_pricing.go b/admin/model_pricing.go index bf56099e5..1614b7aff 100644 --- a/admin/model_pricing.go +++ b/admin/model_pricing.go @@ -7,6 +7,7 @@ import ( "sort" "strconv" "strings" + "sync/atomic" "time" "github.com/codex2api/auth" @@ -155,15 +156,94 @@ func (h *Handler) grokBillingModelIDs() []string { return ids } +// grokDefaultDisplayModelIDs 是定价页始终展示的 Grok 内置文本模型集(即使没有 Grok 账号), +// 与 Codex 内置模型的常显行为对齐。取 OAuth 与 API Key 两套默认集的并集(后者为超集)。 +// 仅文本模型:定价页按 token 计费,媒体(生图/生视频)定价模型另计,不在此列。 +func grokDefaultDisplayModelIDs() []string { + ids := make([]string, 0, 8) + ids = append(ids, auth.GrokOAuthDefaultModelIDs()...) + ids = append(ids, auth.GrokAPIKeyDefaultModelIDs()...) + return ids +} + // modelPricingRow 是定价管理页每个规范模型的一行:当前生效价 + 来源。 type modelPricingRow struct { Model string `json:"model"` - Source string `json:"source"` // custom / synced / default + Channel string `json:"channel"` // codex / grok / antigravity / claude —— 供前端按 provider 分组 + Source string `json:"source"` // custom / synced / default Pricing database.ModelPricingOverride `json:"pricing"` CanonicalModel string `json:"canonical_model,omitempty"` IsAlias bool `json:"is_alias,omitempty"` } +// claudeChannelModels 返回定价页要展示的 Claude 模型:各 Claude 账号可见模型的并集。 +// 没有 Claude 账号时返回空,纯 Codex/其它部署的定价页不受影响。 +func (h *Handler) claudeChannelModels() []string { + if h == nil || h.store == nil { + return nil + } + seen := make(map[string]struct{}) + models := make([]string, 0) + for _, account := range h.store.Accounts() { + if account == nil || !account.IsClaudeOAuth() { + continue + } + for _, model := range proxy.DefaultClaudeModelIDsForAccount(account) { + key := strings.ToLower(strings.TrimSpace(model)) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + models = append(models, model) + } + } + sort.Strings(models) + return models +} + +// claudeAvailableChannelModels returns models from enabled, non-banned Claude +// accounts for request-facing catalogs. Pricing/history still use +// claudeChannelModels so a disabled account cannot make an unusable model +// selectable while its historical cost data remains visible to administrators. +func (h *Handler) claudeAvailableChannelModels() []string { + if h == nil || h.store == nil { + return nil + } + seen := make(map[string]struct{}) + models := make([]string, 0) + for _, account := range h.store.Accounts() { + if account == nil || !account.IsClaudeOAuth() || + atomic.LoadInt32(&account.Disabled) != 0 || + atomic.LoadInt32(&account.DispatchPaused) != 0 { + continue + } + account.Mu().RLock() + status := account.Status + tier := account.HealthTier + account.Mu().RUnlock() + if status == auth.StatusError || tier == auth.HealthTierBanned { + continue + } + for _, model := range proxy.DefaultClaudeModelIDsForAccount(account) { + model = strings.TrimSpace(model) + key := strings.ToLower(model) + if key == "" || account.IsModelRateLimited(model) { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + models = append(models, model) + } + } + sort.Strings(models) + return models +} + func modelPricingManagementKeys(ids []string) []string { seen := make(map[string]struct{}, len(ids)) out := make([]string, 0, len(ids)) @@ -208,28 +288,36 @@ func (h *Handler) ListModelPricing(c *gin.Context) { } return out } - grokKeys := dedup(h.grokBillingModelIDs()) + // Grok 内置默认模型始终并入,使定价页像 Codex 内置模型一样常显 grok 家族, + // 即使当前没有任何 Grok 账号(官方同步的 grok 采集逻辑不受影响)。 + grokKeys := dedup(append(h.grokBillingModelIDs(), grokDefaultDisplayModelIDs()...)) antigravityKeys := dedup(h.antigravityChannelModels()) + claudeKeys := dedup(h.claudeChannelModels()) - // 新版本在前(gpt-5.6 > gpt-5.5 > gpt-5.4 …),避免字典序把旧模型顶到列表顶部。 - // Grok 单独排序并整体排在 Codex 之后,避免两家版本号交叉穿插。 + // 每个渠道内按新版本在前排序;渠道之间整体拼接,避免版本号交叉穿插。 sortModelKeysNewestFirst(keys) sortModelKeysNewestFirst(grokKeys) sortModelKeysNewestFirst(antigravityKeys) - keys = append(keys, grokKeys...) - keys = append(keys, antigravityKeys...) + sortModelKeysNewestFirst(claudeKeys) - rows := make([]modelPricingRow, 0, len(keys)) - for _, key := range keys { - canonicalModel := database.PricingAliasTarget(key) - rows = append(rows, modelPricingRow{ - Model: key, - Source: database.ModelPricingSourceFor(key), - Pricing: database.ModelPricingOverrideFromPricing(database.GetModelPricing(key), database.ModelPricingSourceFor(key)), - CanonicalModel: canonicalModel, - IsAlias: canonicalModel != "", - }) + rows := make([]modelPricingRow, 0, len(keys)+len(grokKeys)+len(antigravityKeys)+len(claudeKeys)) + appendRows := func(modelKeys []string, channel string) { + for _, key := range modelKeys { + canonicalModel := database.PricingAliasTarget(key) + rows = append(rows, modelPricingRow{ + Model: key, + Channel: channel, + Source: database.ModelPricingSourceFor(key), + Pricing: database.ModelPricingOverrideFromPricing(database.GetModelPricing(key), database.ModelPricingSourceFor(key)), + CanonicalModel: canonicalModel, + IsAlias: canonicalModel != "", + }) + } } + appendRows(keys, database.UpstreamChannelCodex) + appendRows(grokKeys, database.UpstreamChannelGrok) + appendRows(antigravityKeys, database.UpstreamChannelAntigravity) + appendRows(claudeKeys, database.UpstreamChannelClaude) syncURL := "" if s, err := h.db.GetSystemSettings(ctx); err == nil && s != nil { diff --git a/admin/model_probe.go b/admin/model_probe.go index a13190b7d..7b7521995 100644 --- a/admin/model_probe.go +++ b/admin/model_probe.go @@ -3,6 +3,7 @@ package admin import ( "context" "fmt" + "io" "net/http" "sort" "strconv" @@ -60,7 +61,7 @@ func (h *Handler) ProbeAccountModels(c *gin.Context) { writeError(c, http.StatusNotFound, "账号不在运行时池中") return } - if account.IsRelayStyle() { + if account.IsRelayStyle() && !account.IsClaudeOAuth() { writeError(c, http.StatusBadRequest, "中转/Grok 账号不支持模型探测") return } @@ -70,6 +71,9 @@ func (h *Handler) ProbeAccountModels(c *gin.Context) { } models := proxy.TextTestModelIDs(c.Request.Context(), h.db) + if account.IsClaudeOAuth() { + models = claudeProbeModelIDs(account) + } streaming := strings.EqualFold(c.Query("stream"), "true") if len(models) == 0 { @@ -191,6 +195,9 @@ func collectAvailableModels(results []modelProbeResult) []string { // probeAccountModel 对单个模型发起最小探测请求并分类结果。不回写任何账号状态。 func (h *Handler) probeAccountModel(ctx context.Context, account *auth.Account, model string) (string, string) { + if account != nil && account.IsClaudeOAuth() { + return h.probeClaudeAccountModel(ctx, account, model) + } probeCtx, cancel := context.WithTimeout(ctx, batchTestAccountTimeout) defer cancel() @@ -223,6 +230,218 @@ func (h *Handler) probeAccountModel(ctx context.Context, account *auth.Account, } } +func claudeProbeModelIDs(account *auth.Account) []string { + models := proxy.DefaultClaudeModelIDsForAccount(account) + filtered := make([]string, 0, len(models)) + seen := make(map[string]struct{}, len(models)) + for _, model := range models { + model = strings.TrimSpace(model) + if !strings.HasPrefix(strings.ToLower(model), "claude-") { + continue + } + key := strings.ToLower(model) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + filtered = append(filtered, model) + } + if len(filtered) > 0 { + return filtered + } + if account != nil { + account.Mu().RLock() + explicit := len(account.Models) > 0 + account.Mu().RUnlock() + if explicit { + // An explicit but invalid whitelist is a configuration error, not a + // reason to probe an unrelated fallback model. + return nil + } + } + return []string{"claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"} +} + +func buildClaudeModelProbePayload(model string) []byte { + model = strings.TrimSpace(model) + return []byte(fmt.Sprintf(`{"model":%q,"max_tokens":8,"stream":true,"messages":[{"role":"user","content":"Reply with OK."}]}`, model)) +} + +func (h *Handler) probeClaudeAccountModel(ctx context.Context, account *auth.Account, model string) (string, string) { + probeCtx, cancel := context.WithTimeout(ctx, batchTestAccountTimeout) + defer cancel() + if h == nil || h.store == nil { + return modelProbeError, "Claude 探测缺少运行时账号池" + } + resp, err := proxy.ExecuteClaudeMessagesRequest( + probeCtx, + account, + buildClaudeModelProbePayload(model), + h.store.ResolveProxyForAccount(account), + nil, + account.EffectiveClaudeFingerprintMode(h.store.ClaudeFingerprintModeDefault()), + h.store.ClaudeSecurityConfig(), + ) + if err != nil { + if msg, ok := batchTestContextFailure(probeCtx, err); ok { + return modelProbeError, msg + } + return modelProbeError, err.Error() + } + if resp == nil { + return modelProbeError, "Claude 探测未返回响应" + } + defer resp.Body.Close() + // Model probing is an administrative read-only check. Do not feed the + // response into the live usage/cooldown synchronizer: a model-specific 429 + // with a 100% window header must not quarantine the account (or affect a + // different model) merely because an operator inspected availability. + switch resp.StatusCode { + case http.StatusOK: + return readClaudeProbeStream(probeCtx, resp) + case http.StatusTooManyRequests: + body, _ := readBatchTestErrorBody(probeCtx, resp.Body) + lowerBody := strings.ToLower(string(body)) + if strings.EqualFold(strings.TrimSpace(gjson.GetBytes(body, "error.details.error_code").String()), "credits_required") || + strings.EqualFold(strings.TrimSpace(gjson.GetBytes(body, "error.code").String()), "credits_required") || + (strings.Contains(lowerBody, "usage credits") && strings.Contains(lowerBody, "required")) { + return modelProbeUnsupported, "上游模型需要 usage credits,当前账号套餐不可用" + } + return modelProbeThrottled, "上游返回 429 限流" + case http.StatusBadRequest, http.StatusForbidden: + body, _ := readBatchTestErrorBody(probeCtx, resp.Body) + if strings.Contains(strings.ToLower(string(body)), "model") && strings.Contains(strings.ToLower(string(body)), "not") { + return modelProbeUnsupported, "账号套餐不支持该模型" + } + return modelProbeError, fmt.Sprintf("上游返回 %d: %s", resp.StatusCode, truncate(string(body), 200)) + default: + body, _ := readBatchTestErrorBody(probeCtx, resp.Body) + return modelProbeError, fmt.Sprintf("上游返回 %d: %s", resp.StatusCode, truncate(string(body), 200)) + } +} + +// readClaudeProbeStream classifies native Anthropic Messages SSE without +// pretending message_start/message_stop are OpenAI response events. +func readClaudeProbeStream(ctx context.Context, resp *http.Response) (string, string) { + status, detail := readClaudeMessagesStream(ctx, resp, nil) + switch status { + case "success": + return modelProbeAvailable, "模型响应正常" + case "rate_limited": + return modelProbeThrottled, detail + default: + return modelProbeError, detail + } +} + +// readClaudeMessagesStream consumes native Anthropic Messages SSE. The +// callback receives only visible text deltas; it is optional for model probes +// and used by the account connection-test UI. +func readClaudeMessagesStream(ctx context.Context, resp *http.Response, onText func(string)) (string, string) { + if resp == nil || resp.Body == nil { + return "failed", "Claude 探测响应为空" + } + contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type"))) + if !strings.Contains(contentType, "text/event-stream") { + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "failed", err.Error() + } + typ := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "type").String())) + if typ == "message" { + text := claudeMessageContentText(body) + if text != "" && onText != nil { + onText(text) + } + if text == "" { + return "failed", "Claude 探测未返回文本内容" + } + return "success", "测试通过" + } + if typ == "error" { + if isClaudeProbeRateLimited(body) { + return "rate_limited", formatClaudeProbeError(body, "上游返回限流错误") + } + return "failed", formatClaudeProbeError(body, "上游返回 Claude 错误") + } + return "failed", "Claude 探测响应格式未知" + } + hasContent := false + gotTerminal := false + lastEvent := []byte(nil) + readErr := proxy.ReadSSEStream(resp.Body, func(data []byte) bool { + lastEvent = append(lastEvent[:0], data...) + typ := strings.ToLower(strings.TrimSpace(gjson.GetBytes(data, "type").String())) + switch typ { + case "message": + if text := claudeMessageContentText(data); text != "" { + hasContent = true + if onText != nil { + onText(text) + } + } + gotTerminal = true + return false + case "content_block_delta": + if text := gjson.GetBytes(data, "delta.text").String(); strings.TrimSpace(text) != "" { + hasContent = true + if onText != nil { + onText(text) + } + } + case "message_stop": + gotTerminal = true + return false + case "error": + gotTerminal = true + return false + } + return true + }) + if readErr != nil { + if msg, ok := batchTestContextFailure(ctx, readErr); ok { + return "failed", msg + } + return "failed", readErr.Error() + } + if typ := strings.ToLower(strings.TrimSpace(gjson.GetBytes(lastEvent, "type").String())); typ == "error" { + if isClaudeProbeRateLimited(lastEvent) { + return "rate_limited", formatClaudeProbeError(lastEvent, "上游返回限流错误") + } + return "failed", formatClaudeProbeError(lastEvent, "上游返回 Claude 错误") + } + if !gotTerminal { + return "failed", "Claude 探测未返回 message_stop" + } + if !hasContent { + return "failed", "Claude 探测未返回文本内容" + } + return "success", "测试通过" +} + +func claudeMessageContentText(data []byte) string { + var text strings.Builder + for _, item := range gjson.GetBytes(data, "content").Array() { + if item.Get("type").String() == "text" { + text.WriteString(item.Get("text").String()) + } + } + return text.String() +} + +func isClaudeProbeRateLimited(data []byte) bool { + raw := strings.ToLower(string(data)) + return strings.Contains(raw, "rate_limit") || strings.Contains(raw, "rate limit") || strings.Contains(raw, "overloaded") +} + +func formatClaudeProbeError(data []byte, fallback string) string { + message := strings.TrimSpace(gjson.GetBytes(data, "error.message").String()) + if message == "" { + message = fallback + } + return truncate(message, 200) +} + // readProbeStream 读取探测 SSE 流并分类,能从终止事件里识别出"账号不支持该模型"。 // 不回写任何账号状态。 func readProbeStream(ctx context.Context, resp *http.Response) (string, string) { diff --git a/admin/model_probe_claude_test.go b/admin/model_probe_claude_test.go new file mode 100644 index 000000000..7c4551b0a --- /dev/null +++ b/admin/model_probe_claude_test.go @@ -0,0 +1,274 @@ +package admin + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "testing" + "time" + + "github.com/codex2api/auth" + "github.com/codex2api/proxy" +) + +func TestBuildClaudeConnectionTestPayloadUsesMessagesShape(t *testing.T) { + payload := buildClaudeConnectionTestPayload(nil, "claude-sonnet-4-5") + var body map[string]interface{} + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatalf("Claude connection payload is invalid JSON: %v", err) + } + if body["model"] != "claude-sonnet-4-5" || body["stream"] != true { + t.Fatalf("Claude connection payload = %#v", body) + } + if _, ok := body["messages"]; !ok { + t.Fatalf("Claude connection payload missing messages: %#v", body) + } + if _, ok := body["input"]; ok { + t.Fatalf("Claude connection payload must not use Responses input: %#v", body) + } +} + +func TestReadClaudeProbeStreamClassifiesNativeMessagesSuccess(t *testing.T) { + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader( + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"m1\"}}\n\n" + + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\n" + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n" + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + ))} + status, detail := readClaudeProbeStream(context.Background(), resp) + if status != modelProbeAvailable || detail != "模型响应正常" { + t.Fatalf("Claude probe result = (%q, %q), want available/model response normal", status, detail) + } +} + +func TestReadClaudeMessagesStreamEmitsTextDeltas(t *testing.T) { + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader( + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n" + + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + ))} + var got strings.Builder + status, detail := readClaudeMessagesStream(context.Background(), resp, func(text string) { _, _ = got.WriteString(text) }) + if status != "success" || detail != "测试通过" || got.String() != "hello" { + t.Fatalf("Claude stream result = (%q, %q, %q)", status, detail, got.String()) + } +} + +func TestReadClaudeMessagesStreamClassifiesRateLimitError(t *testing.T) { + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader( + "event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\",\"message\":\"slow down\"}}\n\n", + ))} + status, detail := readClaudeMessagesStream(context.Background(), resp, nil) + if status != "rate_limited" || detail != "slow down" { + t.Fatalf("Claude rate-limit result = (%q, %q)", status, detail) + } +} + +func TestReadClaudeMessagesStreamAcceptsNonStreamingMessageJSON(t *testing.T) { + resp := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader( + `{"type":"message","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn"}`, + ))} + status, detail := readClaudeMessagesStream(context.Background(), resp, nil) + if status != "success" || detail != "测试通过" { + t.Fatalf("Claude non-stream result = (%q, %q)", status, detail) + } +} + +func TestClaudeConnectionTestPreservesAuthoritativeRejectedCooldown(t *testing.T) { + account := &auth.Account{UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token"} + account.SetCooldownWithReason(time.Hour, auth.ResponsesRateLimitedCooldownReason) + headers := make(http.Header) + headers.Set("anthropic-ratelimit-unified-status", "rejected") + headers.Set("anthropic-ratelimit-unified-5h-utilization", "1") + headers.Set("anthropic-ratelimit-unified-representative-claim", "five_hour") + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: headers, + } + if !claudeResponseHasUsageLimitSignal(resp) { + t.Fatal("rejected Claude response should carry a usage-limit signal") + } + if !claudeConnectionTestShouldPreserveUsageCooldown(account, resp) { + t.Fatal("manual Claude test must preserve an authoritative cooldown") + } +} + +func TestClaudeConnectionTestAllowsNormalResponseToClearOldCooldown(t *testing.T) { + account := &auth.Account{UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token"} + account.SetCooldownWithReason(time.Hour, auth.ResponsesRateLimitedCooldownReason) + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{"anthropic-ratelimit-unified-status": []string{"allowed"}}} + if claudeConnectionTestShouldPreserveUsageCooldown(account, resp) { + t.Fatal("normal Claude response should not preserve an old cooldown") + } +} + +func TestClaudeConnectionTestPreservesUsageSignalWithoutExistingCooldown(t *testing.T) { + account := &auth.Account{UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token"} + headers := make(http.Header) + headers.Set("anthropic-ratelimit-unified-status", "rejected") + headers.Set("anthropic-ratelimit-unified-representative-claim", "five_hour") + headers.Set("anthropic-ratelimit-unified-5h-utilization", "1") + resp := &http.Response{StatusCode: http.StatusOK, Header: headers} + if !claudeConnectionTestShouldPreserveUsageCooldown(account, resp) { + t.Fatal("an authoritative rejected usage signal must prevent transient restore even before local cooldown exists") + } +} + +func TestClaudeConnectionStreamFailureAppliesShortCooldown(t *testing.T) { + store := auth.NewStore(nil, nil, nil) + defer store.Stop() + account := &auth.Account{UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token", Status: auth.StatusReady} + h := &Handler{store: store} + applyClaudeConnectionStreamFailure(h, account, "claude-haiku-4-5", "rate_limited", "slow down", &http.Response{Header: make(http.Header)}) + if !account.HasActiveCooldown() { + t.Fatal("body-only Claude rate limit from a connection test must apply a cooldown") + } +} + +func TestClaudeConnectionStreamAuthFailureAppliesUnauthorizedCooldown(t *testing.T) { + store := auth.NewStore(nil, nil, nil) + defer store.Stop() + account := &auth.Account{UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token", Status: auth.StatusReady} + h := &Handler{store: store} + applyClaudeConnectionStreamFailure(h, account, "claude-haiku-4-5", "failed", "invalid token", nil) + reason, _ := account.GetCooldownSnapshot() + if reason != "unauthorized" { + t.Fatalf("Claude auth failure cooldown reason = %q, want unauthorized", reason) + } +} + +func TestClaudeConnectionStreamRateLimitDoesNotReplacePreciseWindow(t *testing.T) { + store := auth.NewStore(nil, nil, nil) + defer store.Stop() + account := &auth.Account{UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token", Status: auth.StatusReady} + reset := time.Now().Add(3 * time.Hour) + headers := make(http.Header) + headers.Set("anthropic-ratelimit-unified-status", "rejected") + headers.Set("anthropic-ratelimit-unified-representative-claim", "five_hour") + headers.Set("anthropic-ratelimit-unified-5h-utilization", "1") + headers.Set("anthropic-ratelimit-unified-5h-reset", strconv.FormatInt(reset.Unix(), 10)) + resp := &http.Response{StatusCode: http.StatusOK, Header: headers} + proxy.SyncClaudeUsageState(store, account, resp) + _, before := account.GetCooldownSnapshot() + applyClaudeConnectionStreamFailure(&Handler{store: store}, account, "claude-haiku-4-5", "rate_limited", "slow down", resp) + reason, after := account.GetCooldownSnapshot() + if reason != auth.ResponsesRateLimitedCooldownReason || after.Before(before.Add(-time.Second)) || after.After(before.Add(time.Second)) { + t.Fatalf("connection test replaced precise Claude cooldown: reason=%q before=%v after=%v", reason, before, after) + } +} + +func TestClaudeConnectionCreditsRequiredIsModelScoped(t *testing.T) { + store := auth.NewStore(nil, nil, nil) + defer store.Stop() + account := &auth.Account{UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token", Status: auth.StatusReady} + if handled := syncClaudeTestUsageState(store, account, "claude-fable-5", &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: make(http.Header), + }, []byte(`{"error":{"type":"rate_limit_error","message":"Usage credits are required for this model.","details":{"error_code":"credits_required","model":"claude-fable-5"}}}`)); !handled { + t.Fatal("credits_required HTTP failure should be handled as a model-level result") + } + if account.HasActiveCooldown() || account.RuntimeStatus() == "rate_limited" { + t.Fatalf("credits_required must not cool down the account: status=%q", account.RuntimeStatus()) + } + if !account.IsModelRateLimited("claude-fable-5") { + t.Fatal("credits_required should cool down only Fable 5") + } +} + +func TestClaudeConnectionStreamCreditsRequiredIsModelScoped(t *testing.T) { + store := auth.NewStore(nil, nil, nil) + defer store.Stop() + account := &auth.Account{UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token", Status: auth.StatusReady} + applyClaudeConnectionStreamFailure(&Handler{store: store}, account, "claude-fable-5", "rate_limited", "Usage credits are required for this model.", &http.Response{Header: make(http.Header)}) + if account.HasActiveCooldown() || account.RuntimeStatus() == "rate_limited" { + t.Fatalf("stream credits_required must not cool down the account: status=%q", account.RuntimeStatus()) + } + if !account.IsModelRateLimited("claude-fable-5") { + t.Fatal("stream credits_required should cool down only Fable 5") + } +} + +func TestClaudeProbeModelIDsPreferAccountModels(t *testing.T) { + account := &auth.Account{UpstreamType: auth.UpstreamClaude, Models: []string{"claude-sonnet-4-5", "claude-haiku-4-5"}} + got := claudeProbeModelIDs(account) + if len(got) != 2 || got[0] != "claude-sonnet-4-5" || got[1] != "claude-haiku-4-5" { + t.Fatalf("Claude probe models = %v", got) + } +} + +func TestClaudeProbeModelIDsRejectNonClaudeCatalogEntries(t *testing.T) { + account := &auth.Account{UpstreamType: auth.UpstreamClaude, Models: []string{"gpt-5.4", "gemini-2.5-pro"}} + got := claudeProbeModelIDs(account) + if len(got) != 0 { + t.Fatalf("explicit non-Claude catalog should fail closed, got %v", got) + } +} + +func TestClaudeProbeModelIDsUsesFallbackOnlyWithoutExplicitCatalog(t *testing.T) { + got := claudeProbeModelIDs(&auth.Account{UpstreamType: auth.UpstreamClaude}) + if len(got) == 0 { + t.Fatal("Claude probe should expose the safe native fallback when no catalog is configured") + } + for _, model := range got { + if !strings.HasPrefix(strings.ToLower(model), "claude-") { + t.Fatalf("probe model %q crossed the Claude provider boundary", model) + } + } +} + +func TestClaudeProbeModelIDsRejectsExplicitInvalidCatalog(t *testing.T) { + account := &auth.Account{UpstreamType: auth.UpstreamClaude, Models: []string{"gpt-5.4"}} + if got := claudeProbeModelIDs(account); len(got) != 0 { + t.Fatalf("explicit invalid Claude catalog fell back to models: %v", got) + } +} + +func TestConnectionTestModelForClaudeUsesNativeCatalog(t *testing.T) { + store := auth.NewStore(nil, nil, nil) + defer store.Stop() + h := &Handler{store: store} + account := &auth.Account{UpstreamType: auth.UpstreamClaude, Models: []string{"claude-opus-4-5", "claude-haiku-4-5"}} + model, err := h.connectionTestModelForAccount(context.Background(), account, "") + if err != nil || model != "claude-haiku-4-5" { + t.Fatalf("Claude connection test model = (%q, %v), want cheapest Haiku model", model, err) + } +} + +func TestConnectionTestModelForClaudeRejectsStaleRuntimeModel(t *testing.T) { + db := newTestAdminDB(t) + ctx := context.Background() + id, err := db.InsertAccountWithUpstream(ctx, "claude-stale", "anthropic", "oauth", map[string]interface{}{ + "upstream_type": "claude", + "access_token": "claude-token", + "refresh_token": "claude-refresh", + "models": []string{"claude-sonnet-5"}, + }, "") + if err != nil { + t.Fatal(err) + } + store := auth.NewStore(db, nil, nil) + defer store.Stop() + account := &auth.Account{ + DBID: id, + UpstreamType: auth.UpstreamClaude, + AccessToken: "claude-token", + Models: []string{"claude-fable-5", "claude-sonnet-5"}, + } + store.AddAccount(account) + h := &Handler{store: store, db: db} + if _, err := h.connectionTestModelForAccount(ctx, account, "claude-fable-5"); err == nil || !strings.Contains(err.Error(), "持久化模型") { + t.Fatalf("stale runtime Fable model error = %v, want persisted catalog rejection", err) + } +} + +func TestConnectionTestModelForClaudeSkipsModelCooldown(t *testing.T) { + account := &auth.Account{ + UpstreamType: auth.UpstreamClaude, + Models: []string{"claude-haiku-4-5", "claude-sonnet-5"}, + } + account.SetModelCooldownUntil("claude-haiku-4-5", "credits_required", time.Now().Add(time.Hour)) + model, err := (&Handler{}).connectionTestModelForAccount(context.Background(), account, "") + if err != nil || model != "claude-sonnet-5" { + t.Fatalf("default Claude connection test model=(%q,%v), want cooldown-free sonnet", model, err) + } +} diff --git a/admin/official_pricing_sync.go b/admin/official_pricing_sync.go index 8f8509a7f..960814bd2 100644 --- a/admin/official_pricing_sync.go +++ b/admin/official_pricing_sync.go @@ -27,6 +27,7 @@ type officialPricingSyncConfigResponse struct { IntervalMinutes int `json:"interval_minutes"` IncludeOpenAI bool `json:"include_openai"` IncludeGrok bool `json:"include_grok"` + IncludeClaude bool `json:"include_claude"` LastAttemptAt *string `json:"last_attempt_at,omitempty"` LastSuccessAt *string `json:"last_success_at,omitempty"` LastError string `json:"last_error,omitempty"` @@ -38,6 +39,7 @@ func officialPricingConfigResponse(cfg *database.OfficialPricingSyncConfig) offi IntervalMinutes: database.DefaultOfficialPricingSyncIntervalMinutes, IncludeOpenAI: true, IncludeGrok: true, + IncludeClaude: true, } if cfg == nil { return response @@ -46,6 +48,7 @@ func officialPricingConfigResponse(cfg *database.OfficialPricingSyncConfig) offi response.IntervalMinutes = cfg.IntervalMinutes response.IncludeOpenAI = cfg.IncludeOpenAI response.IncludeGrok = cfg.IncludeGrok + response.IncludeClaude = cfg.IncludeClaude response.LastError = cfg.LastError response.LastWarning = cfg.LastWarning if cfg.LastAttemptAt.Valid { @@ -62,10 +65,11 @@ func officialPricingConfigResponse(cfg *database.OfficialPricingSyncConfig) offi func (h *Handler) officialPricingModelIDs(ctx context.Context) []string { models := proxy.SupportedModelIDs(ctx, h.db) models = append(models, h.grokBillingModelIDs()...) + models = append(models, h.claudeChannelModels()...) return models } -func (h *Handler) runOfficialPricingSync(ctx context.Context, includeOpenAI, includeGrok bool) (*proxy.OfficialPricingSyncResult, error) { +func (h *Handler) runOfficialPricingSync(ctx context.Context, includeOpenAI, includeGrok, includeClaude bool) (*proxy.OfficialPricingSyncResult, error) { select { case <-ctx.Done(): return nil, ctx.Err() @@ -82,6 +86,7 @@ func (h *Handler) runOfficialPricingSync(ctx context.Context, includeOpenAI, inc Models: h.officialPricingModelIDs(ctx), IncludeOpenAI: includeOpenAI, IncludeGrok: includeGrok, + IncludeClaude: includeClaude, }) recordCtx, recordCancel := context.WithTimeout(context.Background(), 5*time.Second) defer recordCancel() @@ -103,6 +108,7 @@ type updateOfficialPricingSyncConfigRequest struct { IntervalMinutes int `json:"interval_minutes"` IncludeOpenAI bool `json:"include_openai"` IncludeGrok bool `json:"include_grok"` + IncludeClaude bool `json:"include_claude"` } func (h *Handler) UpdateOfficialPricingSyncConfig(c *gin.Context) { @@ -122,6 +128,7 @@ func (h *Handler) UpdateOfficialPricingSyncConfig(c *gin.Context) { IntervalMinutes: req.IntervalMinutes, IncludeOpenAI: req.IncludeOpenAI, IncludeGrok: req.IncludeGrok, + IncludeClaude: req.IncludeClaude, }) if err != nil { writeError(c, http.StatusBadRequest, err.Error()) @@ -133,6 +140,7 @@ func (h *Handler) UpdateOfficialPricingSyncConfig(c *gin.Context) { type syncOfficialPricingRequest struct { IncludeOpenAI *bool `json:"include_openai"` IncludeGrok *bool `json:"include_grok"` + IncludeClaude *bool `json:"include_claude"` } func (h *Handler) SyncOfficialPricingNow(c *gin.Context) { @@ -143,20 +151,23 @@ func (h *Handler) SyncOfficialPricingNow(c *gin.Context) { return } } - includeOpenAI, includeGrok := true, true + includeOpenAI, includeGrok, includeClaude := true, true, true if req.IncludeOpenAI != nil { includeOpenAI = *req.IncludeOpenAI } if req.IncludeGrok != nil { includeGrok = *req.IncludeGrok } - if !includeOpenAI && !includeGrok { + if req.IncludeClaude != nil { + includeClaude = *req.IncludeClaude + } + if !includeOpenAI && !includeGrok && !includeClaude { writeError(c, http.StatusBadRequest, "至少选择一个官方价格来源") return } ctx, cancel := context.WithTimeout(c.Request.Context(), 90*time.Second) defer cancel() - result, err := h.runOfficialPricingSync(ctx, includeOpenAI, includeGrok) + result, err := h.runOfficialPricingSync(ctx, includeOpenAI, includeGrok, includeClaude) if err != nil { writeError(c, http.StatusBadGateway, err.Error()) return @@ -179,7 +190,7 @@ func (h *Handler) StartOfficialPricingSync(ctx context.Context) { log.Printf("读取官方价格轮询设置失败: %v", err) return } - if cfg == nil || !cfg.Enabled || (!cfg.IncludeOpenAI && !cfg.IncludeGrok) { + if cfg == nil || !cfg.Enabled || (!cfg.IncludeOpenAI && !cfg.IncludeGrok && !cfg.IncludeClaude) { return } lastRun := time.Time{} @@ -191,7 +202,7 @@ func (h *Handler) StartOfficialPricingSync(ctx context.Context) { } syncCtx, syncCancel := context.WithTimeout(ctx, 90*time.Second) - result, syncErr := h.runOfficialPricingSync(syncCtx, cfg.IncludeOpenAI, cfg.IncludeGrok) + result, syncErr := h.runOfficialPricingSync(syncCtx, cfg.IncludeOpenAI, cfg.IncludeGrok, cfg.IncludeClaude) syncCancel() if syncErr != nil { log.Printf("官方模型价格自动同步失败: %v", syncErr) diff --git a/admin/plan_allow_grok_test.go b/admin/plan_allow_grok_test.go index df633f7f6..958ff4a8f 100644 --- a/admin/plan_allow_grok_test.go +++ b/admin/plan_allow_grok_test.go @@ -18,3 +18,11 @@ func TestCleanPlanAllowAcceptsGrokLiveTiersAndAPI(t *testing.T) { t.Fatalf("cleanPlanAllow() = %#v, want %#v", got, want) } } + +func TestCleanPlanAllowAcceptsClaudePlans(t *testing.T) { + input := []string{"Claude", "max-5x", "max-20x", "enterprise", "team", "free", "unknown", "MAX-5X"} + want := []string{"claude", "max-5x", "max-20x", "enterprise", "team", "free"} + if got := cleanPlanAllow(input); !reflect.DeepEqual(got, want) { + t.Fatalf("cleanPlanAllow() = %#v, want %#v", got, want) + } +} diff --git a/admin/proxy_balance.go b/admin/proxy_balance.go index 54c4ecfe8..7ded9f374 100644 --- a/admin/proxy_balance.go +++ b/admin/proxy_balance.go @@ -15,7 +15,8 @@ import ( ) // autoBalanceProxiesReq 是代理均衡绑定的请求体。 -// - Channel: grok/codex/空(全部 OAuth)。Grok 单 IP 号多会被上游 402,均衡绑定把号摊开。 +// - Channel: grok/codex/claude/空(全部 OAuth)。同一出口上的 OAuth 账号过多时, +// 均衡绑定把账号摊开,降低上游按出口聚合限流的概率。 // - Mode: unbound(默认,只分配未绑定账号) / all(全量重排,但尽量保留现有绑定以减少换 IP)。 // - MaxPerProxy: 每条代理的账号数上限,0 表示不限。 // - ProxyIDs: 限定参与分配的代理,空表示所有启用且未测出错误的代理。 @@ -53,7 +54,7 @@ func isOAuthProxyBalanceTarget(row *database.AccountRow) bool { switch upstreamType { case "": return strings.EqualFold(strings.TrimSpace(row.Type), "oauth") - case auth.UpstreamGrok, auth.UpstreamAntigravity: + case auth.UpstreamGrok, auth.UpstreamAntigravity, auth.UpstreamClaude: return true default: return false @@ -177,8 +178,8 @@ func (h *Handler) AutoBalanceProxies(c *gin.Context) { return } channel := strings.ToLower(strings.TrimSpace(req.Channel)) - if channel != "" && channel != database.UpstreamChannelGrok && channel != database.UpstreamChannelCodex && channel != database.UpstreamChannelAntigravity { - writeError(c, http.StatusBadRequest, "channel 仅支持 grok / codex / antigravity / 空") + if channel != "" && channel != database.UpstreamChannelGrok && channel != database.UpstreamChannelCodex && channel != database.UpstreamChannelAntigravity && channel != database.UpstreamChannelClaude { + writeError(c, http.StatusBadRequest, "channel 仅支持 grok / codex / antigravity / claude / 空") return } if req.MaxPerProxy < 0 { diff --git a/admin/proxy_balance_test.go b/admin/proxy_balance_test.go index d56f293dc..4cd84d134 100644 --- a/admin/proxy_balance_test.go +++ b/admin/proxy_balance_test.go @@ -49,6 +49,17 @@ func TestIsOAuthProxyBalanceTarget(t *testing.T) { }, want: true, }, + { + name: "claude oauth", + row: &database.AccountRow{ + Type: "anthropic", + Credentials: map[string]interface{}{ + "upstream_type": auth.UpstreamClaude, + "refresh_token": "rt-claude", + }, + }, + want: true, + }, { name: "codex access token only", row: &database.AccountRow{ diff --git a/admin/responses.go b/admin/responses.go index c886f7e0f..a6593f14e 100644 --- a/admin/responses.go +++ b/admin/responses.go @@ -24,7 +24,7 @@ type statsResponse struct { RateLimited int `json:"rate_limited"` Error int `json:"error"` TodayRequests int64 `json:"today_requests"` - // Channels 按上游渠道(codex/grok)拆分的账号与今日请求计数, + // Channels 按上游渠道(codex/grok/antigravity/claude)拆分的账号与今日请求计数, // 供仪表盘在「全部」视图并列展示、渠道视图切换主数字。 Channels map[string]statsChannelCounts `json:"channels,omitempty"` } diff --git a/admin/test_connection.go b/admin/test_connection.go index c9635e772..117befab9 100644 --- a/admin/test_connection.go +++ b/admin/test_connection.go @@ -106,7 +106,8 @@ func (h *Handler) TestConnection(c *gin.Context) { defer h.invalidateAccountSnapshotCaches() } - isOpenAIResponsesAccount := account.IsRelayStyle() + isClaudeAccount := account.IsClaudeOAuth() + isOpenAIResponsesAccount := account.IsRelayStyle() && !isClaudeAccount // Agent Identity 无 AT,凭私钥动态签名,跳过 AT 预检(请求走 Codex 执行器动态签名)。 if !isOpenAIResponsesAccount && !account.IsCodexAgentIdentity() && account.GetAccessToken() == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "账号没有可用的 Access Token,请先刷新"}) @@ -140,12 +141,17 @@ func (h *Handler) TestConnection(c *gin.Context) { // 构建最小测试请求体(参考 sub2api createOpenAITestPayload) payload := buildConnectionTestPayload(h.store, testModel) + if isClaudeAccount { + payload = buildClaudeConnectionTestPayload(h.store, testModel) + } // 发送请求 start := time.Now() var resp *http.Response var reqErr error - if isOpenAIResponsesAccount { + if isClaudeAccount { + resp, reqErr = proxy.ExecuteClaudeMessagesRequest(c.Request.Context(), account, payload, h.store.ResolveProxyForAccount(account), c.Request.Header.Clone(), account.EffectiveClaudeFingerprintMode(h.store.ClaudeFingerprintModeDefault()), h.store.ClaudeSecurityConfig()) + } else if isOpenAIResponsesAccount { resp, reqErr = proxy.ExecuteRelayStyleRequest(c.Request.Context(), account, payload, h.store.ResolveProxyForAccount(account), nil) } else { resp, reqErr = proxy.ExecuteRequest(c.Request.Context(), account, payload, "", h.store.ResolveProxyForAccount(account), "", nil, nil) @@ -155,6 +161,10 @@ func (h *Handler) TestConnection(c *gin.Context) { return } defer resp.Body.Close() + if isClaudeAccount { + h.handleClaudeConnectionTest(c, account, resp, testModel, start, isTransient, restoreOnSuccess, &transientOutcome, id) + return + } if resp.StatusCode != http.StatusOK { if !isOpenAIResponsesAccount && !isTransient { @@ -351,6 +361,227 @@ func buildConnectionTestPayload(store *auth.Store, model string) []byte { return buildTestPayloadWithContent(model, auth.RenderTestContent(content)) } +// buildClaudeConnectionTestPayload builds the native Anthropic Messages +// shape used by Claude OAuth accounts. Keeping this separate from the +// Responses test payload prevents an imported Claude token from ever being +// sent through an OpenAI-shaped probe. +func buildClaudeConnectionTestPayload(store *auth.Store, model string) []byte { + content := auth.DefaultTestContent + if store != nil { + content = store.GetTestContent() + } + content = auth.NormalizeTestContent(auth.RenderTestContent(content)) + body, err := json.Marshal(map[string]interface{}{ + "model": strings.TrimSpace(model), + "max_tokens": 32, + "stream": true, + "messages": []map[string]interface{}{{ + "role": "user", + "content": content, + }}, + }) + if err != nil { + return []byte(`{"model":"claude-haiku-4-5","max_tokens":1,"stream":true,"messages":[{"role":"user","content":"ping"}]}`) + } + return body +} + +func (h *Handler) handleClaudeConnectionTest( + c *gin.Context, + account *auth.Account, + resp *http.Response, + testModel string, + start time.Time, + isTransient bool, + restoreOnSuccess bool, + transientOutcome *string, + id int64, +) { + if resp == nil { + sendTestEvent(c, testEvent{Type: "error", Error: "Claude 上游未返回响应"}) + return + } + usageStore := h.store + if isTransient { + usageStore = nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + message := fmt.Sprintf("上游返回 %d: %s", resp.StatusCode, truncate(string(body), 500)) + creditsRequired := false + if account.IsClaudeOAuth() { + creditsRequired = syncClaudeTestUsageState(usageStore, account, testModel, resp, body) + if creditsRequired { + message = fmt.Sprintf("上游模型 %s 需要 usage credits,当前账号套餐不可用", testModel) + } + } + if !isTransient && !creditsRequired { + switch resp.StatusCode { + case http.StatusUnauthorized: + h.store.MarkCooldownWithError(account, 24*time.Hour, "unauthorized", message) + case http.StatusPaymentRequired: + if proxy.IsDeactivatedWorkspaceError(body) { + h.store.MarkDeactivatedWorkspace(account, message) + } else { + h.store.MarkError(account, message) + } + case http.StatusForbidden: + if proxy.IsDeactivatedWorkspaceError(body) { + h.store.MarkDeactivatedWorkspace(account, message) + } + } + } + if isTransient && resp.StatusCode == http.StatusTooManyRequests && transientOutcome != nil { + *transientOutcome = "rate_limited" + } + sendTestEvent(c, testEvent{Type: "error", Error: message}) + return + } + if account.IsClaudeOAuth() { + proxy.SyncClaudeUsageState(usageStore, account, resp) + } + status, detail := readClaudeMessagesStream(c.Request.Context(), resp, func(text string) { + if strings.TrimSpace(text) != "" { + sendTestEvent(c, testEvent{Type: "content", Text: text}) + } + }) + if status != "success" { + if !isTransient { + applyClaudeConnectionStreamFailure(h, account, testModel, status, detail, resp) + } + if status == "rate_limited" && transientOutcome != nil && isTransient { + *transientOutcome = "rate_limited" + } + sendTestEvent(c, testEvent{Type: "error", Error: detail}) + return + } + if !isTransient && claudeConnectionTestShouldPreserveUsageCooldown(account, resp) { + // A native Messages response can carry a valid body while explicitly + // reporting a rejected/exhausted quota window. It is not evidence that + // the account recovered; never let the manual-test success path erase the + // authoritative cooldown just created by the same response. + sendTestEvent(c, testEvent{Type: "error", Error: "Claude 上游返回了有效响应,但账号仍处于配额/限流状态"}) + return + } + if isTransient && claudeConnectionTestShouldPreserveUsageCooldown(account, resp) { + if transientOutcome != nil { + *transientOutcome = "rate_limited" + } + sendTestEvent(c, testEvent{Type: "error", Error: "Claude 上游返回了有效响应,但账号仍处于配额/限流状态"}) + return + } + if isTransient { + if transientOutcome != nil { + *transientOutcome = "success" + } + if restoreOnSuccess { + restoreCtx, restoreCancel := context.WithTimeout(context.Background(), 5*time.Second) + restoreErr := h.restoreAccountByID(restoreCtx, id) + restoreCancel() + if restoreErr != nil { + sendTestEvent(c, testEvent{Type: "content", Text: "\n\n--- 自动恢复失败: " + restoreErr.Error() + " ---"}) + } + } + } else { + h.store.RecordManualTestSuccess(account, time.Since(start)) + } + sendTestEvent(c, testEvent{Type: "content", Text: fmt.Sprintf("\n\n--- 耗时 %dms ---", time.Since(start).Milliseconds())}) + sendTestEvent(c, testEvent{Type: "test_complete", Success: true}) +} + +// applyClaudeConnectionStreamFailure makes a body-only native error visible to +// the account scheduler. Anthropic may return HTTP 200 with an SSE error event, +// so the ordinary HTTP status handlers cannot establish a short cooldown. +func applyClaudeConnectionStreamFailure(h *Handler, account *auth.Account, model, status, detail string, resp *http.Response) { + if h == nil || h.store == nil || account == nil || !account.IsClaudeOAuth() { + return + } + switch status { + case "rate_limited": + if claudeConnectionDetailRequiresCredits(h.store, account, model, detail) { + return + } + // The caller already synchronized response headers before consuming the + // stream. Never replace a precise 5h/7d cooldown with the generic one + // minute fallback when those headers were authoritative. + if claudeResponseHasUsageLimitSignal(resp) { + return + } + headers := make(http.Header) + if resp != nil { + if retryAfter := strings.TrimSpace(resp.Header.Get("Retry-After")); retryAfter != "" { + headers.Set("Retry-After", retryAfter) + } + } + proxy.SyncClaudeUsageState(h.store, account, &http.Response{StatusCode: http.StatusTooManyRequests, Header: headers}) + case "failed": + lower := strings.ToLower(strings.TrimSpace(detail)) + if strings.Contains(lower, "authentication") || strings.Contains(lower, "unauthor") || strings.Contains(lower, "invalid token") || strings.Contains(lower, "invalid_token") { + h.store.MarkCooldownWithError(account, 5*time.Minute, "unauthorized", "Claude 测试返回授权失败: "+truncate(detail, 300)) + } + } +} + +// syncClaudeTestUsageState keeps connection tests from turning a model-level +// credits_required response into an account-level cooldown. It returns true +// only when the response was handled as a model entitlement failure. +func syncClaudeTestUsageState(store *auth.Store, account *auth.Account, model string, resp *http.Response, body []byte) bool { + if store == nil || account == nil || !account.IsClaudeOAuth() || resp == nil { + return false + } + if proxy.HandleClaudeModelBillingRejection(store, account, model, resp.StatusCode, body) { + return true + } + proxy.SyncClaudeUsageState(store, account, resp) + return false +} + +func claudeConnectionDetailRequiresCredits(store *auth.Store, account *auth.Account, model, detail string) bool { + lower := strings.ToLower(strings.TrimSpace(detail)) + if !strings.Contains(lower, "credits_required") && !strings.Contains(lower, "usage credits") { + return false + } + body := []byte(fmt.Sprintf(`{"error":{"details":{"error_code":"credits_required","model":%q}}}`, strings.TrimSpace(model))) + return proxy.HandleClaudeModelBillingRejection(store, account, model, http.StatusTooManyRequests, body) +} + +func claudeResponseHasUsageLimitSignal(resp *http.Response) bool { + if resp == nil { + return false + } + status := strings.ToLower(strings.TrimSpace(resp.Header.Get("anthropic-ratelimit-unified-status"))) + if resp.StatusCode == http.StatusTooManyRequests || status == "rejected" { + return true + } + claim := strings.ToLower(strings.TrimSpace(resp.Header.Get("anthropic-ratelimit-unified-representative-claim"))) + if claim != "five_hour" && claim != "five-hour" && claim != "5h" && claim != "seven_day" && claim != "seven-day" && claim != "7d" { + return false + } + key := "anthropic-ratelimit-unified-5h-utilization" + if claim == "seven_day" || claim == "seven-day" || claim == "7d" { + key = "anthropic-ratelimit-unified-7d-utilization" + } + value, err := strconv.ParseFloat(strings.TrimSpace(resp.Header.Get(key)), 64) + if err == nil && ((value <= 1.5 && value >= 1) || value >= 100) { + return true + } + return false +} + +func claudeConnectionTestShouldPreserveUsageCooldown(account *auth.Account, resp *http.Response) bool { + if !claudeResponseHasUsageLimitSignal(resp) { + return false + } + // The response headers/event are authoritative even for a transient account + // that intentionally does not persist state. Returning true prevents a + // rejected 200 body from being treated as a successful recovery and restored + // into the active pool. + if account == nil { + return true + } + return true +} + // buildTestPayload 构建默认最小测试请求体 func buildTestPayload(model string) []byte { return buildTestPayloadWithContent(model, auth.DefaultTestContent) @@ -582,6 +813,52 @@ func defaultGrokConnectionTestModels(account *auth.Account) []string { func (h *Handler) connectionTestModelForAccount(ctx context.Context, account *auth.Account, requested string) (string, error) { requested = strings.TrimSpace(requested) + if account != nil && account.IsClaudeOAuth() { + models := claudeProbeModelIDs(account) + if requested != "" { + if h != nil && h.db != nil && account.DBID > 0 { + row, err := h.db.GetAccountByID(ctx, account.DBID) + if err == nil && row != nil { + persistedModels := row.GetCredentialStringSlice("models") + if len(persistedModels) > 0 { + persistedMatch := false + for _, persisted := range persistedModels { + if strings.EqualFold(strings.TrimSpace(persisted), requested) { + persistedMatch = true + break + } + } + if !persistedMatch { + return "", fmt.Errorf("该 Claude 账号的持久化模型清单不支持测试模型: %s", requested) + } + } + } + } + if account.IsModelRateLimited(requested) { + return "", fmt.Errorf("该 Claude 模型当前不可用(模型级冷却): %s", requested) + } + for _, model := range models { + if strings.EqualFold(strings.TrimSpace(model), requested) { + return strings.TrimSpace(model), nil + } + } + return "", fmt.Errorf("该 Claude 账号不支持测试模型: %s", requested) + } + if len(models) == 0 { + return "", fmt.Errorf("该 Claude 账号没有可用于测试的文本模型") + } + for _, candidate := range models { + if !account.IsModelRateLimited(candidate) && strings.Contains(strings.ToLower(candidate), "haiku") { + return strings.TrimSpace(candidate), nil + } + } + for _, candidate := range models { + if !account.IsModelRateLimited(candidate) { + return strings.TrimSpace(candidate), nil + } + } + return "", fmt.Errorf("该 Claude 账号的文本模型均处于模型级冷却") + } if account == nil || !account.IsRelayStyle() { if requested == "" { return h.connectionTestModel(ctx), nil @@ -1070,7 +1347,9 @@ func (h *Handler) runSingleBatchTest(ctx context.Context, acc *auth.Account) (st var resp *http.Response var err error - if acc.IsRelayStyle() { + if acc.IsClaudeOAuth() { + resp, err = proxy.ExecuteClaudeMessagesRequest(testCtx, acc, buildClaudeConnectionTestPayload(h.store, testModel), h.store.ResolveProxyForAccount(acc), nil, acc.EffectiveClaudeFingerprintMode(h.store.ClaudeFingerprintModeDefault()), h.store.ClaudeSecurityConfig()) + } else if acc.IsRelayStyle() { resp, err = proxy.ExecuteRelayStyleRequest(testCtx, acc, payload, h.store.ResolveProxyForAccount(acc), nil) } else { resp, err = proxy.ExecuteRequest(testCtx, acc, payload, "", h.store.ResolveProxyForAccount(acc), "", nil, nil) @@ -1089,17 +1368,35 @@ func (h *Handler) runSingleBatchTest(ctx context.Context, acc *auth.Account) (st switch resp.StatusCode { case http.StatusOK: - if !acc.IsRelayStyle() { + if acc.IsClaudeOAuth() { + proxy.SyncClaudeUsageState(h.store, acc, resp) + status, msg := readClaudeMessagesStream(testCtx, resp, nil) + if status != "success" { + applyClaudeConnectionStreamFailure(h, acc, testModel, status, msg, resp) + } + if status == "rate_limited" { + return "rate_limited", msg + } + if status != "success" { + return "failed", msg + } + } else if !acc.IsRelayStyle() { usageState := proxy.SyncCodexUsageState(h.store, acc, resp) applyUsageLimitedTestState(h.store, acc, usageState) if msg, limited := formatUsageLimitedTestError(usageState); limited { return "rate_limited", msg } } - status, msg := h.readBatchTestStreamResult(testCtx, acc, resp, testModel) + status, msg := "success", "测试通过" + if !acc.IsClaudeOAuth() { + status, msg = h.readBatchTestStreamResult(testCtx, acc, resp, testModel) + } if status != "success" { return status, msg } + if acc.IsClaudeOAuth() && claudeConnectionTestShouldPreserveUsageCooldown(acc, resp) { + return "rate_limited", "Claude 上游返回了有效响应,但账号仍处于配额/限流状态" + } // 测试成功即重置失败/冷却状态,用量限制由调度器自行判断 h.store.RecordManualTestSuccess(acc, time.Since(start)) return "success", msg @@ -1108,7 +1405,9 @@ func (h *Handler) runSingleBatchTest(ctx context.Context, acc *auth.Account) (st if readErr != nil { return h.handleBatchTestReadError(testCtx, acc, readErr) } - if !acc.IsRelayStyle() { + if acc.IsClaudeOAuth() { + proxy.SyncClaudeUsageState(h.store, acc, resp) + } else if !acc.IsRelayStyle() { proxy.SyncCodexUsageState(h.store, acc, resp) } h.store.MarkCooldownWithError(acc, 24*time.Hour, "unauthorized", fmt.Sprintf("上游返回 %d: %s", resp.StatusCode, truncate(string(body), 300))) @@ -1120,7 +1419,12 @@ func (h *Handler) runSingleBatchTest(ctx context.Context, acc *auth.Account) (st } // Grok 走 relay 但有 free-usage-exhausted 语义,须交给 Apply429Cooldown 识别耗尽 // (→ 24h usage_limited + 落权威用量快照),不能并入 relay 的 1 分钟 rate_limited。 - if acc.IsRelayStyle() && !acc.IsGrokAPI() { + if acc.IsClaudeOAuth() { + if proxy.HandleClaudeModelBillingRejection(h.store, acc, testModel, resp.StatusCode, body) { + return "rate_limited", fmt.Sprintf("上游模型 %s 需要 usage credits,当前账号套餐不可用", testModel) + } + proxy.SyncClaudeUsageState(h.store, acc, resp) + } else if acc.IsRelayStyle() && !acc.IsGrokAPI() { h.store.MarkCooldown(acc, time.Minute, "rate_limited") } else { if !acc.IsRelayStyle() { @@ -1172,10 +1476,15 @@ func (h *Handler) runRecycleBinSingleTest(ctx context.Context, acc *auth.Account return "failed", modelErr.Error() } payload := buildConnectionTestPayload(h.store, testModel) + if acc.IsClaudeOAuth() { + payload = buildClaudeConnectionTestPayload(h.store, testModel) + } var resp *http.Response var err error - if acc.IsRelayStyle() { + if acc.IsClaudeOAuth() { + resp, err = proxy.ExecuteClaudeMessagesRequest(testCtx, acc, payload, h.store.ResolveProxyForAccount(acc), nil, acc.EffectiveClaudeFingerprintMode(h.store.ClaudeFingerprintModeDefault()), h.store.ClaudeSecurityConfig()) + } else if acc.IsRelayStyle() { resp, err = proxy.ExecuteRelayStyleRequest(testCtx, acc, payload, h.store.ResolveProxyForAccount(acc), nil) } else { resp, err = proxy.ExecuteRequest(testCtx, acc, payload, "", h.store.ResolveProxyForAccount(acc), "", nil, nil) @@ -1190,7 +1499,15 @@ func (h *Handler) runRecycleBinSingleTest(ctx context.Context, acc *auth.Account switch resp.StatusCode { case http.StatusOK: - if !acc.IsRelayStyle() { + if acc.IsClaudeOAuth() { + // Recycle-bin tests are intentionally read-only. Inspect the native + // response headers without mutating the transient account snapshot. + status, msg := readClaudeMessagesStream(testCtx, resp, nil) + if status == "success" && claudeConnectionTestShouldPreserveUsageCooldown(acc, resp) { + return "rate_limited", "Claude 上游返回了有效响应,但账号仍处于配额/限流状态" + } + return status, msg + } else if !acc.IsRelayStyle() { // store 传 nil:只解析用量头用于结果展示,不持久化、不改限流状态。 usageState := proxy.SyncCodexUsageState(nil, acc, resp) if msg, limited := formatUsageLimitedTestError(usageState); limited { diff --git a/admin/usage_probe.go b/admin/usage_probe.go index 5e694ffd0..532c9d9f3 100644 --- a/admin/usage_probe.go +++ b/admin/usage_probe.go @@ -13,6 +13,7 @@ import ( "github.com/codex2api/auth" "github.com/codex2api/proxy" + "github.com/codex2api/security" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" ) @@ -63,6 +64,12 @@ func (h *Handler) ProbeUsageSnapshot(ctx context.Context, account *auth.Account) if account == nil { return nil } + // Claude Code OAuth credentials are Anthropic-only. Never send them to the + // ChatGPT WHAM or Responses probe: those endpoints use a different token + // issuer and a false 401 would incorrectly quarantine a valid account. + if account.IsClaudeOAuth() { + return h.probeUsageViaClaudeMessages(ctx, account) + } if account.IsAntigravityAPI() { return errors.New("Antigravity 账号请使用专用配额刷新,不能执行 Codex wham 探针") } @@ -122,6 +129,175 @@ func (h *Handler) ProbeUsageSnapshot(ctx context.Context, account *auth.Account) return h.probeUsageViaResponses(ctx, account) } +// selectClaudeUsageProbeModel picks a low-cost, previously unblocked Claude +// model for the background usage probe. Model discovery is not entitlement +// discovery: Anthropic may advertise a model such as Fable 5 while requiring +// purchased usage credits for a particular plan. Keep such models as a last +// resort, and never retry one while its model-level cooldown is active. +func selectClaudeUsageProbeModel(account *auth.Account) (string, error) { + if account == nil { + return "", errors.New("Claude 用量探针缺少账号") + } + models := proxy.DefaultClaudeModelIDsForAccount(account) + account.Mu().RLock() + explicit := len(account.Models) > 0 + account.Mu().RUnlock() + if len(models) == 0 { + if explicit { + return "", errors.New("Claude 账号模型白名单没有有效的 claude-* 模型") + } + models = []string{"claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"} + } + + // Prefer the cheapest stable family, then unknown future models, and only + // probe Fable after every other candidate is unavailable. This prevents a + // credits_required Fable entry sorted first from creating a probe storm. + bestModel := "" + bestRank := 99 + for _, candidate := range models { + candidate = strings.TrimSpace(candidate) + lower := strings.ToLower(candidate) + if candidate == "" || !strings.HasPrefix(lower, "claude-") || account.IsModelRateLimited(candidate) { + continue + } + rank := 3 + switch { + case strings.Contains(lower, "haiku"): + rank = 0 + case strings.Contains(lower, "sonnet"): + rank = 1 + case strings.Contains(lower, "opus"): + rank = 2 + case strings.Contains(lower, "fable"): + rank = 4 + } + if rank < bestRank { + bestModel = candidate + bestRank = rank + } + } + if bestModel == "" { + return "", errors.New("Claude 用量探针跳过:所有模型均处于模型级冷却") + } + return bestModel, nil +} + +// probeUsageViaClaudeMessages sends a bounded, non-streaming Anthropic Messages +// request and records the unified 5h/7d rate-limit headers. A probe failure is +// returned to the import queue but does not itself ban the account; a +// credits_required response is recorded as a model-only cooldown. +func (h *Handler) probeUsageViaClaudeMessages(ctx context.Context, account *auth.Account) (probeErr error) { + if account == nil { + return nil + } + defer func() { + // Count failed/metadata-free attempts for freshness as well. This is a + // bounded backoff marker, not a quota observation; it prevents a failed + // provider probe from being retried on every scheduler sweep. + account.MarkClaudeUsageObservation(time.Now()) + h.recordClaudeUsageProbe(account, probeErr) + }() + model, modelErr := selectClaudeUsageProbeModel(account) + if modelErr != nil { + return modelErr + } + body := []byte(fmt.Sprintf(`{"model":%q,"max_tokens":1,"messages":[{"role":"user","content":"ping"}],"stream":false}`, model)) + var ( + resp *http.Response + err error + ) + if h != nil && h.executeClaudeUsageProbe != nil { + resp, err = h.executeClaudeUsageProbe(ctx, account, body) + } else { + proxyURL := "" + fingerprintMode := "" + securityConfig := auth.DefaultClaudeSecurityConfig() + if h != nil && h.store != nil { + proxyURL = h.store.ResolveProxyForAccount(account) + fingerprintMode = account.EffectiveClaudeFingerprintMode(h.store.ClaudeFingerprintModeDefault()) + securityConfig = h.store.ClaudeSecurityConfig() + } + resp, err = proxy.ExecuteClaudeMessagesRequest(ctx, account, body, proxyURL, nil, fingerprintMode, securityConfig) + } + if err != nil { + return err + } + if resp == nil { + return errors.New("Claude Messages probe returned nil response") + } + defer resp.Body.Close() + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + if readErr != nil { + return fmt.Errorf("读取 Claude Messages probe 响应失败: %w", readErr) + } + if h != nil && h.store != nil { + if proxy.HandleClaudeModelBillingRejection(h.store, account, model, resp.StatusCode, body) { + return fmt.Errorf("Claude 模型 %s 需要 usage credits", model) + } + // Some compatibility layers wrap a native error payload in HTTP 200. + // Treat credits_required the same way as the normal 429 path without + // feeding it into the account-level quota synchronizer. + if strings.EqualFold(strings.TrimSpace(gjson.GetBytes(body, "type").String()), "error") { + if proxy.HandleClaudeModelBillingRejection(h.store, account, model, http.StatusTooManyRequests, body) { + return fmt.Errorf("Claude 模型 %s 需要 usage credits", model) + } + } + proxy.SyncClaudeUsageState(h.store, account, resp) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Do not mark unauthorized here: OAuth token failures need corroboration + // from real Claude traffic, while rate-limit state was already synced. + return fmt.Errorf("Claude Messages probe returned status %d", resp.StatusCode) + } + if len(bytes.TrimSpace(body)) == 0 { + return fmt.Errorf("Claude Messages probe returned an empty body") + } + // Anthropic normally uses a non-2xx status for errors, but a proxy or + // compatibility layer may wrap a native error in HTTP 200. Do not mark + // such a response as a successful sample. + if !gjson.ValidBytes(body) { + return fmt.Errorf("Claude Messages probe returned an invalid JSON payload") + } + typeName := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "type").String())) + if typeName == "error" { + return fmt.Errorf("Claude Messages probe returned an error payload") + } + if typeName != "message" { + return fmt.Errorf("Claude Messages probe returned an invalid message payload") + } + if h != nil && h.store != nil { + h.store.ReportRequestSuccess(account, 0) + } + return nil +} + +// recordClaudeUsageProbe persists only the outcome metadata needed by the +// account-management UI. It never changes account health/cooldown state and a +// persistence failure is intentionally best-effort: sampling must not block +// request routing or turn a valid OAuth token into an error account. +func (h *Handler) recordClaudeUsageProbe(account *auth.Account, probeErr error) { + if h == nil || h.db == nil || account == nil || account.DBID <= 0 { + return + } + fields := map[string]interface{}{ + auth.ClaudeUsageProbeAtCredentialKey: time.Now().UTC().Format(time.RFC3339), + auth.ClaudeUsageProbeErrorCredentialKey: "", + } + if probeErr != nil { + fields[auth.ClaudeUsageProbeErrorCredentialKey] = security.SafeTruncate(security.SanitizeLog(strings.TrimSpace(probeErr.Error())), 300) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := h.db.UpdateCredentials(ctx, account.DBID, fields); err != nil { + log.Printf("[账号 %d] 持久化 Claude 用量采样状态失败: %v", account.DBID, err) + return + } + // The paged account list is projection-backed and may be cached for up to + // 30s on large pools. Expire only the Claude snapshot so the next silent + // poll observes this attempt without disturbing Codex/Grok pages. + h.invalidateClaudeCatalogCaches() +} + // probeUsageViaWham 通过 /backend-api/wham/usage 拉取用量, // 不消耗任何 token 额度。 // diff --git a/admin/usage_probe_test.go b/admin/usage_probe_test.go index 8d0a873ff..1350ccf81 100644 --- a/admin/usage_probe_test.go +++ b/admin/usage_probe_test.go @@ -13,6 +13,7 @@ import ( "github.com/codex2api/auth" "github.com/codex2api/database" "github.com/codex2api/proxy" + "github.com/tidwall/gjson" ) func TestProbeUsageSnapshotRejectsAntigravity(t *testing.T) { @@ -24,6 +25,243 @@ func TestProbeUsageSnapshotRejectsAntigravity(t *testing.T) { } } +func TestProbeUsageSnapshotClaudeUsesAnthropicMessagesOnly(t *testing.T) { + store := auth.NewStore(nil, nil, nil) + account := &auth.Account{DBID: 77, UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token", Status: auth.StatusReady} + account.Models = []string{"claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"} + store.AddAccount(account) + called := false + h := &Handler{store: store, executeClaudeUsageProbe: func(_ context.Context, acc *auth.Account, body []byte) (*http.Response, error) { + called = true + if acc != account || !strings.Contains(string(body), `"model":"claude-haiku-4-5"`) || !strings.Contains(string(body), `"max_tokens":1`) { + t.Fatalf("unexpected Claude probe request: account=%p body=%s", acc, body) + } + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"type":"message","id":"msg_probe","content":[{"type":"text","text":"ok"}]}`))} + resp.Header.Set("anthropic-ratelimit-unified-5h-utilization", "0.25") + resp.Header.Set("anthropic-ratelimit-unified-5h-reset", "4102444800") + resp.Header.Set("anthropic-ratelimit-unified-7d-utilization", "0.4") + resp.Header.Set("anthropic-ratelimit-unified-7d-reset", "4103049600") + return resp, nil + }} + if err := h.ProbeUsageSnapshot(context.Background(), account); err != nil { + t.Fatalf("ProbeUsageSnapshot() error = %v", err) + } + if !called { + t.Fatal("Claude probe callback was not called") + } + if got := account.UsagePercent5h; got != 25 { + t.Fatalf("5h usage = %v, want 25", got) + } + if got := account.UsagePercent7d; got != 40 { + t.Fatalf("7d usage = %v, want 40", got) + } +} + +func TestSelectClaudeUsageProbeModelSkipsFableWhenCheaperModelExists(t *testing.T) { + account := &auth.Account{ + UpstreamType: auth.UpstreamClaude, + Models: []string{"claude-fable-5", "claude-sonnet-5", "claude-opus-4-7"}, + } + model, err := selectClaudeUsageProbeModel(account) + if err != nil || model != "claude-sonnet-5" { + t.Fatalf("probe model=(%q,%v), want sonnet instead of credits-gated Fable", model, err) + } +} + +func TestSelectClaudeUsageProbeModelSkipsActiveModelCooldown(t *testing.T) { + account := &auth.Account{ + UpstreamType: auth.UpstreamClaude, + Models: []string{"claude-haiku-4-5", "claude-sonnet-5"}, + } + account.SetModelCooldownUntil("claude-haiku-4-5", "credits_required", time.Now().Add(time.Hour)) + model, err := selectClaudeUsageProbeModel(account) + if err != nil || model != "claude-sonnet-5" { + t.Fatalf("probe model=(%q,%v), want cooldown-free sonnet", model, err) + } +} + +func TestProbeUsageSnapshotClaudeCreditsRequiredDoesNotCooldownAccount(t *testing.T) { + store := auth.NewStore(nil, nil, nil) + defer store.Stop() + account := &auth.Account{ + DBID: 82, + UpstreamType: auth.UpstreamClaude, + AccessToken: "claude-token", + Status: auth.StatusReady, + Models: []string{"claude-fable-5", "claude-sonnet-5"}, + } + store.AddAccount(account) + calledModel := "" + h := &Handler{store: store, executeClaudeUsageProbe: func(_ context.Context, _ *auth.Account, body []byte) (*http.Response, error) { + calledModel = gjson.GetBytes(body, "model").String() + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"rate_limit_error","message":"Usage credits are required for this model.","details":{"error_code":"credits_required","model":"claude-sonnet-5"}}}`)), + }, nil + }} + if err := h.ProbeUsageSnapshot(context.Background(), account); err == nil || !strings.Contains(err.Error(), "usage credits") { + t.Fatalf("credits_required probe error=%v, want explicit usage credits error", err) + } + if calledModel != "claude-sonnet-5" { + t.Fatalf("probe selected model %q, want to skip Fable", calledModel) + } + if account.HasActiveCooldown() || account.RuntimeStatus() == "rate_limited" { + t.Fatalf("credits_required probe must not cool down account: status=%q", account.RuntimeStatus()) + } + if !account.IsModelRateLimited("claude-sonnet-5") { + t.Fatal("credits_required probe should set a model-level cooldown") + } +} + +func TestProbeUsageSnapshotClaudePersistsRejectedFiveHourLimit(t *testing.T) { + store := auth.NewStore(nil, nil, nil) + account := &auth.Account{DBID: 78, UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token", Status: auth.StatusReady} + store.AddAccount(account) + h := &Handler{store: store, executeClaudeUsageProbe: func(context.Context, *auth.Account, []byte) (*http.Response, error) { + resp := &http.Response{StatusCode: http.StatusTooManyRequests, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":{"type":"rate_limit_error"}}`))} + resp.Header.Set("anthropic-ratelimit-unified-5h-utilization", "1") + resp.Header.Set("anthropic-ratelimit-unified-5h-reset", "4102444800") + resp.Header.Set("anthropic-ratelimit-unified-status", "rejected") + return resp, nil + }} + if err := h.ProbeUsageSnapshot(context.Background(), account); err == nil { + t.Fatal("Claude 429 probe should return an error to the queue") + } + if got := account.RuntimeStatus(); got != "rate_limited" && got != "cooldown" && got != auth.ResponsesRateLimitedCooldownReason { + t.Fatalf("Claude rejected status = %q, want a rate-limited cooldown", got) + } + if !account.UsagePercent5hValid || account.UsagePercent5h != 100 { + t.Fatalf("Claude 5h snapshot = (%v, %t), want 100%% valid", account.UsagePercent5h, account.UsagePercent5hValid) + } +} + +func TestProbeUsageSnapshotClaudeDoesNotClearRejectedStatusOnHTTP200(t *testing.T) { + store := auth.NewStore(nil, nil, nil) + account := &auth.Account{DBID: 79, UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token", Status: auth.StatusReady} + store.AddAccount(account) + h := &Handler{store: store, executeClaudeUsageProbe: func(context.Context, *auth.Account, []byte) (*http.Response, error) { + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"type":"message","content":[{"type":"text","text":"ok"}]}`))} + resp.Header.Set("anthropic-ratelimit-unified-5h-utilization", "1") + resp.Header.Set("anthropic-ratelimit-unified-5h-reset", "4102444800") + resp.Header.Set("anthropic-ratelimit-unified-status", "rejected") + return resp, nil + }} + if err := h.ProbeUsageSnapshot(context.Background(), account); err != nil { + t.Fatalf("ProbeUsageSnapshot() error = %v", err) + } + if got := account.RuntimeStatus(); got != auth.ResponsesRateLimitedCooldownReason { + t.Fatalf("Claude rejected status after HTTP 200 = %q, want %q", got, auth.ResponsesRateLimitedCooldownReason) + } +} + +func TestProbeUsageSnapshotClaudePersistsSamplingMetadata(t *testing.T) { + db := newTestAdminDB(t) + ctx := context.Background() + id, err := db.InsertAccountWithUpstream(ctx, "claude-sampling", "anthropic", "oauth", map[string]interface{}{ + "upstream_type": "claude", + "access_token": "claude-token", + "refresh_token": "claude-refresh", + }, "") + if err != nil { + t.Fatalf("insert Claude account: %v", err) + } + store := auth.NewStore(db, nil, nil) + defer store.Stop() + account := &auth.Account{DBID: id, UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token", Status: auth.StatusReady} + store.AddAccount(account) + h := &Handler{store: store, db: db, executeClaudeUsageProbe: func(context.Context, *auth.Account, []byte) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"type":"message","content":[{"type":"text","text":"ok"}]}`))}, nil + }} + if err := h.ProbeUsageSnapshot(ctx, account); err != nil { + t.Fatalf("successful Claude probe: %v", err) + } + row, err := db.GetAccountByID(ctx, id) + if err != nil { + t.Fatalf("read successful probe metadata: %v", err) + } + if row.GetCredential("claude_usage_probe_at") == "" || row.GetCredential("claude_usage_probe_error") != "" { + t.Fatalf("successful probe metadata = at=%q error=%q", row.GetCredential("claude_usage_probe_at"), row.GetCredential("claude_usage_probe_error")) + } + + h.executeClaudeUsageProbe = func(context.Context, *auth.Account, []byte) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusBadGateway, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`upstream failed`))}, nil + } + if err := h.ProbeUsageSnapshot(ctx, account); err == nil { + t.Fatal("failed Claude probe should return an error") + } + row, err = db.GetAccountByID(ctx, id) + if err != nil { + t.Fatalf("read failed probe metadata: %v", err) + } + if row.GetCredential("claude_usage_probe_at") == "" || row.GetCredential("claude_usage_probe_error") == "" { + t.Fatalf("failed probe metadata = at=%q error=%q", row.GetCredential("claude_usage_probe_at"), row.GetCredential("claude_usage_probe_error")) + } +} + +func TestProbeUsageSnapshotClaudeRejectsHTTP200ErrorPayload(t *testing.T) { + store := auth.NewStore(nil, nil, nil) + defer store.Stop() + account := &auth.Account{DBID: 80, UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token", Status: auth.StatusReady} + store.AddAccount(account) + h := &Handler{store: store, executeClaudeUsageProbe: func(context.Context, *auth.Account, []byte) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"type":"error","error":{"message":"wrapped failure"}}`)), + }, nil + }} + if err := h.ProbeUsageSnapshot(context.Background(), account); err == nil { + t.Fatal("HTTP 200 native error payload must fail the Claude sample") + } +} + +func TestProbeUsageSnapshotClaudeCreditsRequiredWrappedInHTTP200(t *testing.T) { + store := auth.NewStore(nil, nil, nil) + defer store.Stop() + account := &auth.Account{ + DBID: 83, + UpstreamType: auth.UpstreamClaude, + AccessToken: "claude-token", + Status: auth.StatusReady, + Models: []string{"claude-sonnet-5"}, + } + store.AddAccount(account) + h := &Handler{store: store, executeClaudeUsageProbe: func(context.Context, *auth.Account, []byte) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"type":"error","error":{"message":"Usage credits are required for this model.","details":{"error_code":"credits_required","model":"claude-sonnet-5"}}}`)), + }, nil + }} + if err := h.ProbeUsageSnapshot(context.Background(), account); err == nil || !strings.Contains(err.Error(), "usage credits") { + t.Fatalf("wrapped credits_required probe error=%v", err) + } + if account.HasActiveCooldown() || account.RuntimeStatus() == "rate_limited" { + t.Fatalf("wrapped credits_required must not cool down account: status=%q", account.RuntimeStatus()) + } + if !account.IsModelRateLimited("claude-sonnet-5") { + t.Fatal("wrapped credits_required should cool down only the model") + } +} + +func TestProbeUsageSnapshotClaudeRejectsHTTP200NonMessagePayload(t *testing.T) { + store := auth.NewStore(nil, nil, nil) + defer store.Stop() + account := &auth.Account{DBID: 81, UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token", Status: auth.StatusReady} + store.AddAccount(account) + h := &Handler{store: store, executeClaudeUsageProbe: func(context.Context, *auth.Account, []byte) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), + }, nil + }} + if err := h.ProbeUsageSnapshot(context.Background(), account); err == nil { + t.Fatal("HTTP 200 non-message payload must not count as a successful Claude sample") + } +} + func TestShouldMarkUsageProbeAccountError(t *testing.T) { tests := []struct { name string diff --git a/admin/wham_daily_probe.go b/admin/wham_daily_probe.go index 6d0b00758..8cdfaeb16 100644 --- a/admin/wham_daily_probe.go +++ b/admin/wham_daily_probe.go @@ -241,7 +241,10 @@ func whamDailyUsageBackfillEligible(account *auth.Account) bool { if account == nil || account.DBID <= 0 { return false } - if account.IsOpenAIResponsesAPI() || account.IsGrokAPI() { + // WHAM is a ChatGPT-only control-plane endpoint. Claude OAuth credentials + // belong to Anthropic Messages and must never be sent to WHAM (even though + // they carry an access token and are relay-style accounts). + if account.IsOpenAIResponsesAPI() || account.IsGrokAPI() || account.IsClaudeOAuth() { return false } if isCodexATAccount(account) { diff --git a/admin/wham_daily_probe_test.go b/admin/wham_daily_probe_test.go index a06130104..92c75d9fd 100644 --- a/admin/wham_daily_probe_test.go +++ b/admin/wham_daily_probe_test.go @@ -119,6 +119,10 @@ func TestWhamDailyUsageBackfillEligibleSkipsRelayGrokAndCodexAT(t *testing.T) { if whamDailyUsageBackfillEligible(&auth.Account{DBID: 4, AccessToken: "at-opaque"}) { t.Fatal("codex_at account should be skipped") } + claude := &auth.Account{DBID: 5, AccessToken: "claude-token", RefreshToken: "claude-refresh", UpstreamType: auth.UpstreamClaude} + if whamDailyUsageBackfillEligible(claude) { + t.Fatal("Claude account must not use the ChatGPT WHAM daily usage endpoint") + } } func TestWhamDailyUsageDueTargetsPrunesRemovedAccounts(t *testing.T) { diff --git a/api/README.md b/api/README.md index 795c9d97f..a003835d7 100644 --- a/api/README.md +++ b/api/README.md @@ -106,6 +106,16 @@ Rate limits are returned in response headers: | `/api/admin/accounts/:id/refresh` | POST | 手动刷新 AT | | `/api/admin/accounts/:id/test` | GET | 测试账号连接 | | `/api/admin/accounts/:id/usage` | GET | 查看账号用量 | +| `/api/admin/accounts/claude/oauth/auth-url` | POST | 生成 Claude OAuth PKCE 授权 URL | +| `/api/admin/accounts/claude/oauth/exchange-code` | POST | 兑换 Claude OAuth code 并入库 | +| `/api/admin/accounts/claude/import` | POST | 导入 Claude Token JSON / 对象数组 / `accounts` bundle | +| `/api/admin/accounts/claude/export` | GET | 导出完整 Claude OAuth 凭据(单 JSON / 多账号 ZIP) | +| `/api/admin/accounts/:id/claude/models` | POST | 刷新单个 Claude 上游模型目录 | +| `/api/admin/accounts/claude/models/refresh` | POST | 批量刷新 Claude 模型目录 | +| `/api/admin/accounts/:id/models/sync-upstream` | POST | 只读预览账号上游模型目录 | +| `/api/admin/accounts/:id/models` | PATCH | 设置账号级 Claude `claude-*` 模型白名单 | +| `/api/admin/accounts/:id/usage/refresh` | POST | 执行 Claude 原生用量采样 | +| `/api/admin/accounts/:id/models/probe` | POST | 只读探测 Claude 模型能力 | | `/api/admin/accounts/batch-test` | POST | 批量测试连接(SSE) | | `/api/admin/accounts/export` | GET | 导出账号 | | `/api/admin/accounts/migrate` | POST | 从远程实例迁移账号(SSE) | @@ -114,6 +124,11 @@ Rate limits are returned in response headers: | `/api/admin/accounts/clean-rate-limited` | POST | 清理 429 账号 | | `/api/admin/accounts/clean-error` | POST | 清理错误账号 | +Claude 凭据导出支持 `ids`、`filter=all|healthy` 和 `format=auto|json|zip`;返回内容含 +OAuth token,只有管理员可访问,客户端应按 `Cache-Control: no-store` 处理并在迁移完成后 +安全删除下载文件。导入端接受单对象、对象数组或 `{"accounts":[...]}`,分组按名称和 +channel 映射,不使用另一实例的数字分组 ID。 + **OAuth 授权:** | Endpoint | Method | Description | @@ -140,6 +155,7 @@ Rate limits are returned in response headers: | `/api/admin/settings` | PUT | 更新系统设置 | | `/api/admin/models` | GET | 获取支持模型列表 | | `/api/admin/models/sync` | POST | 从 OpenAI 官方 Codex 模型页同步模型注册表 | +| `/api/admin/settings/claude-config` | GET/PUT | Claude 指纹、时区和会话窗口默认配置 | **用量统计:** diff --git a/auth/claude_account.go b/auth/claude_account.go new file mode 100644 index 000000000..5cf39db1e --- /dev/null +++ b/auth/claude_account.go @@ -0,0 +1,153 @@ +package auth + +// Claude Code(Anthropic)账号在账号池中的运行时接线。 +// +// 设计原则:尽量复用现有通用 OAuth 加载/调度框架,只新增 Claude 独有的部分。 +// - 加载:带 access_token + refresh_token 的 Claude 账号(upstream_type=claude) +// 直接走 buildAccountFromRow 的通用分支,无需改动那段 CRITICAL 代码。 +// - 刷新:Claude 的 RT 刷新端点与请求体和 ChatGPT/Codex 不同,故在 +// refreshAccountWithOptions 顶部按 IsClaudeOAuth() 早返回到这里的专用流程。 + +import ( + "context" + "fmt" + "strings" + "sync/atomic" + "time" +) + +// UpstreamClaude 是 Claude Code OAuth 账号的 upstream_type 判别值。 +const UpstreamClaude = "claude" + +// ClaudeUsageProbeAtCredentialKey and ClaudeUsageProbeErrorCredentialKey are +// non-sensitive control-plane fields used by the admin account list to show +// whether an imported Claude account has completed its first native sampling +// request. They deliberately live alongside credentials so the existing +// SQLite/PostgreSQL projection remains backward compatible. +const ( + ClaudeUsageProbeAtCredentialKey = "claude_usage_probe_at" + ClaudeUsageProbeErrorCredentialKey = "claude_usage_probe_error" +) + +// isClaudeOAuthLocked 判断账号是否为 Claude Code OAuth 账号。调用方需持有 a.mu。 +func (a *Account) isClaudeOAuthLocked() bool { + return strings.EqualFold(strings.TrimSpace(a.UpstreamType), UpstreamClaude) +} + +// IsClaudeOAuth 判断账号是否为 Claude Code OAuth 账号。 +func (a *Account) IsClaudeOAuth() bool { + if a == nil { + return false + } + a.mu.RLock() + defer a.mu.RUnlock() + return a.isClaudeOAuthLocked() +} + +// refreshClaudeAccount 刷新一个 Claude Code OAuth 账号的 access token。 +// +// 与 Grok/Codex 相比刷新逻辑刻意从简(自用场景账号数不多):复用跨实例共享的 +// OAuth 刷新租约避免并发抢刷 + RT 轮换竞争,拿到新 token 后原子合并落库并更新 +// 内存态与调度器。 +func (s *Store) refreshClaudeAccount(ctx context.Context, acc *Account, forceRefresh bool) error { + acc.mu.RLock() + rt := strings.TrimSpace(acc.RefreshToken) + dbID := acc.DBID + proxyURL := strings.TrimSpace(acc.ProxyURL) + lockedAccessToken := acc.AccessToken + cooldownActive := acc.Status == StatusCooldown && time.Now().Before(acc.CooldownUtil) + acc.mu.RUnlock() + + if rt == "" { + return fmt.Errorf("claude refresh_token 为空") + } + + // 跨实例共享刷新租约:等待期间别的实例可能已经轮换过 RT,拿到锁后重新读库, + // 若已被刷新且可用则直接复用,避免第二次刷新消费掉刚轮换出来的新 RT。 + lease, lockErr := s.acquireOAuthRefreshLease(ctx, rt) + if lockErr != nil { + return lockErr + } + defer lease.Release() + ctx = lease.Context() + + if changed, usable, reloadErr := s.reloadOAuthCredentialsAfterLock(ctx, acc, rt, lockedAccessToken); reloadErr != nil { + // 读库失败不阻断刷新,继续用入口快照的 rt 尝试。 + } else if changed && usable && !forceRefresh { + s.finishReloadedOAuthRefresh(ctx, acc) + return nil + } else if changed { + acc.mu.RLock() + rt = strings.TrimSpace(acc.RefreshToken) + acc.mu.RUnlock() + if rt == "" { + return fmt.Errorf("claude refresh_token 为空") + } + } + + client := NewClaudeAuth(proxyURL) + td, err := client.RefreshTokens(ctx, rt) + if err != nil { + return fmt.Errorf("claude token 刷新失败: %w", err) + } + if strings.TrimSpace(td.AccessToken) == "" { + return fmt.Errorf("claude 刷新响应缺少 access_token") + } + + // 原子合并落库(JSONB ||,不覆盖其他字段)。 + updates := map[string]interface{}{ + "access_token": td.AccessToken, + "expires_at": td.ExpiresAt.Format(time.RFC3339), + } + if strings.TrimSpace(td.RefreshToken) != "" { + updates["refresh_token"] = td.RefreshToken + } + if strings.TrimSpace(td.Email) != "" { + updates["email"] = td.Email + } + // 订阅档位随 profile 变化(升降级)时同步更新。 + if plan := strings.TrimSpace(td.PlanType); plan != "" { + updates["plan_type"] = plan + } + if strings.TrimSpace(td.AccountUUID) != "" { + updates["account_id"] = td.AccountUUID + } + if s.db != nil { + if err := s.db.UpdateCredentials(ctx, dbID, updates); err != nil { + return fmt.Errorf("claude 刷新结果落库失败: %w", err) + } + } + + // 更新内存态与调度器。冷却中的账号保留冷却状态,仅刷新令牌。 + acc.mu.Lock() + acc.AccessToken = td.AccessToken + if strings.TrimSpace(td.RefreshToken) != "" { + acc.RefreshToken = td.RefreshToken + } + acc.ExpiresAt = td.ExpiresAt + if strings.TrimSpace(td.Email) != "" { + acc.Email = td.Email + } + if strings.TrimSpace(td.AccountUUID) != "" { + acc.AccountID = td.AccountUUID + } + if plan := strings.TrimSpace(td.PlanType); plan != "" { + acc.PlanType = plan + } + if !cooldownActive { + acc.Status = StatusReady + acc.CooldownUtil = time.Time{} + acc.CooldownReason = "" + } + if acc.Status != StatusError { + acc.HealthTier = HealthTierHealthy + } + acc.recomputeSchedulerLocked(atomic.LoadInt64(&s.maxConcurrency)) + acc.mu.Unlock() + + s.fastSchedulerUpdate(acc) + if !cooldownActive && s.db != nil { + _ = s.db.ClearError(ctx, dbID) + } + return nil +} diff --git a/auth/claude_fingerprint.go b/auth/claude_fingerprint.go new file mode 100644 index 000000000..23383e2c3 --- /dev/null +++ b/auth/claude_fingerprint.go @@ -0,0 +1,126 @@ +package auth + +// Claude Code 客户端指纹。 +// +// 目的:让每个 Claude 账号对外呈现一套**稳定且各不相同**的真实 Claude Code CLI +// 身份(UA / x-app / x-stainless-*),对抗 Anthropic 的一致性风控——最容易被标记的 +// 不是某个具体值,而是"同一账号身份忽变"。指纹在导入账号时生成一次并持久化到 +// credentials.custom_headers,之后每次上游请求原样套用。 +// +// 值域取自真实 Claude Code / @anthropic-ai SDK 在链路上出现过的组合,随机挑选但一旦 +// 落库即固定。真实 Claude Code 客户端直连时,其自带的这些头会被优先保留(见 +// proxy 层 applyClaudeMessagesHeaders),仅在缺失时才用这里合成的指纹补齐。 + +import ( + "crypto/rand" + "math/big" + "strings" + "time" +) + +// 真实取值池(保持精简、贴近近期版本)。 +var ( + claudeCLIVersions = []string{"2.1.220", "2.1.219", "2.1.205", "2.0.14"} + claudeSDKVersions = []string{"0.68.0", "0.65.0", "0.63.1", "0.60.0"} + claudeNodeRuntime = []string{"v22.14.0", "v22.11.0", "v20.18.1", "v20.17.0"} + claudeStainlessOS = []string{"MacOS", "Linux", "Windows"} + claudeArchByOS = map[string][]string{ + "MacOS": {"arm64", "x64"}, + "Linux": {"x64", "arm64"}, + "Windows": {"x64"}, + } +) + +// ClaudeFingerprint 是一套稳定的 Claude Code CLI 身份。 +type ClaudeFingerprint struct { + UserAgent string `json:"user_agent"` + XApp string `json:"x_app"` + StainlessLang string `json:"x_stainless_lang"` + StainlessPackageVersion string `json:"x_stainless_package_version"` + StainlessOS string `json:"x_stainless_os"` + StainlessArch string `json:"x_stainless_arch"` + StainlessRuntime string `json:"x_stainless_runtime"` + StainlessRuntimeVersion string `json:"x_stainless_runtime_version"` + // Timezone 是账号绑定的 IANA 时区(如 Asia/Shanghai),用于身份一致性; + // 空表示不指定。 + Timezone string `json:"timezone,omitempty"` +} + +func claudePick(pool []string) string { + if len(pool) == 0 { + return "" + } + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(pool)))) + if err != nil { + return pool[0] + } + return pool[n.Int64()] +} + +// GenerateClaudeFingerprint 生成一套稳定指纹。timezone 为空时不设置(留给调用方决定 +// 是否用全局默认)。非空时会校验为合法 IANA 时区,非法则丢弃。 +func GenerateClaudeFingerprint(timezone string) ClaudeFingerprint { + cliVer := claudePick(claudeCLIVersions) + os := claudePick(claudeStainlessOS) + arch := claudePick(claudeArchByOS[os]) + fp := ClaudeFingerprint{ + UserAgent: "claude-cli/" + cliVer + " (external, cli)", + XApp: "cli", + StainlessLang: "js", + StainlessPackageVersion: claudePick(claudeSDKVersions), + StainlessOS: os, + StainlessArch: arch, + StainlessRuntime: "node", + StainlessRuntimeVersion: claudePick(claudeNodeRuntime), + } + if tz := strings.TrimSpace(timezone); tz != "" { + if _, err := time.LoadLocation(tz); err == nil { + fp.Timezone = tz + } + } + return fp +} + +// Headers 返回该指纹对应的请求头(键为规范化的头名)。仅返回 x-stainless / x-app / +// user-agent 这类身份头;Authorization / anthropic-* 由调用方另行设置。 +func (f ClaudeFingerprint) Headers() map[string]string { + h := map[string]string{} + if f.UserAgent != "" { + h["User-Agent"] = f.UserAgent + } + if f.XApp != "" { + h["X-App"] = f.XApp + } + if f.StainlessLang != "" { + h["X-Stainless-Lang"] = f.StainlessLang + } + if f.StainlessPackageVersion != "" { + h["X-Stainless-Package-Version"] = f.StainlessPackageVersion + } + if f.StainlessOS != "" { + h["X-Stainless-OS"] = f.StainlessOS + } + if f.StainlessArch != "" { + h["X-Stainless-Arch"] = f.StainlessArch + } + if f.StainlessRuntime != "" { + h["X-Stainless-Runtime"] = f.StainlessRuntime + } + if f.StainlessRuntimeVersion != "" { + h["X-Stainless-Runtime-Version"] = f.StainlessRuntimeVersion + } + return h +} + +// ClaudeIdentityHeaderNames 是"客户端身份"类头名(小写),用于在透传时判断入站真实 +// 客户端是否已自带身份、以及需要用指纹补齐哪些。 +var ClaudeIdentityHeaderNames = []string{ + "user-agent", + "x-app", + "x-stainless-lang", + "x-stainless-package-version", + "x-stainless-os", + "x-stainless-arch", + "x-stainless-runtime", + "x-stainless-runtime-version", +} diff --git a/auth/claude_fingerprint_mode.go b/auth/claude_fingerprint_mode.go new file mode 100644 index 000000000..1e7abfa26 --- /dev/null +++ b/auth/claude_fingerprint_mode.go @@ -0,0 +1,247 @@ +package auth + +import ( + "encoding/json" + "strings" + "sync/atomic" +) + +// Claude Code 出站请求的指纹收敛模式(账号级;空值 = 跟随全局默认): +// +// preserve — 入站真实客户端身份头优先,缺失才用账号绑定指纹补齐(历史默认行为)。 +// force — 无条件用账号绑定指纹覆盖入站身份头(强制替换,保证同一账号 +// 对 Anthropic 始终呈现同一套 Claude Code 身份)。 +const ( + ClaudeFingerprintModePreserve = "preserve" + ClaudeFingerprintModeForce = "force" +) + +// ClaudeFingerprintModeCredentialKey 是该模式在账号 credentials 中的存储键。 +const ClaudeFingerprintModeCredentialKey = "claude_fingerprint_mode" + +// ClaudeSecurityConfig 是 ClaudeCode 出站请求的安全边界。 +// 布尔字段默认 false(默认过滤敏感字段);数值字段为 0 时表示不设置 +// Codex2API 应用层上限,仍受请求体、整数和 Anthropic 上游能力约束。 +// AllowedBetaHeaders 只允许额外的 Beta token,OAuth 必需 token 由 proxy 始终注入。 +type ClaudeSecurityConfig struct { + AllowServiceTier bool `json:"allow_service_tier"` + AllowInferenceGeo bool `json:"allow_inference_geo"` + AllowSpeed bool `json:"allow_speed"` + AllowSafetyIdentifier bool `json:"allow_safety_identifier"` + AllowedBetaHeaders []string `json:"allowed_beta_headers"` + MaxOutputTokens int64 `json:"max_output_tokens"` + MaxToolCount int `json:"max_tool_count"` + MaxToolSchemaBytes int64 `json:"max_tool_schema_bytes"` +} + +// DefaultClaudeSecurityConfig returns compatibility-safe defaults used when an +// older installation has no Claude resource-limit fields persisted yet. +func DefaultClaudeSecurityConfig() ClaudeSecurityConfig { + return ClaudeSecurityConfig{} +} + +func validClaudeBetaToken(value string) bool { + if value == "" || len(value) > 128 { + return false + } + for i, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || (i > 0 && strings.ContainsRune("._-", r)) { + continue + } + return false + } + return true +} + +// NormalizeClaudeSecurityConfig canonicalizes operator-provided values and +// keeps zero as the explicit "no application cap" sentinel. Negative values +// are never meaningful and normalize to that same sentinel. Integer and body +// size guards remain enforced at the request boundary. +func NormalizeClaudeSecurityConfig(cfg ClaudeSecurityConfig) ClaudeSecurityConfig { + if cfg.MaxOutputTokens < 0 { + cfg.MaxOutputTokens = 0 + } + if cfg.MaxToolCount < 0 { + cfg.MaxToolCount = 0 + } + if cfg.MaxToolSchemaBytes < 0 { + cfg.MaxToolSchemaBytes = 0 + } + allowed := make([]string, 0, len(cfg.AllowedBetaHeaders)) + seen := make(map[string]struct{}, len(cfg.AllowedBetaHeaders)) + for _, raw := range cfg.AllowedBetaHeaders { + token := strings.ToLower(strings.TrimSpace(raw)) + if !validClaudeBetaToken(token) { + continue + } + if _, exists := seen[token]; exists { + continue + } + seen[token] = struct{}{} + allowed = append(allowed, token) + } + cfg.AllowedBetaHeaders = allowed + return cfg +} + +// NormalizeClaudeFingerprintMode 归一化模式取值;空/非法值归一为空串(跟随全局)。 +func NormalizeClaudeFingerprintMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case ClaudeFingerprintModePreserve: + return ClaudeFingerprintModePreserve + case ClaudeFingerprintModeForce: + return ClaudeFingerprintModeForce + } + return "" +} + +// IsValidClaudeFingerprintMode 报告取值是否合法(空串=跟随全局,亦视为合法)。 +func IsValidClaudeFingerprintMode(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", ClaudeFingerprintModePreserve, ClaudeFingerprintModeForce: + return true + } + return false +} + +// EffectiveClaudeFingerprintMode 返回账号生效模式:账号级覆盖 > 全局默认 > preserve。 +func (a *Account) EffectiveClaudeFingerprintMode(globalDefault string) string { + if a != nil { + a.mu.RLock() + mode := a.ClaudeFingerprintMode + a.mu.RUnlock() + if m := NormalizeClaudeFingerprintMode(mode); m != "" { + return m + } + } + if m := NormalizeClaudeFingerprintMode(globalDefault); m != "" { + return m + } + return ClaudeFingerprintModePreserve +} + +// ── Claude 全局配置访问器(来自系统设置 claude_config,ApplySystemSettings 注入) ── + +// SetClaudeFingerprintModeDefault 设置 Claude 指纹模式全局默认。 +func (s *Store) SetClaudeFingerprintModeDefault(mode string) { + s.claudeFingerprintDefault.Store(NormalizeClaudeFingerprintMode(mode)) +} + +// ClaudeFingerprintModeDefault 返回 Claude 指纹模式全局默认(空=preserve)。 +func (s *Store) ClaudeFingerprintModeDefault() string { + if v, ok := s.claudeFingerprintDefault.Load().(string); ok { + return v + } + return "" +} + +// SetClaudeDefaultTimezone 设置导入 Claude 账号的默认时区。 +func (s *Store) SetClaudeDefaultTimezone(tz string) { + s.claudeDefaultTimezone.Store(strings.TrimSpace(tz)) +} + +// ClaudeDefaultTimezone 返回导入 Claude 账号的默认时区(空=不指定)。 +func (s *Store) ClaudeDefaultTimezone() string { + if v, ok := s.claudeDefaultTimezone.Load().(string); ok { + return v + } + return "" +} + +// SetClaudeSecurityConfig publishes an immutable copy of the Claude egress +// policy to request handlers without taking a lock on the first-token path. +func (s *Store) SetClaudeSecurityConfig(cfg ClaudeSecurityConfig) { + if s == nil { + return + } + cfg = NormalizeClaudeSecurityConfig(cfg) + cfg.AllowedBetaHeaders = append([]string(nil), cfg.AllowedBetaHeaders...) + s.claudeSecurityConfig.Store(cfg) +} + +// ClaudeSecurityConfig returns the current Claude egress policy. A missing +// legacy setting is treated as the secure default configuration. +func (s *Store) ClaudeSecurityConfig() ClaudeSecurityConfig { + if s == nil { + return DefaultClaudeSecurityConfig() + } + if value, ok := s.claudeSecurityConfig.Load().(ClaudeSecurityConfig); ok { + value.AllowedBetaHeaders = append([]string(nil), value.AllowedBetaHeaders...) + return NormalizeClaudeSecurityConfig(value) + } + return DefaultClaudeSecurityConfig() +} + +// SetClaudeSessionWindowLimit 设置 Claude 账号默认并发会话窗口数(<=0 归 0=跟随全局)。 +func (s *Store) SetClaudeSessionWindowLimit(n int64) { + if n < 0 { + n = 0 + } + atomic.StoreInt64(&s.claudeSessionWindowLimit, n) +} + +// ClaudeSessionWindowLimit 返回 Claude 账号默认并发会话窗口数(0=跟随全局 maxConcurrency)。 +func (s *Store) ClaudeSessionWindowLimit() int64 { + return atomic.LoadInt64(&s.claudeSessionWindowLimit) +} + +// ApplyAccountClaudeFingerprintMode 更新内存态账号的 Claude 指纹模式。 +func (s *Store) ApplyAccountClaudeFingerprintMode(dbID int64, mode string) bool { + acc := s.FindByID(dbID) + if acc == nil { + return false + } + acc.mu.Lock() + acc.ClaudeFingerprintMode = NormalizeClaudeFingerprintMode(mode) + acc.mu.Unlock() + return true +} + +// claudeSessionWindowForRow 仅对 Claude 账号返回全局并发会话窗口默认;其它渠道返回 0。 +func claudeSessionWindowForRow(upstreamType string, globalWindow int64) int64 { + if globalWindow > 0 && strings.EqualFold(strings.TrimSpace(upstreamType), UpstreamClaude) { + return globalWindow + } + return 0 +} + +// ClaudeConfig 是 ClaudeCode 全局配置(系统设置 claude_config 列反序列化目标)。 +// 全体 Claude 账号默认遵守;个体账号可通过编辑覆盖。 +type ClaudeConfig struct { + FingerprintMode string `json:"fingerprint_mode"` // preserve / force(空=preserve) + DefaultTimezone string `json:"default_timezone"` // 导入账号默认 IANA 时区 + SessionWindowLimit int64 `json:"session_window_limit"` // 默认并发会话窗口数(0=跟随全局 maxConcurrency) + ClaudeSecurityConfig +} + +// SecurityConfig extracts the flattened Claude security fields from the +// persisted system setting while keeping the legacy top-level fields intact. +func (c ClaudeConfig) SecurityConfig() ClaudeSecurityConfig { + return NormalizeClaudeSecurityConfig(c.ClaudeSecurityConfig) +} + +// ParseClaudeConfig 解析 claude_config JSON;空/非法回落到零值(即全部默认)。 +func ParseClaudeConfig(raw string) ClaudeConfig { + var cfg ClaudeConfig + raw = strings.TrimSpace(raw) + if raw == "" || raw == "{}" { + return cfg + } + _ = json.Unmarshal([]byte(raw), &cfg) + cfg.FingerprintMode = NormalizeClaudeFingerprintMode(cfg.FingerprintMode) + cfg.DefaultTimezone = strings.TrimSpace(cfg.DefaultTimezone) + if cfg.SessionWindowLimit < 0 { + cfg.SessionWindowLimit = 0 + } + cfg.ClaudeSecurityConfig = NormalizeClaudeSecurityConfig(cfg.ClaudeSecurityConfig) + return cfg +} + +// applyClaudeConfigToStore 把解析后的 ClaudeCode 全局配置写入 Store 的运行时访问器。 +func applyClaudeConfigToStore(s *Store, raw string) { + cfg := ParseClaudeConfig(raw) + s.SetClaudeFingerprintModeDefault(cfg.FingerprintMode) + s.SetClaudeDefaultTimezone(cfg.DefaultTimezone) + s.SetClaudeSessionWindowLimit(cfg.SessionWindowLimit) + s.SetClaudeSecurityConfig(cfg.SecurityConfig()) +} diff --git a/auth/claude_fingerprint_test.go b/auth/claude_fingerprint_test.go new file mode 100644 index 000000000..28b017d1b --- /dev/null +++ b/auth/claude_fingerprint_test.go @@ -0,0 +1,58 @@ +package auth + +import ( + "strings" + "testing" +) + +func TestGenerateClaudeFingerprint_Fields(t *testing.T) { + fp := GenerateClaudeFingerprint("") + if !strings.HasPrefix(fp.UserAgent, "claude-cli/") || !strings.Contains(fp.UserAgent, "(external, cli)") { + t.Fatalf("UA 不像 Claude Code CLI: %s", fp.UserAgent) + } + if fp.XApp != "cli" { + t.Errorf("x-app 应为 cli, got %s", fp.XApp) + } + if fp.StainlessLang != "js" || fp.StainlessRuntime != "node" { + t.Errorf("stainless lang/runtime 不符: %s/%s", fp.StainlessLang, fp.StainlessRuntime) + } + if fp.StainlessOS == "" || fp.StainlessArch == "" || fp.StainlessRuntimeVersion == "" || fp.StainlessPackageVersion == "" { + t.Error("stainless os/arch/runtime-version/package-version 不应为空") + } +} + +func TestGenerateClaudeFingerprint_TimezoneValidation(t *testing.T) { + if fp := GenerateClaudeFingerprint("Asia/Shanghai"); fp.Timezone != "Asia/Shanghai" { + t.Errorf("合法时区应保留, got %q", fp.Timezone) + } + if fp := GenerateClaudeFingerprint("Not/A_Zone"); fp.Timezone != "" { + t.Errorf("非法时区应丢弃, got %q", fp.Timezone) + } +} + +func TestClaudeFingerprintHeaders(t *testing.T) { + fp := GenerateClaudeFingerprint("") + h := fp.Headers() + for _, k := range []string{"User-Agent", "X-App", "X-Stainless-Lang", "X-Stainless-OS", "X-Stainless-Arch", "X-Stainless-Runtime", "X-Stainless-Runtime-Version", "X-Stainless-Package-Version"} { + if strings.TrimSpace(h[k]) == "" { + t.Errorf("Headers() 缺少 %s", k) + } + } +} + +func TestGenerateClaudeFingerprint_ArchMatchesOS(t *testing.T) { + // Windows 只应出现 x64(池约束)。多次抽样验证不越界。 + for i := 0; i < 30; i++ { + fp := GenerateClaudeFingerprint("") + valid := claudeArchByOS[fp.StainlessOS] + found := false + for _, a := range valid { + if a == fp.StainlessArch { + found = true + } + } + if !found { + t.Fatalf("os=%s 的 arch=%s 不在允许集 %v", fp.StainlessOS, fp.StainlessArch, valid) + } + } +} diff --git a/auth/claude_oauth.go b/auth/claude_oauth.go new file mode 100644 index 000000000..0d672de8d --- /dev/null +++ b/auth/claude_oauth.go @@ -0,0 +1,611 @@ +package auth + +// Claude Code(Anthropic)OAuth 登录模块。 +// +// 本文件把 Claude Code 官方客户端的 OAuth2 + PKCE 登录流程移植进账号池,使得 +// 平台可以像管理 Codex / Grok / Antigravity 账号一样,纳管多个 Claude Pro/Max +// 订阅账号并统一调度。参数对齐 Claude Code 官方客户端(client_id / 端点 / scope / +// 强制 beta 头),逆向常量参考 CLIProxyAPI(router-for-me/CLIProxyAPI)。 +// +// 使用方式(服务器无本地回调场景,采用手动粘贴授权码): +// 1. StartClaudeLogin() 生成 AuthURL + State + Verifier,把 AuthURL 交给用户在 +// 浏览器打开授权;State/Verifier 由调用方短期缓存。 +// 2. 用户授权后浏览器跳转到 RedirectURI?code=...#state,用户复制 code 粘回后台。 +// 3. ExchangeCode() 用 code + Verifier 换取 access/refresh token 并回填账号身份。 +// 4. RefreshTokens() 在 access token 临期时用 refresh token 续期。 + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "compress/zlib" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/andybalholm/brotli" +) + +// Claude OAuth 配置常量。对齐 Claude Code 官方客户端在链路上的取值。 +const ( + // ClaudeOAuthClientID 是 Claude Code 官方客户端的公开 OAuth client_id。 + ClaudeOAuthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + // ClaudeOAuthAuthURL 是授权页地址(用户浏览器打开处)。 + ClaudeOAuthAuthURL = "https://claude.ai/oauth/authorize" + // ClaudeOAuthTokenURL 同时用于授权码交换与刷新(Claude Code 走 platform.claude.com)。 + ClaudeOAuthTokenURL = "https://platform.claude.com/v1/oauth/token" + // ClaudeOAuthProfileURL 用 access token 换取账号身份(email / uuid / 组织)。 + ClaudeOAuthProfileURL = "https://api.anthropic.com/api/oauth/profile" + // ClaudeOAuthRedirectURI 是官方客户端使用的本地回调地址;服务器场景下仅用于 + // 拼装授权 URL,用户从跳转后的地址栏复制授权码即可,无需本机监听。 + ClaudeOAuthRedirectURI = "http://localhost:54545/callback" + // ClaudeOAuthScope 是 Claude Code 请求的权限范围,必须与官方一致。 + ClaudeOAuthScope = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload" + // ClaudeOAuthBeta 是 OAuth 凭据调用推理接口时必须声明的 anthropic-beta 值。 + ClaudeOAuthBeta = "oauth-2025-04-20" + + claudeOAuthHTTPTimeout = 30 * time.Second +) + +// ClaudePKCECodes 保存一对 PKCE 校验码(RFC 7636,S256)。 +type ClaudePKCECodes struct { + CodeVerifier string + CodeChallenge string +} + +// ClaudeLoginSession 是一次登录发起后需要短期保存的上下文。ExchangeCode 时回传。 +type ClaudeLoginSession struct { + AuthURL string `json:"auth_url"` + State string `json:"state"` + Verifier string `json:"verifier"` +} + +// ClaudeTokenData 是登录/刷新后得到的令牌与账号身份。 +type ClaudeTokenData struct { + AccessToken string + RefreshToken string + Email string + AccountUUID string + OrganizationUUID string + OrganizationName string + // PlanType 是由 profile 推导的订阅档位(pro / max-5x / max-20x / team / …)。 + PlanType string + // ExpiresAt 是本次 access token 的过期时刻(本地时钟)。 + ExpiresAt time.Time +} + +// claudeTokenResponse 映射 Anthropic OAuth token 端点的响应体。 +type claudeTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Organization struct { + UUID string `json:"uuid"` + Name string `json:"name"` + } `json:"organization"` + Account struct { + UUID string `json:"uuid"` + EmailAddress string `json:"email_address"` + } `json:"account"` +} + +// claudeOAuthProfile 映射 profile 端点的响应体。 +type claudeOAuthProfile struct { + Account struct { + UUID string `json:"uuid"` + Email string `json:"email"` + HasClaudeMax bool `json:"has_claude_max"` + HasClaudePro bool `json:"has_claude_pro"` + } `json:"account"` + Organization struct { + UUID string `json:"uuid"` + Name string `json:"name"` + // OrganizationType 是订阅档位判定主键(实测 2026-08): + // claude_pro / claude_max / claude_team / claude_enterprise / claude_free。 + OrganizationType string `json:"organization_type"` + // RateLimitTier 区分 Max 档倍率(如含 "5x" / "20x")。 + RateLimitTier string `json:"rate_limit_tier"` + } `json:"organization"` +} + +// DeriveClaudePlanType 由 profile 推导展示用套餐档位: +// pro / max-5x / max-20x / max / team / enterprise / free;无法判定时回退 "claude"。 +func DeriveClaudePlanType(p *claudeOAuthProfile) string { + if p == nil { + return "claude" + } + orgType := strings.ToLower(strings.TrimSpace(p.Organization.OrganizationType)) + tier := strings.ToLower(strings.TrimSpace(p.Organization.RateLimitTier)) + switch { + case strings.Contains(orgType, "max") || p.Account.HasClaudeMax: + if strings.Contains(tier, "20x") { + return "max-20x" + } + if strings.Contains(tier, "5x") { + return "max-5x" + } + return "max" + case strings.Contains(orgType, "enterprise"): + return "enterprise" + case strings.Contains(orgType, "team"): + return "team" + case strings.Contains(orgType, "pro") || p.Account.HasClaudePro: + return "pro" + case strings.Contains(orgType, "free"): + return "free" + } + return "claude" +} + +// claudeAuthCodeExchangeRequest 是授权码交换请求体。字段顺序刻意对齐官方客户端在 +// 链路上的键序(map 会被 encoding/json 按字母重排,可能触发风控),故用结构体固定。 +type claudeAuthCodeExchangeRequest struct { + GrantType string `json:"grant_type"` + Code string `json:"code"` + RedirectURI string `json:"redirect_uri"` + ClientID string `json:"client_id"` + CodeVerifier string `json:"code_verifier"` + State string `json:"state"` +} + +// ClaudeAuth 封装 Claude OAuth 登录/刷新所需的 HTTP 客户端。 +// +// 采用主/备双客户端 + 自动回退: +// - primary:uTLS 浏览器指纹客户端,规避 Anthropic 域名上的 Cloudflare 指纹拦截; +// - fallback:标准 http 客户端(ALPN 自动协商 h1/h2,兼容性更好)。 +// 当 primary 出现传输错误或被判定为挑战(403)时,自动改用 fallback 重试。这样无论 +// 拦截来自指纹、强制 h2 还是网络层,都能提高登录/刷新成功率。 +type ClaudeAuth struct { + primary *http.Client + fallback *http.Client +} + +// NewClaudeAuth 创建一个 Claude OAuth 客户端。proxyURL 为空时走直连。 +func NewClaudeAuth(proxyURL string) *ClaudeAuth { + proxyURL = strings.TrimSpace(proxyURL) + primary := buildUTLSHTTPClient(proxyURL) + if primary == nil { + primary = buildPlainClaudeOAuthClient(proxyURL) + } else if primary.Timeout == 0 { + primary.Timeout = claudeOAuthHTTPTimeout + } + return &ClaudeAuth{primary: primary, fallback: buildPlainClaudeOAuthClient(proxyURL)} +} + +// buildPlainClaudeOAuthClient 构建标准(非 uTLS)代理感知 HTTP 客户端,用作回退。 +func buildPlainClaudeOAuthClient(proxyURL string) *http.Client { + tr := http.DefaultTransport.(*http.Transport).Clone() + if strings.TrimSpace(proxyURL) != "" { + _ = ConfigureTransportProxy(tr, proxyURL, nil) + } + return &http.Client{Transport: tr, Timeout: claudeOAuthHTTPTimeout} +} + +// doWithFallback 用 primary 发送请求;传输错误或 403 挑战时,用 fallback 以全新请求 +// 重试。bodyBytes 为请求体(GET 传 nil);decorate 用于附加 Authorization 等额外头。 +func (o *ClaudeAuth) doWithFallback(ctx context.Context, method, url string, bodyBytes []byte, decorate func(*http.Request)) (*http.Response, error) { + build := func() (*http.Request, error) { + var body io.Reader + if bodyBytes != nil { + body = bytes.NewReader(bodyBytes) + } + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, err + } + applyClaudeOAuthAxiosHeaders(req) + if decorate != nil { + decorate(req) + } + return req, nil + } + + req, err := build() + if err != nil { + return nil, err + } + resp, err := o.primary.Do(req) + if err == nil && resp.StatusCode != http.StatusForbidden { + return resp, nil + } + // primary 传输失败或被 403 挑战 → 用标准客户端重试。 + if resp != nil { + _ = resp.Body.Close() + } + retryReq, buildErr := build() + if buildErr != nil { + if err != nil { + return nil, err + } + return nil, buildErr + } + return o.fallback.Do(retryReq) +} + +// GenerateClaudePKCE 生成一对 PKCE 校验码(S256)。 +func GenerateClaudePKCE() (*ClaudePKCECodes, error) { + verifierBytes := make([]byte, 96) + if _, err := rand.Read(verifierBytes); err != nil { + return nil, fmt.Errorf("生成 PKCE verifier 失败: %w", err) + } + verifier := base64.RawURLEncoding.EncodeToString(verifierBytes) + sum := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(sum[:]) + return &ClaudePKCECodes{CodeVerifier: verifier, CodeChallenge: challenge}, nil +} + +// generateClaudeOAuthState 生成用于防 CSRF 的随机 state。 +func generateClaudeOAuthState() (string, error) { + stateBytes := make([]byte, 32) + if _, err := rand.Read(stateBytes); err != nil { + return "", fmt.Errorf("生成 OAuth state 失败: %w", err) + } + return base64.RawURLEncoding.EncodeToString(stateBytes), nil +} + +// BuildAuthURL 用给定 state 与 PKCE 拼装授权 URL。 +func BuildClaudeAuthURL(state string, pkce *ClaudePKCECodes) (string, error) { + if pkce == nil { + return "", fmt.Errorf("缺少 PKCE 校验码") + } + params := url.Values{ + "code": {"true"}, + "client_id": {ClaudeOAuthClientID}, + "response_type": {"code"}, + "redirect_uri": {ClaudeOAuthRedirectURI}, + "scope": {ClaudeOAuthScope}, + "code_challenge": {pkce.CodeChallenge}, + "code_challenge_method": {"S256"}, + "state": {state}, + } + return ClaudeOAuthAuthURL + "?" + params.Encode(), nil +} + +// StartClaudeLogin 发起一次登录:生成 state + PKCE 并返回授权 URL 与需缓存的上下文。 +func StartClaudeLogin() (*ClaudeLoginSession, error) { + pkce, err := GenerateClaudePKCE() + if err != nil { + return nil, err + } + state, err := generateClaudeOAuthState() + if err != nil { + return nil, err + } + authURL, err := BuildClaudeAuthURL(state, pkce) + if err != nil { + return nil, err + } + return &ClaudeLoginSession{AuthURL: authURL, State: state, Verifier: pkce.CodeVerifier}, nil +} + +// parseClaudeCodeAndState 从回调里拿到的 code 中拆出可能附带的 state 片段 +// (官方回调形如 code#state)。 +func parseClaudeCodeAndState(code string) (parsedCode, parsedState string) { + splits := strings.Split(strings.TrimSpace(code), "#") + parsedCode = strings.TrimSpace(splits[0]) + if len(splits) > 1 { + parsedState = strings.TrimSpace(splits[1]) + } + return +} + +// ExchangeCode 用授权码 + PKCE verifier 换取 access/refresh token,并回填账号身份。 +// +// - code:用户从回调地址栏复制的授权码(可含 #state 片段)。 +// - state:StartClaudeLogin 返回的 state。 +// - verifier:StartClaudeLogin 返回的 verifier。 +func (o *ClaudeAuth) ExchangeCode(ctx context.Context, code, state, verifier string) (*ClaudeTokenData, error) { + if strings.TrimSpace(verifier) == "" { + return nil, fmt.Errorf("缺少 PKCE verifier") + } + if ctx == nil { + ctx = context.Background() + } + newCode, newState := parseClaudeCodeAndState(code) + if newCode == "" { + return nil, fmt.Errorf("授权码为空") + } + effectiveState := state + if newState != "" { + effectiveState = newState + } + + reqBody := claudeAuthCodeExchangeRequest{ + GrantType: "authorization_code", + Code: newCode, + RedirectURI: ClaudeOAuthRedirectURI, + ClientID: ClaudeOAuthClientID, + CodeVerifier: verifier, + State: effectiveState, + } + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("序列化授权码交换请求失败: %w", err) + } + + body, status, err := o.doClaudeOAuthPost(ctx, ClaudeOAuthTokenURL, jsonBody) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("授权码交换失败 (status %d): %s", status, string(body)) + } + + var tokenResp claudeTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("解析 token 响应失败: %w", err) + } + if strings.TrimSpace(tokenResp.AccessToken) == "" { + return nil, fmt.Errorf("token 响应缺少 access_token") + } + + td := &ClaudeTokenData{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + Email: tokenResp.Account.EmailAddress, + AccountUUID: tokenResp.Account.UUID, + OrganizationUUID: tokenResp.Organization.UUID, + OrganizationName: tokenResp.Organization.Name, + ExpiresAt: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second), + } + // 用 profile 端点补齐 token 响应可能缺失的身份字段。 + if profile, errProfile := o.FetchProfile(ctx, tokenResp.AccessToken); errProfile == nil && profile != nil { + if v := strings.TrimSpace(profile.Account.UUID); v != "" { + td.AccountUUID = v + } + if v := strings.TrimSpace(profile.Account.Email); v != "" { + td.Email = v + } + if v := strings.TrimSpace(profile.Organization.UUID); v != "" { + td.OrganizationUUID = v + } + if v := strings.TrimSpace(profile.Organization.Name); v != "" { + td.OrganizationName = v + } + td.PlanType = DeriveClaudePlanType(profile) + } + return td, nil +} + +// RefreshTokens 用 refresh token 续期。Anthropic 若未返回新的 refresh token,则沿用旧值。 +func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*ClaudeTokenData, error) { + if strings.TrimSpace(refreshToken) == "" { + return nil, fmt.Errorf("缺少 refresh token") + } + if ctx == nil { + ctx = context.Background() + } + // 刷新请求体键序对齐官方客户端。 + reqBody := map[string]string{ + "client_id": ClaudeOAuthClientID, + "grant_type": "refresh_token", + "refresh_token": refreshToken, + "scope": ClaudeOAuthScope, + } + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("序列化刷新请求失败: %w", err) + } + + body, status, err := o.doClaudeOAuthPost(ctx, ClaudeOAuthTokenURL, jsonBody) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("token 刷新失败 (status %d): %s", status, string(body)) + } + + var tokenResp claudeTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("解析刷新响应失败: %w", err) + } + if strings.TrimSpace(tokenResp.RefreshToken) == "" { + tokenResp.RefreshToken = refreshToken + } + td := &ClaudeTokenData{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + ExpiresAt: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second), + } + if profile, errProfile := o.FetchProfile(ctx, tokenResp.AccessToken); errProfile == nil && profile != nil { + td.Email = strings.TrimSpace(profile.Account.Email) + td.AccountUUID = strings.TrimSpace(profile.Account.UUID) + td.OrganizationUUID = strings.TrimSpace(profile.Organization.UUID) + td.OrganizationName = strings.TrimSpace(profile.Organization.Name) + td.PlanType = DeriveClaudePlanType(profile) + } + return td, nil +} + +// FetchProfile 用 access token 拉取账号身份。 +func (o *ClaudeAuth) FetchProfile(ctx context.Context, accessToken string) (*claudeOAuthProfile, error) { + if strings.TrimSpace(accessToken) == "" { + return nil, fmt.Errorf("缺少 access token") + } + if ctx == nil { + ctx = context.Background() + } + resp, err := o.doWithFallback(ctx, http.MethodGet, ClaudeOAuthProfileURL, nil, func(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+accessToken) + }) + if err != nil { + return nil, fmt.Errorf("profile 请求失败: %w", err) + } + defer resp.Body.Close() + + body, err := readClaudeOAuthResponseBody(resp) + if err != nil { + return nil, fmt.Errorf("读取 profile 响应失败: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("获取 profile 失败 (status %d): %s", resp.StatusCode, string(body)) + } + var profile claudeOAuthProfile + if err := json.Unmarshal(body, &profile); err != nil { + return nil, fmt.Errorf("解析 profile 响应失败: %w", err) + } + if strings.TrimSpace(profile.Account.UUID) == "" { + return nil, fmt.Errorf("profile 响应缺少账号 UUID") + } + return &profile, nil +} + +// ClaudeModelsListURL 是 Anthropic 官方模型列表端点(返回该凭据真实可用的模型)。 +const ClaudeModelsListURL = "https://api.anthropic.com/v1/models" + +// FetchModels 用 access token 拉取该账号**真实可用**的模型 ID 列表(动态发现, +// 不写死)。分页拉全(has_more/last_id)。失败时由调用方回退到内置兜底集。 +func (o *ClaudeAuth) FetchModels(ctx context.Context, accessToken string) ([]string, error) { + if strings.TrimSpace(accessToken) == "" { + return nil, fmt.Errorf("缺少 access token") + } + if ctx == nil { + ctx = context.Background() + } + ids := make([]string, 0, 16) + seen := map[string]struct{}{} + afterID := "" + for page := 0; page < 10; page++ { // 上限保护,正常一两页即可拉全 + url := ClaudeModelsListURL + "?limit=100" + if afterID != "" { + url += "&after_id=" + afterID + } + resp, err := o.doWithFallback(ctx, http.MethodGet, url, nil, func(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("anthropic-version", "2023-06-01") + req.Header.Set("anthropic-beta", ClaudeOAuthBeta) + }) + if err != nil { + return nil, fmt.Errorf("拉取 Claude 模型列表失败: %w", err) + } + body, readErr := readClaudeOAuthResponseBody(resp) + _ = resp.Body.Close() + if readErr != nil { + return nil, fmt.Errorf("读取模型列表响应失败: %w", readErr) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("获取模型列表失败 (status %d): %s", resp.StatusCode, string(body)) + } + var parsed struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + HasMore bool `json:"has_more"` + LastID string `json:"last_id"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, fmt.Errorf("解析模型列表失败: %w", err) + } + for _, m := range parsed.Data { + id := strings.TrimSpace(m.ID) + if id == "" { + continue + } + if _, ok := seen[strings.ToLower(id)]; ok { + continue + } + seen[strings.ToLower(id)] = struct{}{} + ids = append(ids, id) + } + if !parsed.HasMore || strings.TrimSpace(parsed.LastID) == "" { + break + } + afterID = parsed.LastID + } + return ids, nil +} + +// doClaudeOAuthPost 发送一个 axios 伪装的 OAuth POST,返回解码后的响应体与状态码。 +func (o *ClaudeAuth) doClaudeOAuthPost(ctx context.Context, endpoint string, jsonBody []byte) ([]byte, int, error) { + resp, err := o.doWithFallback(ctx, http.MethodPost, endpoint, jsonBody, nil) + if err != nil { + return nil, 0, fmt.Errorf("OAuth 请求失败: %w", err) + } + defer resp.Body.Close() + + body, err := readClaudeOAuthResponseBody(resp) + if err != nil { + return nil, resp.StatusCode, fmt.Errorf("读取 OAuth 响应失败: %w", err) + } + return body, resp.StatusCode, nil +} + +// applyClaudeOAuthAxiosHeaders 复刻官方客户端 OAuth 控制面请求的 axios 头,降低被 +// Cloudflare 拦截的概率。 +func applyClaudeOAuthAxiosHeaders(req *http.Request) { + if req == nil { + return + } + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "axios/1.15.2") + req.Header.Set("Accept-Encoding", "gzip, compress, deflate, br") + // 注意:本模块的 HTTP 客户端是 HTTP/2(buildUTLSHTTPClient 强制 h2)。HTTP/2 + // 协议禁止 Connection / Keep-Alive 等逐跳头,设置它们会让 Go 的 http2 transport + // 直接以 "invalid Connection request header" 拒发请求(登录/刷新全失败)。因此这里 + // 不设置 Connection: close 也不置 req.Close——h2 本就不携带这些头。 +} + +// readClaudeOAuthResponseBody 读取并按 Content-Encoding 解码响应体。 +// 因为我们手动设置了 Accept-Encoding,Go 的 transport 不会自动解压,需自行处理。 +func readClaudeOAuthResponseBody(resp *http.Response) ([]byte, error) { + if resp == nil || resp.Body == nil { + return nil, fmt.Errorf("响应体为空") + } + encoded, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + encodings := strings.Split(strings.Join(resp.Header.Values("Content-Encoding"), ","), ",") + for i := len(encodings) - 1; i >= 0; i-- { + encoding := strings.ToLower(strings.TrimSpace(encodings[i])) + if encoding == "" || encoding == "identity" { + continue + } + encoded, err = decodeClaudeOAuthEncoding(encoded, encoding) + if err != nil { + return nil, err + } + } + return encoded, nil +} + +func decodeClaudeOAuthEncoding(encoded []byte, encoding string) ([]byte, error) { + var reader io.ReadCloser + switch encoding { + case "gzip": + gz, err := gzip.NewReader(bytes.NewReader(encoded)) + if err != nil { + return nil, fmt.Errorf("解码 gzip 响应失败: %w", err) + } + reader = gz + case "deflate": + if zr, err := zlib.NewReader(bytes.NewReader(encoded)); err == nil { + reader = zr + } else { + reader = flate.NewReader(bytes.NewReader(encoded)) + } + case "br": + reader = io.NopCloser(brotli.NewReader(bytes.NewReader(encoded))) + default: + return nil, fmt.Errorf("不支持的 Content-Encoding: %q", encoding) + } + decoded, err := io.ReadAll(reader) + if err != nil { + _ = reader.Close() + return nil, fmt.Errorf("解码 %s 响应失败: %w", encoding, err) + } + if err := reader.Close(); err != nil { + return nil, fmt.Errorf("关闭 %s 解码器失败: %w", encoding, err) + } + return decoded, nil +} diff --git a/auth/claude_oauth_test.go b/auth/claude_oauth_test.go new file mode 100644 index 000000000..12f6cd5ff --- /dev/null +++ b/auth/claude_oauth_test.go @@ -0,0 +1,118 @@ +package auth + +import ( + "crypto/sha256" + "encoding/base64" + "net/url" + "strings" + "testing" +) + +func TestGenerateClaudePKCE(t *testing.T) { + pkce, err := GenerateClaudePKCE() + if err != nil { + t.Fatalf("GenerateClaudePKCE 出错: %v", err) + } + if pkce.CodeVerifier == "" || pkce.CodeChallenge == "" { + t.Fatal("verifier/challenge 不应为空") + } + // verifier 应满足 RFC 7636 长度 43-128。 + if l := len(pkce.CodeVerifier); l < 43 || l > 128 { + t.Fatalf("verifier 长度 %d 不在 [43,128]", l) + } + // challenge 必须是 verifier 的 S256(RawURL 无填充)。 + sum := sha256.Sum256([]byte(pkce.CodeVerifier)) + want := base64.RawURLEncoding.EncodeToString(sum[:]) + if pkce.CodeChallenge != want { + t.Fatalf("challenge 不是 verifier 的 S256:\n got=%s\nwant=%s", pkce.CodeChallenge, want) + } + // 不应含有 base64 填充或非 URL 安全字符。 + if strings.ContainsAny(pkce.CodeVerifier+pkce.CodeChallenge, "=+/") { + t.Fatal("PKCE 值含有非 URL 安全字符或填充") + } +} + +func TestGenerateClaudePKCEUnique(t *testing.T) { + a, _ := GenerateClaudePKCE() + b, _ := GenerateClaudePKCE() + if a.CodeVerifier == b.CodeVerifier { + t.Fatal("两次生成的 verifier 不应相同") + } +} + +func TestBuildClaudeAuthURL(t *testing.T) { + pkce := &ClaudePKCECodes{CodeVerifier: "v", CodeChallenge: "challenge-xyz"} + raw, err := BuildClaudeAuthURL("state-123", pkce) + if err != nil { + t.Fatalf("BuildClaudeAuthURL 出错: %v", err) + } + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("生成的 URL 无法解析: %v", err) + } + if got := u.Scheme + "://" + u.Host + u.Path; got != ClaudeOAuthAuthURL { + t.Fatalf("授权端点错误: %s", got) + } + q := u.Query() + checks := map[string]string{ + "client_id": ClaudeOAuthClientID, + "response_type": "code", + "redirect_uri": ClaudeOAuthRedirectURI, + "scope": ClaudeOAuthScope, + "code_challenge": "challenge-xyz", + "code_challenge_method": "S256", + "state": "state-123", + "code": "true", + } + for k, want := range checks { + if got := q.Get(k); got != want { + t.Errorf("查询参数 %s = %q, 期望 %q", k, got, want) + } + } +} + +func TestBuildClaudeAuthURLNilPKCE(t *testing.T) { + if _, err := BuildClaudeAuthURL("s", nil); err == nil { + t.Fatal("PKCE 为 nil 时应报错") + } +} + +func TestParseClaudeCodeAndState(t *testing.T) { + cases := []struct { + in string + wantCode string + wantState string + }{ + {"abc", "abc", ""}, + {"abc#xyz", "abc", "xyz"}, + {" abc#xyz ", "abc", "xyz"}, + {"abc#xyz#extra", "abc", "xyz"}, + } + for _, c := range cases { + code, state := parseClaudeCodeAndState(c.in) + if code != c.wantCode || state != c.wantState { + t.Errorf("parseClaudeCodeAndState(%q) = (%q,%q), 期望 (%q,%q)", + c.in, code, state, c.wantCode, c.wantState) + } + } +} + +func TestStartClaudeLogin(t *testing.T) { + s, err := StartClaudeLogin() + if err != nil { + t.Fatalf("StartClaudeLogin 出错: %v", err) + } + if s.State == "" || s.Verifier == "" || s.AuthURL == "" { + t.Fatal("登录会话字段不应为空") + } + if !strings.Contains(s.AuthURL, url.QueryEscape(s.State)) { + t.Fatal("AuthURL 应包含 state") + } + // AuthURL 里的 challenge 必须与返回的 verifier 对得上。 + u, _ := url.Parse(s.AuthURL) + sum := sha256.Sum256([]byte(s.Verifier)) + wantChallenge := base64.RawURLEncoding.EncodeToString(sum[:]) + if u.Query().Get("code_challenge") != wantChallenge { + t.Fatal("AuthURL 中的 code_challenge 与 verifier 不匹配") + } +} diff --git a/auth/claude_security_config_test.go b/auth/claude_security_config_test.go new file mode 100644 index 000000000..5e45d83f7 --- /dev/null +++ b/auth/claude_security_config_test.go @@ -0,0 +1,47 @@ +package auth + +import "testing" + +func TestNormalizeClaudeSecurityConfigUsesSafeDefaults(t *testing.T) { + cfg := NormalizeClaudeSecurityConfig(ClaudeSecurityConfig{}) + if cfg.MaxOutputTokens != 0 || cfg.MaxToolCount != 0 || cfg.MaxToolSchemaBytes != 0 { + t.Fatalf("zero values should mean no application cap: %+v", cfg) + } + if len(cfg.AllowedBetaHeaders) != 0 { + t.Fatalf("empty beta allowlist should stay empty: %v", cfg.AllowedBetaHeaders) + } +} + +func TestNormalizeClaudeSecurityConfigCanonicalizesBetaAllowlistAndKeepsExplicitLimits(t *testing.T) { + cfg := NormalizeClaudeSecurityConfig(ClaudeSecurityConfig{ + AllowedBetaHeaders: []string{" Foo-Bar ", "foo-bar", "bad value", "oauth-2025-04-20"}, + MaxOutputTokens: 999999, + MaxToolCount: 999, + MaxToolSchemaBytes: 99999999, + }) + if len(cfg.AllowedBetaHeaders) != 2 || cfg.AllowedBetaHeaders[0] != "foo-bar" || cfg.AllowedBetaHeaders[1] != "oauth-2025-04-20" { + t.Fatalf("normalized beta allowlist = %v", cfg.AllowedBetaHeaders) + } + if cfg.MaxOutputTokens != 999999 || cfg.MaxToolCount != 999 || cfg.MaxToolSchemaBytes != 99999999 { + t.Fatalf("explicit compatibility limits should remain operator values: %+v", cfg) + } + unlimited := NormalizeClaudeSecurityConfig(ClaudeSecurityConfig{ + MaxOutputTokens: -1, + MaxToolCount: -1, + MaxToolSchemaBytes: -1, + }) + if unlimited.MaxOutputTokens != 0 || unlimited.MaxToolCount != 0 || unlimited.MaxToolSchemaBytes != 0 { + t.Fatalf("negative values should normalize to unlimited zero values: %+v", unlimited) + } +} + +func TestParseClaudeConfigKeepsLegacyFieldsAndSecurityDefaults(t *testing.T) { + cfg := ParseClaudeConfig(`{"fingerprint_mode":"force","default_timezone":"Asia/Shanghai","session_window_limit":4,"allow_service_tier":true,"allowed_beta_headers":["beta-x"]}`) + if cfg.FingerprintMode != ClaudeFingerprintModeForce || cfg.DefaultTimezone != "Asia/Shanghai" || cfg.SessionWindowLimit != 4 { + t.Fatalf("legacy Claude config fields changed: %+v", cfg) + } + security := cfg.SecurityConfig() + if !security.AllowServiceTier || len(security.AllowedBetaHeaders) != 1 || security.MaxOutputTokens != 0 || security.MaxToolCount != 0 || security.MaxToolSchemaBytes != 0 { + t.Fatalf("security config parse = %+v", security) + } +} diff --git a/auth/dispatch_reconcile_test.go b/auth/dispatch_reconcile_test.go index da8cedd18..2cc2f7370 100644 --- a/auth/dispatch_reconcile_test.go +++ b/auth/dispatch_reconcile_test.go @@ -15,12 +15,15 @@ func TestReconcileDispatchStateLoadsAccountAddedAfterStartup(t *testing.T) { if err != nil { t.Fatalf("database.New: %v", err) } - t.Cleanup(func() { _ = db.Close() }) store := NewStore(db, nil, &database.SystemSettings{ MaxConcurrency: 1, FastSchedulerEnabled: true, }) + t.Cleanup(func() { + store.Stop() + _ = db.Close() + }) if err := store.Init(ctx); err != nil { t.Fatalf("Store.Init: %v", err) } @@ -39,13 +42,10 @@ func TestReconcileDispatchStateLoadsAccountAddedAfterStartup(t *testing.T) { t.Fatalf("InsertOpenAIResponsesAccount: %v", err) } - changed, err := store.ReconcileDispatchState(ctx) + _, err = store.ReconcileDispatchState(ctx) if err != nil { t.Fatalf("ReconcileDispatchState: %v", err) } - if !changed { - t.Fatal("ReconcileDispatchState reported no change for a newly added account") - } got := store.Next() if got == nil { t.Fatal("Next() returned nil after dispatch reconciliation") @@ -62,12 +62,15 @@ func TestTriggerDispatchStateReconcileAsyncLoadsAccount(t *testing.T) { if err != nil { t.Fatalf("database.New: %v", err) } - t.Cleanup(func() { _ = db.Close() }) store := NewStore(db, nil, &database.SystemSettings{ MaxConcurrency: 1, FastSchedulerEnabled: true, }) + t.Cleanup(func() { + store.Stop() + _ = db.Close() + }) if err := store.Init(ctx); err != nil { t.Fatalf("Store.Init: %v", err) } @@ -136,12 +139,15 @@ func TestAsyncReconcileCoalescesOntoActiveRunCompletion(t *testing.T) { if err != nil { t.Fatalf("database.New: %v", err) } - t.Cleanup(func() { _ = db.Close() }) store := NewStore(db, nil, &database.SystemSettings{ MaxConcurrency: 1, FastSchedulerEnabled: true, }) + t.Cleanup(func() { + store.Stop() + _ = db.Close() + }) if err := store.Init(ctx); err != nil { t.Fatalf("Store.Init: %v", err) } @@ -169,13 +175,10 @@ func TestAsyncReconcileCoalescesOntoActiveRunCompletion(t *testing.T) { default: } - changed, err := store.reconcileDispatchState(ctx) + _, err = store.reconcileDispatchState(ctx) if err != nil { t.Fatalf("reconcileDispatchState: %v", err) } - if !changed { - t.Fatal("reconcileDispatchState reported no change for a newly added account") - } store.finishDispatchStateReconcile(activeDone) select { case <-asyncDone: @@ -199,9 +202,12 @@ func TestTriggerDispatchStateReconcileAsyncThrottledReturnsNil(t *testing.T) { if err != nil { t.Fatalf("database.New: %v", err) } - t.Cleanup(func() { _ = db.Close() }) store := NewStore(db, nil, &database.SystemSettings{MaxConcurrency: 1}) + t.Cleanup(func() { + store.Stop() + _ = db.Close() + }) if err := store.Init(ctx); err != nil { t.Fatalf("Store.Init: %v", err) } diff --git a/auth/grok_account.go b/auth/grok_account.go index 0267b7ae1..ad12dd109 100644 --- a/auth/grok_account.go +++ b/auth/grok_account.go @@ -136,7 +136,7 @@ func (a *Account) IsGrokAPI() bool { // isRelayStyleLocked:openai_responses 中转或 Grok —— 一切「非 Codex OAuth 官方上游」 // 的账号。这类账号不参与 Codex 专属行为(wham 探针、WS 上游、manifest、alpha search)。 func (a *Account) isRelayStyleLocked() bool { - return a.isOpenAIResponsesAPILocked() || a.isGrokAPILocked() || a.isAntigravityAPILocked() + return a.isOpenAIResponsesAPILocked() || a.isGrokAPILocked() || a.isAntigravityAPILocked() || a.isClaudeOAuthLocked() } // IsRelayStyle 判断账号是否为「非 Codex 官方」的外部上游账号。 diff --git a/auth/openai_responses_identity_test.go b/auth/openai_responses_identity_test.go index 010bbef2d..068347bbf 100644 --- a/auth/openai_responses_identity_test.go +++ b/auth/openai_responses_identity_test.go @@ -63,7 +63,6 @@ func TestReconcileDispatchStateReloadsChangedResponsesIdentity(t *testing.T) { if err != nil { t.Fatalf("database.New: %v", err) } - t.Cleanup(func() { _ = db.Close() }) accountID, err := db.InsertOpenAIResponsesAccount(ctx, "relay", map[string]interface{}{ "upstream_type": UpstreamOpenAIResponses, @@ -75,6 +74,10 @@ func TestReconcileDispatchStateReloadsChangedResponsesIdentity(t *testing.T) { t.Fatalf("InsertOpenAIResponsesAccount: %v", err) } store := NewStore(db, nil, &database.SystemSettings{MaxConcurrency: 1, FastSchedulerEnabled: true}) + t.Cleanup(func() { + store.Stop() + _ = db.Close() + }) if err := store.Init(ctx); err != nil { t.Fatalf("Store.Init: %v", err) } @@ -88,13 +91,10 @@ func TestReconcileDispatchStateReloadsChangedResponsesIdentity(t *testing.T) { t.Fatalf("UpdateOpenAIResponsesAccount: %v", err) } - changed, err := store.ReconcileDispatchState(ctx) + _, err = store.ReconcileDispatchState(ctx) if err != nil { t.Fatalf("ReconcileDispatchState: %v", err) } - if !changed { - t.Fatal("ReconcileDispatchState reported no change for corrected endpoint identity") - } baseURL, apiKey := acc.OpenAIResponsesCredentials() if baseURL != "https://relay.example" || apiKey != "sk-new" { t.Fatalf("reconciled credentials = (%q, %q), want corrected endpoint", baseURL, apiKey) diff --git a/auth/premium_rate_limit.go b/auth/premium_rate_limit.go index d8aa091cc..2aba80af7 100644 --- a/auth/premium_rate_limit.go +++ b/auth/premium_rate_limit.go @@ -45,10 +45,16 @@ func normalizePlanType(plan string) string { // premium5hRateLimitedLocked additionally require an actually observed 5h // window at 100%, so a plan without a real 5h window can never get stuck. func isPremium5hPlan(plan string) bool { - switch normalizePlanType(plan) { + normalized := normalizePlanType(plan) + switch normalized { case "plus", "pro", "team", "k12", "edu", "education", "go": return true + case "claude", "max", "max-5x", "max-20x": + return true default: + if strings.HasPrefix(normalized, "claude-") { + return true + } return IsPlusOrHigherPlan(plan) } } diff --git a/auth/premium_rate_limit_test.go b/auth/premium_rate_limit_test.go index 5afab67e9..01a9493a9 100644 --- a/auth/premium_rate_limit_test.go +++ b/auth/premium_rate_limit_test.go @@ -292,6 +292,14 @@ func TestPaidWorkspacePlansAreTreatedAsPremium5hPlans(t *testing.T) { } } +func TestClaudePlansAreTreatedAsPremium5hPlans(t *testing.T) { + for _, plan := range []string{"claude", "claude-pro", "max", "max-5x", "max-20x", "enterprise", "business"} { + if !isPremium5hPlan(plan) { + t.Errorf("isPremium5hPlan(%q) = false, want true for Claude usage windows", plan) + } + } +} + func TestK12RateLimitedAccountIsFencedFromScheduling(t *testing.T) { acc := newPremium5hTestAccount("k12", time.Now().Add(45*time.Minute)) diff --git a/auth/scheduler_outbox_consumer.go b/auth/scheduler_outbox_consumer.go index 9e77250e7..fb2b8b187 100644 --- a/auth/scheduler_outbox_consumer.go +++ b/auth/scheduler_outbox_consumer.go @@ -488,6 +488,8 @@ func (s *Store) applyPersistentAccountSnapshot(dst, src *Account, enabled bool) dst.ModelMapping = src.ModelMapping dst.CodexClientMetadataMode = src.CodexClientMetadataMode dst.CodexFingerprintMode = src.CodexFingerprintMode + dst.ClaudeFingerprintMode = src.ClaudeFingerprintMode + dst.claudeSessionWindow = src.claudeSessionWindow dst.CodexAuthMode = src.CodexAuthMode dst.AgentRuntimeID = src.AgentRuntimeID dst.AgentPrivateKey = src.AgentPrivateKey @@ -525,6 +527,9 @@ func (s *Store) applyPersistentAccountSnapshot(dst, src *Account, enabled bool) dst.Reset5hAt = src.Reset5hAt dst.UsageUpdatedAt = src.UsageUpdatedAt dst.UsageUpdatedAt5h = src.UsageUpdatedAt5h + if src.usageObservedAt.After(dst.usageObservedAt) { + dst.usageObservedAt = src.usageObservedAt + } dst.UsagePercentSpark = src.UsagePercentSpark dst.UsagePercentSparkValid = src.UsagePercentSparkValid dst.ResetSparkAt = src.ResetSparkAt diff --git a/auth/scheduler_outbox_consumer_test.go b/auth/scheduler_outbox_consumer_test.go index 51a3abde8..b172f2cb1 100644 --- a/auth/scheduler_outbox_consumer_test.go +++ b/auth/scheduler_outbox_consumer_test.go @@ -205,6 +205,7 @@ func TestApplyPersistentAccountSnapshotRoutingInvalidationGate(t *testing.T) { func TestApplyPersistentAccountSnapshotPreservesRuntimeState(t *testing.T) { store := newIndexedRoutingTestStore(nil) dst := newFastSchedulerTestAccount(1, HealthTierWarm, 100, 1) + dst.usageObservedAt = time.Now() atomic.StoreInt64(&dst.ActiveRequests, 3) dst.SuccessStreak = 5 src := newFastSchedulerTestAccount(1, HealthTierHealthy, 100, 1) @@ -214,6 +215,9 @@ func TestApplyPersistentAccountSnapshotPreservesRuntimeState(t *testing.T) { if atomic.LoadInt64(&dst.ActiveRequests) != 3 || dst.SuccessStreak != 5 { t.Fatalf("runtime state clobbered: active=%d streak=%d", atomic.LoadInt64(&dst.ActiveRequests), dst.SuccessStreak) } + if dst.usageObservedAt.IsZero() { + t.Fatal("persistent snapshot should not erase a newer runtime observation timestamp") + } rotated := newFastSchedulerTestAccount(1, HealthTierHealthy, 100, 1) rotated.CredentialGeneration = dst.CredentialGeneration + 1 diff --git a/auth/store.go b/auth/store.go index f45cae6d8..9a37bffad 100644 --- a/auth/store.go +++ b/auth/store.go @@ -123,6 +123,12 @@ type Account struct { // CodexFingerprintMode 见 codex_fingerprint_mode.go:Codex 官方出站请求的 // 设备指纹收敛档位(off / device / session / full),默认 off。 CodexFingerprintMode string + // ClaudeFingerprintMode 见 claude_fingerprint_mode.go:Claude Code 出站身份头 + // 收敛模式(preserve/force;空=跟随全局默认)。 + ClaudeFingerprintMode string + // claudeSessionWindow 是 Claude 账号的全局默认并发会话窗口数(装载时从系统设置 + // 快照,>0 时作为无账号级/分组覆盖时的基础并发回退)。 + claudeSessionWindow int64 // Codex Agent Identity(auth_mode=agentIdentity):不存 AT/RT,每次上游请求用 // agent_private_key(Ed25519, PKCS#8 base64) 动态签名。AgentTaskID 由 task 注册获得, // 运行时缓存并落库(credentials.task_id)。 @@ -1110,6 +1116,10 @@ func (a *Account) effectiveBaseConcurrencyLocked(storeBaseLimit int64) int64 { if a.groupBaseConcurrency > 0 { return a.groupBaseConcurrency } + // Claude 账号:无账号级/分组覆盖时回退到全局「并发会话窗口数」默认。 + if a.claudeSessionWindow > 0 { + return a.claudeSessionWindow + } if storeBaseLimit <= 0 { return 1 } @@ -2090,6 +2100,17 @@ func (a *Account) SetUsageSnapshot(pct float64, updatedAt time.Time) { a.UsageUpdatedAt = updatedAt } +// MarkClaudeUsageObservation records a native Claude response (or a bounded +// probe attempt) even when Anthropic omits unified quota headers. The timestamp +// participates only in Claude probe freshness; it never fabricates a 5h/7d +// percentage and therefore cannot make an unmeasured account look quota-safe. +func (a *Account) MarkClaudeUsageObservation(observedAt time.Time) bool { + if a == nil || !a.IsClaudeOAuth() { + return false + } + return a.ApplyUsageObservation(observedAt, func() {}) +} + // GetUsagePercent7d 获取 7d 用量百分比 func (a *Account) GetUsagePercent7d() (float64, bool) { a.mu.RLock() @@ -2939,18 +2960,35 @@ func (a *Account) NeedsUsageProbe(maxAge time.Duration) bool { if a.usageProbeInFlight || a.AccessToken == "" || a.Status == StatusError { return false } - if a.isRelayStyleLocked() { + if a.isRelayStyleLocked() && !a.isClaudeOAuthLocked() { return false // wham 探针是 ChatGPT 专属;中转/Grok 账号没有该端点 } if a.Status == StatusCooldown && a.CooldownReason == "unauthorized" && (a.CooldownUtil.IsZero() || now.Before(a.CooldownUtil)) { return false // token 失效,wham 也会 401,探针无意义 } + // Claude uses the native Messages endpoint rather than WHAM and may legally + // omit both unified quota windows. In that case the shared 7d validity bits + // remain false by design; use the provider observation timestamp to avoid + // sending a paid probe on every background sweep. A cooldown that has just + // expired is still worth one confirmation probe. + if a.isClaudeOAuthLocked() { + if a.Status == StatusCooldown && !a.CooldownUtil.IsZero() && !now.Before(a.CooldownUtil) { + return true + } + if a.UsagePercent5hValid && !a.Reset5hAt.IsZero() && !a.Reset5hAt.After(now) && a.UsageUpdatedAt5h.Before(a.Reset5hAt) { + return true + } + if a.UsagePercent7dValid && !a.Reset7dAt.IsZero() && !a.Reset7dAt.After(now) && a.UsageUpdatedAt.Before(a.Reset7dAt) { + return true + } + return a.usageObservedAt.IsZero() || now.Sub(a.usageObservedAt) > maxAge + } // 「主动重置次数」只能由 wham 探针刷新(普通 /responses 流量不携带该字段), // 因此用独立的 resetCreditsProbedAt 判断它是否过期。否则活跃账号的用量快照被 // 业务流量持续刷新,会让用量看起来一直"新鲜",从而长期不触发 wham 探针、 // 重置次数迟迟探测不出来。 - resetCreditsStale := a.resetCreditsProbedAt.IsZero() || now.Sub(a.resetCreditsProbedAt) > maxAge + resetCreditsStale := !a.isClaudeOAuthLocked() && (a.resetCreditsProbedAt.IsZero() || now.Sub(a.resetCreditsProbedAt) > maxAge) if a.premium5hRateLimitedLocked(now) { // premium 5h 限流期间仍允许 wham 刷新重置次数;是否补 Responses @@ -3287,6 +3325,10 @@ type Store struct { schedulerMode atomic.Value // string: "round_robin" / "remaining_quota" / "fill_first" affinityMode atomic.Value // string: "bounded" / "off" / "strict" affinitySpreadEnabled atomic.Bool // 新亲和键按 HRW 哈希散列选号(issue #484) + claudeFingerprintDefault atomic.Value // string: Claude 指纹模式全局默认(preserve/force;空=preserve) + claudeDefaultTimezone atomic.Value // string: 导入 Claude 账号时的默认 IANA 时区 + claudeSecurityConfig atomic.Value // ClaudeSecurityConfig: ClaudeCode 出站安全策略 + claudeSessionWindowLimit int64 // Claude 账号默认并发会话窗口数(0=用全局 maxConcurrency) grokAffinityMode atomic.Value // string: "follow" / "bounded" / "off" / "strict"("follow"=跟随全局) grokProbeEnabled atomic.Bool // 定期探测 Grok 账号状态是否开启(默认关) grokProbeIntervalMin atomic.Int64 // 定期探测间隔(分钟,默认 30,下限 grokProbeMinIntervalMinutes) @@ -3818,6 +3860,7 @@ func NewStore(db *database.DB, tc cache.TokenCache, settings *database.SystemSet s.SetAffinityMode(settings.AffinityMode) s.SetSessionAffinitySpread(settings.SessionAffinitySpread) s.SetGrokAffinityMode(grokAffinityModeFromConfig(settings.GrokConfig)) + applyClaudeConfigToStore(s, settings.ClaudeConfig) s.SetGrokProbeConfig(grokProbeConfigFromConfig(settings.GrokConfig)) s.SetGrokMaxRateLimitRetries(grokMaxRateLimitRetriesFromConfig(settings.GrokConfig)) s.SetGrokFollowUpEffortConfig(GrokFollowUpEffortConfigFromJSON(settings.GrokConfig)) @@ -5047,6 +5090,7 @@ func (s *Store) buildAccountFromRow(ctx context.Context, row *database.AccountRo modelMapping := strings.TrimSpace(row.GetCredential("model_mapping")) codexClientMetadataMode := NormalizeCodexClientMetadataMode(row.GetCredential("codex_client_metadata_mode")) codexFingerprintMode := NormalizeCodexFingerprintMode(row.GetCredential(CodexFingerprintModeCredentialKey)) + claudeFingerprintMode := NormalizeClaudeFingerprintMode(row.GetCredential(ClaudeFingerprintModeCredentialKey)) isOpenAIResponsesAccount := strings.EqualFold(strings.TrimSpace(upstreamType), UpstreamOpenAIResponses) && strings.TrimSpace(baseURL) != "" && strings.TrimSpace(apiKey) != "" isGrokAccount := strings.EqualFold(strings.TrimSpace(upstreamType), UpstreamGrok) && (strings.TrimSpace(apiKey) != "" || rt != "" || at != "") isAntigravityAccount := strings.EqualFold(strings.TrimSpace(upstreamType), UpstreamAntigravity) && (strings.TrimSpace(apiKey) != "" || rt != "" || at != "") @@ -5077,6 +5121,19 @@ func (s *Store) buildAccountFromRow(ctx context.Context, row *database.AccountRo ModelMapping: modelMapping, CodexClientMetadataMode: codexClientMetadataMode, CodexFingerprintMode: codexFingerprintMode, + ClaudeFingerprintMode: claudeFingerprintMode, + claudeSessionWindow: claudeSessionWindowForRow(upstreamType, s.ClaudeSessionWindowLimit()), + } + if strings.EqualFold(strings.TrimSpace(upstreamType), UpstreamClaude) { + if observedRaw := strings.TrimSpace(row.GetCredential(ClaudeUsageProbeAtCredentialKey)); observedRaw != "" { + if observedAt, parseErr := time.Parse(time.RFC3339, observedRaw); parseErr == nil { + // This is only a freshness hint; quota validity remains false until + // an actual Anthropic response supplies a window header. + account.MarkClaudeUsageObservation(observedAt) + } else { + log.Printf("[账号 %d] 解析 claude_usage_probe_at 失败: %v", row.ID, parseErr) + } + } } if account.CredentialGeneration <= 0 { account.CredentialGeneration = 1 @@ -8688,14 +8745,14 @@ func (s *Store) GetAPIKeyAllowedGroups(apiKeyID int64) []int64 { return cloneInt64Slice(s.apiKeyAllowedGroups[apiKeyID]) } -// SetAPIKeyUpstreamChannel 设置某 API Key 的上游渠道限定(codex/grok,空=不限)。 +// SetAPIKeyUpstreamChannel 设置某 API Key 的上游渠道限定(codex/grok/antigravity/claude,空=不限)。 // 仅在取值真正变化时重建调度器。 func (s *Store) SetAPIKeyUpstreamChannel(apiKeyID int64, channel string) { if apiKeyID <= 0 { return } channel = strings.ToLower(strings.TrimSpace(channel)) - if channel != database.UpstreamChannelCodex && channel != database.UpstreamChannelGrok && channel != database.UpstreamChannelAntigravity { + if channel != database.UpstreamChannelCodex && channel != database.UpstreamChannelGrok && channel != database.UpstreamChannelAntigravity && channel != database.UpstreamChannelClaude { channel = "" } s.apiKeyGroupsMu.Lock() @@ -8785,13 +8842,17 @@ func (s *Store) APIKeyAllowsAccount(apiKeyID int64, acc *Account) bool { return false } case database.UpstreamChannelCodex: - if acc.IsGrokAPI() || acc.IsAntigravityAPI() { + if acc.IsGrokAPI() || acc.IsAntigravityAPI() || acc.IsClaudeOAuth() { return false } case database.UpstreamChannelAntigravity: if !acc.IsAntigravityAPI() { return false } + case database.UpstreamChannelClaude: + if !acc.IsClaudeOAuth() { + return false + } } if len(allowedGroups) == 0 && len(allowedPlans) == 0 { return true @@ -10930,6 +10991,11 @@ func (s *Store) refreshAccountWithOptions(ctx context.Context, acc *Account, for if acc.IsGrokAPI() { return s.refreshGrokAccount(ctx, acc, forceRefresh) } + // Claude Code OAuth 账号走 platform.claude.com 的 RT 刷新,请求体与端点均与 + // ChatGPT 不同,单独处理。对所有非 claude 账号此分支恒不进入。 + if acc.IsClaudeOAuth() { + return s.refreshClaudeAccount(ctx, acc, forceRefresh) + } acc.mu.RLock() rt := acc.RefreshToken st := acc.SessionToken diff --git a/auth/store_scheduler_test.go b/auth/store_scheduler_test.go index 6723238d0..90178009f 100644 --- a/auth/store_scheduler_test.go +++ b/auth/store_scheduler_test.go @@ -488,6 +488,44 @@ func TestNeedsUsageProbeAllowsReadyAccount(t *testing.T) { } } +func TestNeedsUsageProbeAllowsClaudeAndRefreshesStaleSnapshot(t *testing.T) { + acc := &Account{UpstreamType: UpstreamClaude, AccessToken: "claude-token", Status: StatusReady} + if !acc.NeedsUsageProbe(10 * time.Minute) { + t.Fatal("Claude account should be eligible for an initial usage probe") + } + acc.SetUsageSnapshot5hAt(12, time.Now(), time.Now()) + acc.SetReset7dAt(time.Now().Add(24 * time.Hour)) + acc.UsagePercent7dValid = true + acc.UsageUpdatedAt = time.Now() + if acc.NeedsUsageProbe(10 * time.Minute) { + t.Fatal("Claude account with fresh snapshots should not be probed again") + } +} + +func TestNeedsUsageProbeClaudeUsesNativeObservationFreshnessWithoutQuotaHeaders(t *testing.T) { + acc := &Account{UpstreamType: UpstreamClaude, AccessToken: "claude-token", Status: StatusReady} + acc.MarkClaudeUsageObservation(time.Now()) + if acc.NeedsUsageProbe(10 * time.Minute) { + t.Fatal("a recent native Claude observation without quota headers should suppress a duplicate probe") + } + + stale := &Account{UpstreamType: UpstreamClaude, AccessToken: "claude-token", Status: StatusReady, + usageObservedAt: time.Now().Add(-11 * time.Minute)} + if !stale.NeedsUsageProbe(10 * time.Minute) { + t.Fatal("a stale native Claude observation should trigger a refresh probe") + } +} + +func TestSetAPIKeyUpstreamChannelAcceptsClaude(t *testing.T) { + store := NewStore(nil, nil, nil) + defer store.Stop() + + store.SetAPIKeyUpstreamChannel(42, " Claude ") + if got := store.APIKeyUpstreamChannel(42); got != database.UpstreamChannelClaude { + t.Fatalf("API key upstream channel = %q, want %q", got, database.UpstreamChannelClaude) + } +} + func TestNeedsUsageProbeRefreshesStaleResetCreditsDespiteFreshUsage(t *testing.T) { now := time.Now() // 核心修复:账号用量快照很新鲜(活跃账号被业务流量持续刷新), diff --git a/auth/workspace_linked_error.go b/auth/workspace_linked_error.go index a92c7f11c..74a308849 100644 --- a/auth/workspace_linked_error.go +++ b/auth/workspace_linked_error.go @@ -48,7 +48,7 @@ func deactivatedWorkspaceLinkedMessage(triggerID int64) string { } func (s *Store) workspaceLinkedTargets(trigger *Account) []*Account { - if trigger.IsGrokAPI() || trigger.IsOpenAIResponsesAPI() { + if trigger.IsGrokAPI() || trigger.IsOpenAIResponsesAPI() || trigger.IsClaudeOAuth() { return nil } workspaceID := strings.TrimSpace(trigger.EffectiveAccountID()) @@ -72,7 +72,7 @@ func shouldLinkDeactivatedWorkspace(trigger, sibling *Account, workspaceID strin if sibling == nil || trigger == nil || sibling.DBID == trigger.DBID { return false } - if sibling.IsGrokAPI() || sibling.IsOpenAIResponsesAPI() { + if sibling.IsGrokAPI() || sibling.IsOpenAIResponsesAPI() || sibling.IsClaudeOAuth() { return false } if siblingErrorStatus(sibling) { @@ -118,7 +118,7 @@ func siblingErrorStatus(acc *Account) bool { // LinkedDeactivatedWorkspaceResult 供批量测试在打 WHAM 前短路: // 该账号已因同空间停用被标错,或所属工作区刚被停用。 func (s *Store) LinkedDeactivatedWorkspaceResult(acc *Account) (string, bool) { - if s == nil || acc == nil || acc.IsGrokAPI() || acc.IsOpenAIResponsesAPI() { + if s == nil || acc == nil || acc.IsGrokAPI() || acc.IsOpenAIResponsesAPI() || acc.IsClaudeOAuth() { return "", false } acc.mu.RLock() diff --git a/auth/workspace_linked_error_test.go b/auth/workspace_linked_error_test.go index 77e566cfd..500617c2d 100644 --- a/auth/workspace_linked_error_test.go +++ b/auth/workspace_linked_error_test.go @@ -234,6 +234,15 @@ func TestMarkDeactivatedWorkspaceSkipsGrokAndResponsesTriggers(t *testing.T) { if sibling.RuntimeStatus() == "error" { t.Fatal("openai responses trigger must not fan out") } + + store.workspaceLinkedRecent = nil + claude := newWorkspaceLinkedAccount(4, "team-A") + claude.UpstreamType = UpstreamClaude + store.AddAccount(claude) + store.MarkDeactivatedWorkspace(claude, "upstream Claude workspace error") + if sibling.RuntimeStatus() == "error" { + t.Fatal("Claude trigger must not fan out into Codex workspace accounts") + } } func accountErrorMsg(acc *Account) string { diff --git a/cmd/claude_login/main.go b/cmd/claude_login/main.go new file mode 100644 index 000000000..8c1d7ed67 --- /dev/null +++ b/cmd/claude_login/main.go @@ -0,0 +1,207 @@ +// 独立的 Claude Code OAuth 登录自测工具(非交互、两步式)。 +// +// 因为服务器/受限终端无法交互式粘贴,本工具拆成两步,各自是一条独立命令, +// 中间用一个临时 session 文件承接 state / verifier: +// +// 第一步(生成授权 URL): +// go run ./cmd/claude_login +// 打印授权 URL 并把 session 存到临时文件。在浏览器打开该 URL 用 Claude 账号授权。 +// +// 第二步(换取 token):授权后浏览器跳到 http://localhost:54545/callback?code=... +// (页面打不开属正常)。直接复制**整条地址栏 URL**,或只复制 code 值,然后: +// go run ./cmd/claude_login -code "把整条回调URL或code粘这里" +// 程序换取 access/refresh token、打印账号身份,并自动试刷新一次。 +// +// 可选参数: +// -proxy 出站代理,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080 +// -session 自定义 session 文件路径(默认系统临时目录) +// -out 把最终 token(JSON)另存到指定文件,便于后续导入账号池 +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/codex2api/auth" +) + +func defaultSessionPath() string { + return filepath.Join(os.TempDir(), "claude_login_session.json") +} + +func main() { + code := flag.String("code", "", "授权后的回调 URL 或 code 值;留空则进入第一步生成授权 URL") + proxy := flag.String("proxy", "", "出站代理 URL(可选)") + sessionPath := flag.String("session", defaultSessionPath(), "session 文件路径(承接 state/verifier)") + outPath := flag.String("out", "", "可选:把最终 token JSON 另存到该文件") + flag.Parse() + + if strings.TrimSpace(*code) == "" { + runStart(*sessionPath) + return + } + runExchange(*sessionPath, *code, *proxy, *outPath) +} + +// runStart 生成授权 URL 并把 session 落盘。 +func runStart(sessionPath string) { + session, err := auth.StartClaudeLogin() + if err != nil { + fmt.Fprintf(os.Stderr, "发起登录失败: %v\n", err) + os.Exit(1) + } + data, _ := json.MarshalIndent(session, "", " ") + if err := os.WriteFile(sessionPath, data, 0600); err != nil { + fmt.Fprintf(os.Stderr, "写入 session 文件失败: %v\n", err) + os.Exit(1) + } + + fmt.Println("========================================================") + fmt.Println("第一步:在浏览器打开下面的授权 URL,用你的 Claude 账号授权") + fmt.Println() + fmt.Println(" " + session.AuthURL) + fmt.Println() + fmt.Println("授权后浏览器会跳转到 http://localhost:54545/callback?code=...(页面打不开属正常)。") + fmt.Println("【推荐】只复制 code= 与 &state= 之间那段纯 code 值(无特殊字符,最省事):") + fmt.Println() + fmt.Println(" go run ./cmd/claude_login -code '这里粘 code 值'") + fmt.Println() + fmt.Println("若要粘整条回调 URL,务必用【单引号】包住(否则 zsh 会把 ? & 当通配符报 no matches found):") + fmt.Println(" go run ./cmd/claude_login -code 'http://localhost:54545/callback?code=...&state=...'") + fmt.Println() + fmt.Printf("(session 已存到 %s)\n", sessionPath) + fmt.Println("========================================================") +} + +// runExchange 读取 session、换取 token 并自测刷新。 +func runExchange(sessionPath, rawCode, proxy, outPath string) { + raw, err := os.ReadFile(sessionPath) + if err != nil { + fmt.Fprintf(os.Stderr, "读取 session 文件失败(%s): %v\n请先执行第一步:go run ./cmd/claude_login\n", sessionPath, err) + os.Exit(1) + } + var session auth.ClaudeLoginSession + if err := json.Unmarshal(raw, &session); err != nil { + fmt.Fprintf(os.Stderr, "解析 session 文件失败: %v\n", err) + os.Exit(1) + } + + code, stateOverride := extractCode(rawCode) + if code == "" { + fmt.Fprintln(os.Stderr, "未能从输入中解析出授权码。") + os.Exit(1) + } + state := session.State + if stateOverride != "" { + state = stateOverride + } + + client := auth.NewClaudeAuth(proxy) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + fmt.Println(">> 正在换取 token ...") + td, err := client.ExchangeCode(ctx, code, state, session.Verifier) + if err != nil { + fmt.Fprintf(os.Stderr, "换取 token 失败: %v\n", err) + diagnoseClaudeLoginError(err) + os.Exit(1) + } + fmt.Println(">> 登录成功!账号身份:") + fmt.Printf(" Email : %s\n", td.Email) + fmt.Printf(" AccountUUID : %s\n", td.AccountUUID) + fmt.Printf(" Organization : %s (%s)\n", td.OrganizationName, td.OrganizationUUID) + fmt.Printf(" AccessToken : %s…(%d 字符)\n", safePrefix(td.AccessToken, 12), len(td.AccessToken)) + fmt.Printf(" RefreshToken : %s…(%d 字符)\n", safePrefix(td.RefreshToken, 12), len(td.RefreshToken)) + fmt.Printf(" 过期时刻 : %s(约 %s 后)\n", td.ExpiresAt.Format(time.RFC3339), time.Until(td.ExpiresAt).Round(time.Second)) + + if strings.TrimSpace(td.RefreshToken) != "" { + fmt.Println("\n>> 正在用 refresh token 试刷新一次 ...") + refreshed, rErr := client.RefreshTokens(ctx, td.RefreshToken) + if rErr != nil { + fmt.Fprintf(os.Stderr, "刷新失败: %v\n", rErr) + os.Exit(1) + } + fmt.Printf(">> 刷新成功!新 AccessToken: %s…(%d 字符),过期 %s\n", + safePrefix(refreshed.AccessToken, 12), len(refreshed.AccessToken), refreshed.ExpiresAt.Format(time.RFC3339)) + td = refreshed + } else { + fmt.Println("\n(!) 未返回 refresh token,跳过刷新自测。") + } + + if strings.TrimSpace(outPath) != "" { + out := map[string]any{ + "upstream_type": auth.UpstreamClaude, + "access_token": td.AccessToken, + "refresh_token": td.RefreshToken, + "email": td.Email, + "account_id": td.AccountUUID, + "expires_at": td.ExpiresAt.Format(time.RFC3339), + } + data, _ := json.MarshalIndent(out, "", " ") + if err := os.WriteFile(outPath, data, 0600); err != nil { + fmt.Fprintf(os.Stderr, "写入 token 文件失败: %v\n", err) + } else { + fmt.Printf("\ntoken 已另存到 %s\n", outPath) + } + } + fmt.Println("\n全链路验证通过:登录 + 身份 + 刷新均可用。") +} + +// extractCode 从输入中提取授权码。支持三种形态: +// 1. 整条回调 URL:http://localhost:54545/callback?code=XXX&state=YYY +// 2. 形如 code#state 的裸串 +// 3. 纯 code +// +// 返回 code 与(若能识别)state 覆盖值。 +func extractCode(input string) (code, stateOverride string) { + input = strings.TrimSpace(input) + if input == "" { + return "", "" + } + if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") { + if u, err := url.Parse(input); err == nil { + q := u.Query() + if c := strings.TrimSpace(q.Get("code")); c != "" { + return c, strings.TrimSpace(q.Get("state")) + } + } + } + // 裸串:交给 ExchangeCode 自行按 # 拆分 state。 + return input, "" +} + +// diagnoseClaudeLoginError 按报错内容给出可能原因,便于快速定位。 +func diagnoseClaudeLoginError(err error) { + msg := strings.ToLower(err.Error()) + fmt.Fprintln(os.Stderr, "\n—— 诊断提示 ——") + switch { + case strings.Contains(msg, "cloudflare") || strings.Contains(msg, "just a moment") || strings.Contains(msg, "\" -proxy http://127.0.0.1:7890") + case strings.Contains(msg, "invalid_grant") || strings.Contains(msg, "code") && strings.Contains(msg, "expired"): + fmt.Fprintln(os.Stderr, "授权码无效或已过期(常见:重复运行了第一步导致 session/verifier 与 code 不匹配,或 code 用过一次)。") + fmt.Fprintln(os.Stderr, "请重新执行第一步 `go run ./cmd/claude_login` 生成新 URL,授权后立刻用新 code 执行第二步。") + case strings.Contains(msg, "invalid_client") || strings.Contains(msg, "unauthorized_client") || strings.Contains(msg, "redirect_uri"): + fmt.Fprintln(os.Stderr, "client_id / redirect_uri 被拒。若确认参数无误,可能是 Anthropic 侧调整,请反馈完整报错。") + case strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline") || strings.Contains(msg, "no such host") || strings.Contains(msg, "connection refused") || strings.Contains(msg, "tls"): + fmt.Fprintln(os.Stderr, "网络/TLS 层失败。请检查能否直连 platform.claude.com,或加 -proxy 走代理重试。") + default: + fmt.Fprintln(os.Stderr, "未能自动归类。请把上面这行完整报错发给我以便定位。") + } + fmt.Fprintln(os.Stderr, "————————————") +} + +func safePrefix(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/database/account_channel_test.go b/database/account_channel_test.go index 04aa51a14..2072c854b 100644 --- a/database/account_channel_test.go +++ b/database/account_channel_test.go @@ -16,6 +16,7 @@ func TestAPIKeyLimitsResolveUpstreamChannel(t *testing.T) { {name: "codex", in: " CODEX ", want: UpstreamChannelCodex}, {name: "grok", in: "Grok", want: UpstreamChannelGrok}, {name: "antigravity", in: " Antigravity ", want: UpstreamChannelAntigravity}, + {name: "claude", in: " Claude ", want: UpstreamChannelClaude}, {name: "unknown", in: "other", want: UpstreamChannelAuto}, } for _, tt := range tests { @@ -78,6 +79,16 @@ func TestSQLiteListAccountListProjectionByChannel(t *testing.T) { if err != nil { t.Fatalf("insert antigravity account: %v", err) } + claudeID, err := db.InsertAccountWithUpstream(ctx, "claude", "anthropic", "oauth", map[string]interface{}{ + "upstream_type": "claude", + "access_token": "claude-secret", + "claude_usage_probe_at": "2026-08-29T05:00:00Z", + "claude_usage_probe_error": "", + "models": []string{"claude-sonnet-4-5"}, + }, "") + if err != nil { + t.Fatalf("insert Claude account: %v", err) + } tests := []struct { channel string @@ -86,6 +97,7 @@ func TestSQLiteListAccountListProjectionByChannel(t *testing.T) { {channel: UpstreamChannelCodex, wantID: codexID}, {channel: UpstreamChannelGrok, wantID: grokID}, {channel: UpstreamChannelAntigravity, wantID: antigravityID}, + {channel: UpstreamChannelClaude, wantID: claudeID}, } for _, tt := range tests { t.Run(tt.channel, func(t *testing.T) { @@ -99,6 +111,9 @@ func TestSQLiteListAccountListProjectionByChannel(t *testing.T) { if tt.channel == UpstreamChannelAntigravity && (rows[0].GetCredential("avatar_url") == "" || !rows[0].GetCredentialBool("verified_email") || rows[0].GetCredential("project_id") != "project-1" || rows[0].GetCredential("antigravity_sync_error") != "sync failed" || rows[0].GetCredential("antigravity_sync_warning") == "" || rows[0].GetCredential("antigravity_permissions") == "" || rows[0].GetCredential("antigravity_quota") == "") { t.Fatalf("Antigravity projection omitted control-plane status fields: %#v", rows[0].Credentials) } + if tt.channel == UpstreamChannelClaude && (rows[0].GetCredential("claude_usage_probe_at") == "" || rows[0].GetCredential("claude_usage_probe_error") != "" || len(rows[0].GetCredentialStringSlice("models")) != 1) { + t.Fatalf("Claude projection omitted sampling metadata: %#v", rows[0].Credentials) + } }) } } diff --git a/database/account_groups.go b/database/account_groups.go index a8786c4cc..b1021a2d9 100644 --- a/database/account_groups.go +++ b/database/account_groups.go @@ -32,6 +32,7 @@ const ( AccountGroupChannelCodex = "codex" AccountGroupChannelGrok = "grok" AccountGroupChannelAntigravity = "antigravity" + AccountGroupChannelClaude = "claude" ) // NormalizeAccountGroupChannel 归一分组渠道,空/非法一律按 codex。 @@ -41,6 +42,8 @@ func NormalizeAccountGroupChannel(channel string) string { return AccountGroupChannelGrok case AccountGroupChannelAntigravity: return AccountGroupChannelAntigravity + case AccountGroupChannelClaude: + return AccountGroupChannelClaude } return AccountGroupChannelCodex } diff --git a/database/account_list_projection.go b/database/account_list_projection.go index 6834c9a17..242915859 100644 --- a/database/account_list_projection.go +++ b/database/account_list_projection.go @@ -20,7 +20,8 @@ func (db *DB) ListAccountListProjection(ctx context.Context, channel string) ([] models jsonb, api_key text, refresh_token text, scheduler_priority text, avatar_url text, verified_email boolean, project_id text, antigravity_sync_error text, antigravity_sync_warning text, - antigravity_permissions text, antigravity_entitlements text, antigravity_quota text + antigravity_permissions text, antigravity_entitlements text, antigravity_quota text, + claude_usage_probe_at text, claude_usage_probe_error text )` credentialColumns := ` COALESCE(account_public.upstream_type, ''), @@ -37,7 +38,9 @@ func (db *DB) ListAccountListProjection(ctx context.Context, channel string) ([] COALESCE(account_public.antigravity_sync_error, ''), COALESCE(account_public.antigravity_sync_warning, ''), COALESCE(NULLIF(account_public.antigravity_permissions, ''), account_public.antigravity_entitlements, ''), - COALESCE(account_public.antigravity_quota, '')` + COALESCE(account_public.antigravity_quota, ''), + COALESCE(account_public.claude_usage_probe_at, ''), + COALESCE(account_public.claude_usage_probe_error, '')` if db.isSQLite() { upstreamExpr = `LOWER(COALESCE(json_extract(credentials, '$.upstream_type'), ''))` fromClause = `FROM accounts` @@ -56,7 +59,9 @@ func (db *DB) ListAccountListProjection(ctx context.Context, channel string) ([] COALESCE(json_extract(credentials, '$.antigravity_sync_error'), ''), COALESCE(json_extract(credentials, '$.antigravity_sync_warning'), ''), COALESCE(NULLIF(json_extract(credentials, '$.antigravity_permissions'), ''), json_extract(credentials, '$.antigravity_entitlements'), '{}'), - COALESCE(json_extract(credentials, '$.antigravity_quota'), '{}')` + COALESCE(json_extract(credentials, '$.antigravity_quota'), '{}'), + COALESCE(json_extract(credentials, '$.claude_usage_probe_at'), ''), + COALESCE(json_extract(credentials, '$.claude_usage_probe_error'), '')` } where += accountChannelFilterSQL(channel, upstreamExpr) query := `SELECT id, name, type, proxy_url, status, cooldown_reason, cooldown_until, @@ -90,6 +95,7 @@ func scanAccountListProjection(scanner accountProjectionScanner) (*AccountRow, e var upstreamType, email, baseURL, planType, schedulerPriority string var avatarURL, projectID string var antigravitySyncError, antigravitySyncWarning, antigravityPermissions, antigravityQuota string + var claudeUsageProbeAt, claudeUsageProbeError string var modelsRaw interface{} var hasAPIKey, hasRefreshToken, verifiedEmail bool if err := scanner.Scan( @@ -100,6 +106,7 @@ func scanAccountListProjection(scanner accountProjectionScanner) (*AccountRow, e &hasAPIKey, &hasRefreshToken, &schedulerPriority, &avatarURL, &verifiedEmail, &projectID, &antigravitySyncError, &antigravitySyncWarning, &antigravityPermissions, &antigravityQuota, + &claudeUsageProbeAt, &claudeUsageProbeError, ); err != nil { return nil, fmt.Errorf("扫描账号列表投影失败: %w", err) } @@ -149,6 +156,12 @@ func scanAccountListProjection(scanner accountProjectionScanner) (*AccountRow, e if trimmed := strings.TrimSpace(antigravityQuota); trimmed != "" && trimmed != "{}" { row.Credentials["antigravity_quota"] = trimmed } + if trimmed := strings.TrimSpace(claudeUsageProbeAt); trimmed != "" { + row.Credentials["claude_usage_probe_at"] = trimmed + } + if trimmed := strings.TrimSpace(claudeUsageProbeError); trimmed != "" { + row.Credentials["claude_usage_probe_error"] = trimmed + } if models := decodeProjectionStringSlice(modelsRaw); len(models) > 0 { row.Credentials["models"] = models } diff --git a/database/billing.go b/database/billing.go index 70587053a..643c6196c 100644 --- a/database/billing.go +++ b/database/billing.go @@ -490,16 +490,23 @@ func modelMatchesRule(model string, rule string) bool { func claudeFamilyPricing(model string) *ModelPricing { switch { case strings.Contains(model, "opus"): - if strings.Contains(model, "4.7") || strings.Contains(model, "4-7") || - strings.Contains(model, "4.6") || strings.Contains(model, "4-6") || - strings.Contains(model, "4.5") || strings.Contains(model, "4-5") { - return &ModelPricing{InputPricePerMToken: 5.0, OutputPricePerMToken: 25.0} + // 传统 Opus(3 / 4 / 4.1)为 $15/$75;自 4.5 起 Opus 降至 $5/$25,更新的版本 + // (4.6/4.7/4.8/5…)默认沿用现代档,避免新模型误套旧高价。 + legacyOpus := strings.Contains(model, "opus-3") || strings.Contains(model, "3-opus") || + strings.Contains(model, "opus-4-1") || strings.Contains(model, "opus-4.1") || + strings.Contains(model, "opus-4-0") || strings.Contains(model, "opus-4-2025") + if legacyOpus { + return &ModelPricing{InputPricePerMToken: 15.0, OutputPricePerMToken: 75.0} } - return &ModelPricing{InputPricePerMToken: 15.0, OutputPricePerMToken: 75.0} + return &ModelPricing{InputPricePerMToken: 5.0, OutputPricePerMToken: 25.0} case strings.Contains(model, "sonnet"): return &ModelPricing{InputPricePerMToken: 3.0, OutputPricePerMToken: 15.0} case strings.Contains(model, "haiku"): - if strings.Contains(model, "3-5") || strings.Contains(model, "3.5") { + // 3.5 与 4.x Haiku 均为 $1/$5;仅初代 claude-3-haiku 为 $0.25/$1.25。 + if strings.Contains(model, "3-5") || strings.Contains(model, "3.5") || + strings.Contains(model, "4-5") || strings.Contains(model, "4.5") || + strings.Contains(model, "4-6") || strings.Contains(model, "4.6") || + strings.Contains(model, "4-7") || strings.Contains(model, "4.7") { return &ModelPricing{InputPricePerMToken: 1.0, OutputPricePerMToken: 5.0} } return &ModelPricing{InputPricePerMToken: 0.25, OutputPricePerMToken: 1.25} diff --git a/database/claude_provider_migration_test.go b/database/claude_provider_migration_test.go new file mode 100644 index 000000000..68ad38da7 --- /dev/null +++ b/database/claude_provider_migration_test.go @@ -0,0 +1,117 @@ +package database + +import ( + "context" + "database/sql" + "path/filepath" + "testing" +) + +func TestBackfillClaudeProviderDataIsConservative(t *testing.T) { + db, err := New("sqlite", filepath.Join(t.TempDir(), "claude-provider-migration.db")) + if err != nil { + t.Fatalf("database.New: %v", err) + } + defer db.Close() + ctx := context.Background() + + claudeID, err := db.InsertAccountWithUpstream(ctx, "claude", "anthropic", "oauth", map[string]interface{}{ + "upstream_type": "claude", + "access_token": "claude-token", + "refresh_token": "claude-refresh", + }, "") + if err != nil { + t.Fatalf("insert Claude account: %v", err) + } + codexID, err := db.InsertAccountWithUpstream(ctx, "codex", "openai", "oauth", map[string]interface{}{ + "upstream_type": "codex", + "access_token": "codex-token", + }, "") + if err != nil { + t.Fatalf("insert Codex account: %v", err) + } + if _, err := db.conn.ExecContext(ctx, ` + INSERT INTO usage_logs (account_id, credential_generation, channel, endpoint, model, status_code) + VALUES (?, ?, 'codex', '/v1/messages', 'claude-sonnet-4-5', 200), + (?, ?, 'codex', '/v1/responses', 'gpt-5.4', 200), + (?, ?, 'codex', '/v1/messages', 'claude-sonnet-4-5', 200)`, + claudeID, 1, claudeID, 1, claudeID, 999); err != nil { + t.Fatalf("insert usage fixtures: %v", err) + } + if _, err := db.conn.ExecContext(ctx, ` + INSERT INTO usage_logs (account_id, credential_generation, channel, endpoint, model, status_code) + VALUES (?, ?, 'codex', '/v1/messages', 'claude-sonnet-4-5', 200)`, codexID, 1); err != nil { + t.Fatalf("insert Codex usage fixture: %v", err) + } + + pureClaude, err := db.CreateAccountGroup(ctx, "pure-claude", "", "", 0, 0, sql.NullInt64{}) + if err != nil { + t.Fatalf("create Claude group: %v", err) + } + mixed, err := db.CreateAccountGroup(ctx, "mixed", "", "", 0, 0, sql.NullInt64{}) + if err != nil { + t.Fatalf("create mixed group: %v", err) + } + if _, err := db.conn.ExecContext(ctx, `INSERT INTO account_group_members (account_id, group_id) VALUES (?, ?), (?, ?)`, claudeID, pureClaude, claudeID, mixed); err != nil { + t.Fatalf("insert Claude group memberships: %v", err) + } + if _, err := db.conn.ExecContext(ctx, `INSERT INTO account_group_members (account_id, group_id) VALUES (?, ?)`, codexID, mixed); err != nil { + t.Fatalf("insert mixed group membership: %v", err) + } + + tx, err := db.conn.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("begin migration transaction: %v", err) + } + if err := db.backfillClaudeProviderData(ctx, tx); err != nil { + tx.Rollback() + t.Fatalf("backfillClaudeProviderData: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit migration transaction: %v", err) + } + + rows, err := db.conn.QueryContext(ctx, `SELECT account_id, credential_generation, channel FROM usage_logs ORDER BY id`) + if err != nil { + t.Fatalf("read usage fixtures: %v", err) + } + defer rows.Close() + var channels []string + for rows.Next() { + var accountID, generation int64 + var channel string + if err := rows.Scan(&accountID, &generation, &channel); err != nil { + t.Fatal(err) + } + channels = append(channels, channel) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if got, want := channels, []string{"claude", "codex", "codex", "codex"}; len(got) != len(want) { + t.Fatalf("migrated channels = %v, want %v", got, want) + } else { + for i := range want { + if got[i] != want[i] { + t.Fatalf("migrated channels = %v, want %v", got, want) + } + } + } + + groups, err := db.ListAccountGroups(ctx) + if err != nil { + t.Fatalf("list groups: %v", err) + } + for _, group := range groups { + switch group.ID { + case pureClaude: + if group.Channel != AccountGroupChannelClaude { + t.Fatalf("pure Claude group channel = %q, want claude", group.Channel) + } + case mixed: + if group.Channel != AccountGroupChannelCodex { + t.Fatalf("mixed group channel = %q, want codex", group.Channel) + } + } + } +} diff --git a/database/credential_crypto.go b/database/credential_crypto.go new file mode 100644 index 000000000..9d8f8a685 --- /dev/null +++ b/database/credential_crypto.go @@ -0,0 +1,165 @@ +package database + +// 账号凭据落库加密(可选,默认关闭)。 +// +// 设计目标:把 credentials JSONB 里的敏感字段(access_token / refresh_token / +// session_token / api_key / id_token / agent_private_key / client_secret)在写库时 +// 加密、读出时解密,而**不改动任何上层调用**,也不破坏平台既有的两类 SQL 依赖: +// 1. 调度 outbox 触发器按 OLD/NEW 的 access_token 等做**变更检测**; +// 2. 账号列表投影按 `<> ''` 做**存在性检查**。 +// 为此采用**确定性 AEAD**(nonce 由 HMAC(key, field||plaintext) 派生):同一明文恒 +// 得同一密文 → 变更检测语义不变;密文非空 → 存在性检查不变。 +// +// 开关:环境变量 CODEX_CRED_ENCRYPTION_KEY。未设置时所有函数是 no-op,行为与不加密 +// 完全一致(存量明文账号照常工作)。设置后:新写入的敏感字段加密,读取端透明解密; +// 存量明文行因无 enc: 前缀被原样返回,继续可用(渐进迁移,改写时自动转密文)。 +// +// 注意:密钥一旦丢失,已加密的凭据无法解密,相关账号需重新导入——这是加密的固有代价。 + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "os" + "strings" + "sync" +) + +const credEncPrefix = "enc:v1:" + +// sensitiveCredentialKeys 是需要加密的凭据字段。仅这些字段加密;upstream_type / +// email / plan_type / models 等参与 SQL 过滤的字段保持明文。 +var sensitiveCredentialKeys = map[string]struct{}{ + "access_token": {}, + "refresh_token": {}, + "session_token": {}, + "api_key": {}, + "id_token": {}, + "agent_private_key": {}, + "client_secret": {}, +} + +var ( + credKeyOnce sync.Once + credKey []byte // 32 字节;nil 表示未启用 +) + +// credCipherKey 惰性读取并派生密钥(SHA-256(env 值)→ 32 字节)。未设置返回 nil。 +func credCipherKey() []byte { + credKeyOnce.Do(func() { + if v := strings.TrimSpace(os.Getenv("CODEX_CRED_ENCRYPTION_KEY")); v != "" { + sum := sha256.Sum256([]byte(v)) + credKey = sum[:] + } + }) + return credKey +} + +// setCredEncryptionKeyForTest 仅供测试注入/清空密钥。 +func setCredEncryptionKeyForTest(raw string) { + credKeyOnce.Do(func() {}) // 标记 once 已触发,避免后续 env 覆盖 + if strings.TrimSpace(raw) == "" { + credKey = nil + return + } + sum := sha256.Sum256([]byte(raw)) + credKey = sum[:] +} + +// encryptCredentialValue 加密单个字段值。已加密 / 空值 / 未启用时原样返回。 +func encryptCredentialValue(field, plaintext string) string { + key := credCipherKey() + if key == nil || plaintext == "" || strings.HasPrefix(plaintext, credEncPrefix) { + return plaintext + } + block, err := aes.NewCipher(key) + if err != nil { + return plaintext + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return plaintext + } + // 确定性 nonce:HMAC(key, field || 0x00 || plaintext) 截断到 nonce 长度。 + // 同明文恒得同 nonce/密文(保变更检测);不同明文几乎必得不同 nonce(GCM 安全)。 + mac := hmac.New(sha256.New, key) + mac.Write([]byte(field)) + mac.Write([]byte{0}) + mac.Write([]byte(plaintext)) + nonce := mac.Sum(nil)[:gcm.NonceSize()] + // AAD=field,把密文绑定到字段,防止跨字段搬运。 + ct := gcm.Seal(nil, nonce, []byte(plaintext), []byte(field)) + buf := make([]byte, 0, len(nonce)+len(ct)) + buf = append(buf, nonce...) + buf = append(buf, ct...) + return credEncPrefix + base64.RawURLEncoding.EncodeToString(buf) +} + +// decryptCredentialValue 解密单个字段值。无前缀 / 未启用 / 解密失败时原样返回。 +func decryptCredentialValue(field, value string) string { + if !strings.HasPrefix(value, credEncPrefix) { + return value + } + key := credCipherKey() + if key == nil { + return value + } + raw, err := base64.RawURLEncoding.DecodeString(value[len(credEncPrefix):]) + if err != nil { + return value + } + block, err := aes.NewCipher(key) + if err != nil { + return value + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return value + } + if len(raw) < gcm.NonceSize() { + return value + } + nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] + pt, err := gcm.Open(nil, nonce, ct, []byte(field)) + if err != nil { + return value + } + return string(pt) +} + +// encryptSensitiveCredentials 返回一份浅拷贝,其中敏感字段被加密。未启用时原样返回入参。 +// 在每个写库函数 marshal 之前调用。 +func encryptSensitiveCredentials(m map[string]interface{}) map[string]interface{} { + if credCipherKey() == nil || m == nil { + return m + } + out := make(map[string]interface{}, len(m)) + for k, v := range m { + if _, ok := sensitiveCredentialKeys[k]; ok { + if s, isStr := v.(string); isStr { + out[k] = encryptCredentialValue(k, s) + continue + } + } + out[k] = v + } + return out +} + +// decryptSensitiveCredentialsInPlace 就地解密 map 里的敏感字段。在 decodeCredentials +// 里调用,使所有 Go 读取端(GetCredential / 各处 map 直读)统一见明文。 +func decryptSensitiveCredentialsInPlace(m map[string]interface{}) { + if credCipherKey() == nil || m == nil { + return + } + for k, v := range m { + if _, ok := sensitiveCredentialKeys[k]; !ok { + continue + } + if s, isStr := v.(string); isStr { + m[k] = decryptCredentialValue(k, s) + } + } +} diff --git a/database/credential_crypto_test.go b/database/credential_crypto_test.go new file mode 100644 index 000000000..3a87d0a54 --- /dev/null +++ b/database/credential_crypto_test.go @@ -0,0 +1,162 @@ +package database + +import ( + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" +) + +func TestCredentialCrypto_RoundTrip(t *testing.T) { + setCredEncryptionKeyForTest("test-master-key-123") + defer setCredEncryptionKeyForTest("") + + m := map[string]interface{}{ + "upstream_type": "claude", + "access_token": "sk-at-secret", + "refresh_token": "rt-secret", + "email": "user@example.com", + "plan_type": "claude", + } + enc := encryptSensitiveCredentials(m) + // 敏感字段应被加密(带前缀),非敏感字段原样。 + if !strings.HasPrefix(enc["access_token"].(string), credEncPrefix) { + t.Fatalf("access_token 未加密: %v", enc["access_token"]) + } + if !strings.HasPrefix(enc["refresh_token"].(string), credEncPrefix) { + t.Fatalf("refresh_token 未加密") + } + if enc["email"] != "user@example.com" || enc["upstream_type"] != "claude" { + t.Fatal("非敏感字段不应改动") + } + // 原 map 不应被 mutate(返回副本)。 + if strings.HasPrefix(m["access_token"].(string), credEncPrefix) { + t.Fatal("encryptSensitiveCredentials 不应 mutate 入参") + } + + // 模拟落库→读出:marshal(enc) 再 decodeCredentials 应还原明文。 + raw, _ := json.Marshal(enc) + decoded := decodeCredentials(raw) + if decoded["access_token"] != "sk-at-secret" || decoded["refresh_token"] != "rt-secret" { + t.Fatalf("解密还原失败: at=%v rt=%v", decoded["access_token"], decoded["refresh_token"]) + } +} + +func TestCredentialCrypto_Deterministic(t *testing.T) { + setCredEncryptionKeyForTest("k") + defer setCredEncryptionKeyForTest("") + // 同明文两次加密应得同密文(保 outbox 变更检测语义)。 + a := encryptCredentialValue("access_token", "same-token") + b := encryptCredentialValue("access_token", "same-token") + if a != b { + t.Fatalf("确定性加密应产生相同密文: %s vs %s", a, b) + } + // 不同明文应得不同密文。 + c := encryptCredentialValue("access_token", "other-token") + if a == c { + t.Fatal("不同明文不应同密文") + } + // 不同字段(AAD)同明文应得不同密文。 + d := encryptCredentialValue("refresh_token", "same-token") + if a == d { + t.Fatal("不同字段应绑定不同密文") + } +} + +func TestCredentialCrypto_Disabled_NoOp(t *testing.T) { + setCredEncryptionKeyForTest("") // 未启用 + defer setCredEncryptionKeyForTest("") + m := map[string]interface{}{"access_token": "plain", "refresh_token": "plain2"} + enc := encryptSensitiveCredentials(m) + if enc["access_token"] != "plain" { + t.Fatal("未启用时应原样返回(no-op)") + } + raw, _ := json.Marshal(enc) + decoded := decodeCredentials(raw) + if decoded["access_token"] != "plain" { + t.Fatal("未启用时解密应原样") + } +} + +func TestCredentialCrypto_BackwardCompat_PlaintextRows(t *testing.T) { + // 存量明文行:即使启用密钥,无 enc: 前缀的值应原样读出(渐进迁移)。 + setCredEncryptionKeyForTest("k") + defer setCredEncryptionKeyForTest("") + raw := []byte(`{"access_token":"legacy-plain","refresh_token":"legacy-rt","upstream_type":"codex"}`) + decoded := decodeCredentials(raw) + if decoded["access_token"] != "legacy-plain" || decoded["refresh_token"] != "legacy-rt" { + t.Fatalf("存量明文应原样读出: %v", decoded) + } +} + +func TestCredentialCrypto_WrongKey_FailsClosed(t *testing.T) { + setCredEncryptionKeyForTest("key-A") + enc := encryptCredentialValue("access_token", "secret") + // 换密钥后解密失败,返回原密文(而非明文),账号需重导——不误当明文用。 + setCredEncryptionKeyForTest("key-B") + defer setCredEncryptionKeyForTest("") + got := decryptCredentialValue("access_token", enc) + if got == "secret" { + t.Fatal("错误密钥不应解出明文") + } + if !strings.HasPrefix(got, credEncPrefix) { + t.Fatal("解密失败应返回原密文") + } +} + +func TestCredentialCrypto_DBRoundTrip_AtRestEncrypted(t *testing.T) { + setCredEncryptionKeyForTest("db-master-key") + defer setCredEncryptionKeyForTest("") + + db, err := New("sqlite", filepath.Join(t.TempDir(), "cred-crypto.db")) + if err != nil { + t.Fatalf("New sqlite: %v", err) + } + defer db.Close() + ctx := context.Background() + + id, err := db.InsertAccountWithUpstream(ctx, "claude", "anthropic", "oauth", map[string]interface{}{ + "upstream_type": "claude", + "access_token": "at-plain-secret", + "refresh_token": "rt-plain-secret", + "email": "u@example.com", + }, "") + if err != nil { + t.Fatalf("insert: %v", err) + } + + // 读回:GetCredential 应见明文。 + row, err := db.GetAccountByID(ctx, id) + if err != nil { + t.Fatalf("get: %v", err) + } + if row.GetCredential("access_token") != "at-plain-secret" || row.GetCredential("refresh_token") != "rt-plain-secret" { + t.Fatalf("读回应为明文: at=%q rt=%q", row.GetCredential("access_token"), row.GetCredential("refresh_token")) + } + + // 底层存储应为密文(enc: 前缀)。 + var rawCred string + if err := db.conn.QueryRowContext(ctx, "SELECT credentials FROM accounts WHERE id = ?", id).Scan(&rawCred); err != nil { + t.Fatalf("raw select: %v", err) + } + if strings.Contains(rawCred, "at-plain-secret") || strings.Contains(rawCred, "rt-plain-secret") { + t.Fatalf("底层不应含明文 token: %s", rawCred) + } + if !strings.Contains(rawCred, credEncPrefix) { + t.Fatalf("底层应为密文(含 %s 前缀): %s", credEncPrefix, rawCred) + } + // email(非敏感)应仍是明文,供 SQL 过滤。 + if !strings.Contains(rawCred, "u@example.com") { + t.Fatalf("非敏感字段应保持明文: %s", rawCred) + } + + // UpdateCredentials 往返:刷新 token 后读回仍明文。 + if err := db.UpdateCredentials(ctx, id, map[string]interface{}{"access_token": "at-refreshed"}); err != nil { + t.Fatalf("update: %v", err) + } + row2, _ := db.GetAccountByID(ctx, id) + if row2.GetCredential("access_token") != "at-refreshed" { + t.Fatalf("更新后读回应为新明文, got %q", row2.GetCredential("access_token")) + } +} diff --git a/database/data_migrations.go b/database/data_migrations.go index 617f2b3e9..8eca528cf 100644 --- a/database/data_migrations.go +++ b/database/data_migrations.go @@ -26,7 +26,10 @@ const ( // account_groups.channel 归类:成员全为 Grok 账号的存量分组标记为 grok 渠道, // 其余(含空组/混合组)保持 codex。此后分组按渠道隔离,写入路径强校验。 dataMigrationGroupChannelV1 = "20260807_account_group_channel_v1" - dataMigrationTimeout = 5 * time.Minute + // Claude 原生渠道上线后的存量回填:只修复能从当前账号、端点或模型可靠 + // 识别的记录;不把混合分组或历史不明请求强行改写成 Claude。 + dataMigrationClaudeProviderV1 = "20260829_claude_provider_backfill_v1" + dataMigrationTimeout = 5 * time.Minute ) type oauthIdentityDedupeAccount struct { @@ -54,7 +57,10 @@ func (db *DB) runDataMigrations(ctx context.Context) error { if err := db.runDataMigrationOnce(ctx, dataMigrationWorkspaceIdentityV3, db.migrateWorkspaceIdentityV3); err != nil { return err } - return db.runDataMigrationOnce(ctx, dataMigrationGroupChannelV1, db.classifyAccountGroupChannels) + if err := db.runDataMigrationOnce(ctx, dataMigrationGroupChannelV1, db.classifyAccountGroupChannels); err != nil { + return err + } + return db.runDataMigrationOnce(ctx, dataMigrationClaudeProviderV1, db.backfillClaudeProviderData) } // classifyAccountGroupChannels 把成员清一色是 Grok 账号的存量分组归到 grok 渠道。 @@ -111,6 +117,131 @@ func (db *DB) backfillUsageLogChannel(ctx context.Context, tx *sql.Tx) error { return nil } +// backfillClaudeProviderData repairs two conservative pieces of provider +// metadata for databases that predate the native Claude channel. Usage rows are +// updated only when their endpoint/model clearly identifies Anthropic Messages +// traffic and the credential generation still matches the account (or is a +// legacy zero). Pure/mixed groups are left untouched; only groups whose active +// members are all Claude accounts are promoted from the legacy Codex channel. +func (db *DB) backfillClaudeProviderData(ctx context.Context, tx *sql.Tx) error { + upstreamTypeExpr := `LOWER(COALESCE(a.credentials->>'upstream_type', ''))` + if db.isSQLite() { + upstreamTypeExpr = `LOWER(COALESCE(json_extract(a.credentials, '$.upstream_type'), ''))` + } + // Do not update usage_logs with a correlated account subquery. On a large + // history that shape forces a full usage_logs scan (and a repeated accounts + // scan) while the migration holds the startup write transaction. Resolve the + // small set of Claude account generations once, then update by the existing + // account_id/credential_generation indexes in bounded batches. + accountRows, err := tx.QueryContext(ctx, ` + SELECT id, COALESCE(credential_generation, 0) + FROM accounts a + WHERE `+upstreamTypeExpr+` = 'claude' + ORDER BY id`) + if err != nil { + return fmt.Errorf("读取 Claude 账号代际: %w", err) + } + const batchSize = 500 + zeroGenerationIDs := make([]int64, 0) + idsByGeneration := make(map[int64][]int64) + for accountRows.Next() { + var id, generation int64 + if err := accountRows.Scan(&id, &generation); err != nil { + accountRows.Close() + return fmt.Errorf("读取 Claude 账号代际: %w", err) + } + if generation <= 0 { + zeroGenerationIDs = append(zeroGenerationIDs, id) + continue + } + idsByGeneration[generation] = append(idsByGeneration[generation], id) + } + if err := accountRows.Err(); err != nil { + accountRows.Close() + return fmt.Errorf("读取 Claude 账号代际: %w", err) + } + if err := accountRows.Close(); err != nil { + return fmt.Errorf("关闭 Claude 账号代际游标: %w", err) + } + + updateUsageBatch := func(ids []int64, generation *int64) (int64, error) { + var affectedTotal int64 + for start := 0; start < len(ids); start += batchSize { + end := start + batchSize + if end > len(ids) { + end = len(ids) + } + batch := ids[start:end] + placeholders := dbPlaceholders(db.isSQLite(), 1, len(batch)) + args := argsFromInt64s(batch) + generationPredicate := "COALESCE(credential_generation, 0) = 0" + if generation != nil { + args = append(args, *generation) + generationPlaceholder := "?" + if !db.isSQLite() { + generationPlaceholder = fmt.Sprintf("$%d", len(args)) + } + generationPredicate = "credential_generation = " + generationPlaceholder + } + usageQuery := fmt.Sprintf(` + UPDATE usage_logs + SET channel = 'claude' + WHERE COALESCE(channel, '') IN ('', 'codex') + AND account_id IN (%s) + AND (LOWER(COALESCE(endpoint, '')) LIKE '/v1/messages%%' + OR LOWER(COALESCE(model, '')) LIKE 'claude-%%') + AND %s`, strings.Join(placeholders, ","), generationPredicate) + res, err := tx.ExecContext(ctx, usageQuery, args...) + if err != nil { + return affectedTotal, fmt.Errorf("回填 Claude usage_logs 渠道: %w", err) + } + if affected, err := res.RowsAffected(); err == nil { + affectedTotal += affected + } + } + return affectedTotal, nil + } + + var usageAffected int64 + if affected, err := updateUsageBatch(zeroGenerationIDs, nil); err != nil { + return err + } else { + usageAffected += affected + } + generations := make([]int64, 0, len(idsByGeneration)) + for generation := range idsByGeneration { + generations = append(generations, generation) + } + sort.Slice(generations, func(i, j int) bool { return generations[i] < generations[j] }) + for _, generation := range generations { + if affected, err := updateUsageBatch(idsByGeneration[generation], &generation); err != nil { + return err + } else { + usageAffected += affected + } + } + if usageAffected > 0 { + log.Printf("[data_migration] %s: %d 条 usage_logs 回填为 Claude", dataMigrationClaudeProviderV1, usageAffected) + } + + groupQuery := ` + UPDATE account_groups SET channel = 'claude' + WHERE COALESCE(channel, 'codex') = 'codex' AND id IN ( + SELECT m.group_id + FROM account_group_members m + JOIN accounts a ON a.id = m.account_id + WHERE a.status <> 'deleted' AND COALESCE(a.error_message, '') <> 'deleted' + GROUP BY m.group_id + HAVING COUNT(*) > 0 AND COUNT(*) = SUM(CASE WHEN ` + upstreamTypeExpr + ` = 'claude' THEN 1 ELSE 0 END) + )` + if res, err := tx.ExecContext(ctx, groupQuery); err != nil { + return fmt.Errorf("归类 Claude 分组: %w", err) + } else if affected, err := res.RowsAffected(); err == nil && affected > 0 { + log.Printf("[data_migration] %s: %d 个存量分组归类为 Claude 渠道", dataMigrationClaudeProviderV1, affected) + } + return nil +} + func (db *DB) runDataMigrationsWithTimeout() error { ctx, cancel := context.WithTimeout(context.Background(), dataMigrationTimeout) defer cancel() @@ -267,7 +398,7 @@ func (db *DB) migrateWorkspaceIdentityV3(ctx context.Context, tx *sql.Tx) error ) if workspaceID != "" && strings.EqualFold(tokenEmail, email) { account.credentials["workspace_id"] = workspaceID - encoded, err := json.Marshal(account.credentials) + encoded, err := json.Marshal(encryptSensitiveCredentials(account.credentials)) if err != nil { return err } diff --git a/database/grok_state.go b/database/grok_state.go index 0f85cd5d6..2ce40e2dc 100644 --- a/database/grok_state.go +++ b/database/grok_state.go @@ -551,7 +551,7 @@ func (db *DB) InsertGrokAccountIfAbsent(ctx context.Context, name string, creden if len(identityKeys) == 0 { return 0, 0, errors.New("grok credential has no stable identity") } - encoded, err := json.Marshal(credentialCopy) + encoded, err := json.Marshal(encryptSensitiveCredentials(credentialCopy)) if err != nil { return 0, 0, err } @@ -665,7 +665,7 @@ func (db *DB) ReauthGrokAccount(ctx context.Context, accountID int64, credential } } - encoded, marshalErr := json.Marshal(merged) + encoded, marshalErr := json.Marshal(encryptSensitiveCredentials(merged)) if marshalErr != nil { return marshalErr } @@ -848,7 +848,7 @@ func (db *DB) UpdateAccountCredentialsCAS(ctx context.Context, accountID, expect // Keep the compatibility JSON field synchronized with the canonical // column in the same write that publishes the rotated credential. merged["credential_family_id"] = familyID - encoded, marshalErr := json.Marshal(merged) + encoded, marshalErr := json.Marshal(encryptSensitiveCredentials(merged)) if marshalErr != nil { return marshalErr } @@ -928,7 +928,7 @@ func (db *DB) ReplaceAccountCredentialsCAS(ctx context.Context, accountID, expec familyID = "cf_" + strings.ReplaceAll(uuid.NewString(), "-", "") } merged["credential_family_id"] = familyID - encoded, marshalErr := json.Marshal(merged) + encoded, marshalErr := json.Marshal(encryptSensitiveCredentials(merged)) if marshalErr != nil { return marshalErr } @@ -1006,7 +1006,7 @@ func (db *DB) MergeAccountCredentialsForGeneration(ctx context.Context, accountI if current != expectedGeneration { return nil } - encoded, marshalErr := json.Marshal(mergeCredentialMaps(decodeCredentials(raw), filtered)) + encoded, marshalErr := json.Marshal(encryptSensitiveCredentials(mergeCredentialMaps(decodeCredentials(raw), filtered))) if marshalErr != nil { return marshalErr } diff --git a/database/helpers.go b/database/helpers.go index 31f9218d7..42dab36cd 100644 --- a/database/helpers.go +++ b/database/helpers.go @@ -146,6 +146,8 @@ func decodeCredentials(raw interface{}) map[string]interface{} { if out == nil { return map[string]interface{}{} } + // 统一读扼要点:解密敏感字段,使所有 Go 读取端见明文(密钥未设时为 no-op)。 + decryptSensitiveCredentialsInPlace(out) return out } diff --git a/database/official_pricing_sync.go b/database/official_pricing_sync.go index 717d0f2e7..0fd216844 100644 --- a/database/official_pricing_sync.go +++ b/database/official_pricing_sync.go @@ -20,6 +20,7 @@ type OfficialPricingSyncConfig struct { IntervalMinutes int `json:"interval_minutes"` IncludeOpenAI bool `json:"include_openai"` IncludeGrok bool `json:"include_grok"` + IncludeClaude bool `json:"include_claude"` LastAttemptAt sql.NullTime `json:"-"` LastSuccessAt sql.NullTime `json:"-"` LastError string `json:"last_error,omitempty"` @@ -53,6 +54,7 @@ func (db *DB) ensureOfficialPricingSyncConfig(ctx context.Context) error { interval_minutes INTEGER NOT NULL DEFAULT 1440, include_openai BOOLEAN NOT NULL DEFAULT TRUE, include_grok BOOLEAN NOT NULL DEFAULT TRUE, + include_claude BOOLEAN NOT NULL DEFAULT TRUE, last_attempt_at TIMESTAMP NULL, last_success_at TIMESTAMP NULL, last_error TEXT NOT NULL DEFAULT '', @@ -60,9 +62,11 @@ func (db *DB) ensureOfficialPricingSyncConfig(ctx context.Context) error { )`); err != nil { return err } + // 存量表补列(幂等):列已存在时忽略错误。 + _, _ = db.conn.ExecContext(ctx, `ALTER TABLE official_pricing_sync_config ADD COLUMN include_claude BOOLEAN NOT NULL DEFAULT TRUE`) _, err := db.conn.ExecContext(ctx, `INSERT INTO official_pricing_sync_config ( - singleton_id, enabled, interval_minutes, include_openai, include_grok - ) VALUES (1, FALSE, 1440, TRUE, TRUE) ON CONFLICT (singleton_id) DO NOTHING`) + singleton_id, enabled, interval_minutes, include_openai, include_grok, include_claude + ) VALUES (1, FALSE, 1440, TRUE, TRUE, TRUE) ON CONFLICT (singleton_id) DO NOTHING`) if err == nil { officialPricingConfigReady[db] = true } @@ -74,10 +78,10 @@ func (db *DB) GetOfficialPricingSyncConfig(ctx context.Context) (*OfficialPricin return nil, err } var cfg OfficialPricingSyncConfig - err := db.conn.QueryRowContext(ctx, `SELECT enabled, interval_minutes, include_openai, include_grok, + err := db.conn.QueryRowContext(ctx, `SELECT enabled, interval_minutes, include_openai, include_grok, include_claude, last_attempt_at, last_success_at, COALESCE(last_error, ''), COALESCE(last_warning, '') FROM official_pricing_sync_config WHERE singleton_id = 1`).Scan( - &cfg.Enabled, &cfg.IntervalMinutes, &cfg.IncludeOpenAI, &cfg.IncludeGrok, + &cfg.Enabled, &cfg.IntervalMinutes, &cfg.IncludeOpenAI, &cfg.IncludeGrok, &cfg.IncludeClaude, &cfg.LastAttemptAt, &cfg.LastSuccessAt, &cfg.LastError, &cfg.LastWarning, ) if err != nil { @@ -92,12 +96,12 @@ func (db *DB) UpdateOfficialPricingSyncConfig(ctx context.Context, cfg OfficialP return nil, err } cfg.IntervalMinutes = NormalizeOfficialPricingSyncInterval(cfg.IntervalMinutes) - if !cfg.IncludeOpenAI && !cfg.IncludeGrok { + if !cfg.IncludeOpenAI && !cfg.IncludeGrok && !cfg.IncludeClaude { return nil, fmt.Errorf("至少选择一个官方价格来源") } _, err := db.conn.ExecContext(ctx, `UPDATE official_pricing_sync_config - SET enabled = $1, interval_minutes = $2, include_openai = $3, include_grok = $4 - WHERE singleton_id = 1`, cfg.Enabled, cfg.IntervalMinutes, cfg.IncludeOpenAI, cfg.IncludeGrok) + SET enabled = $1, interval_minutes = $2, include_openai = $3, include_grok = $4, include_claude = $5 + WHERE singleton_id = 1`, cfg.Enabled, cfg.IntervalMinutes, cfg.IncludeOpenAI, cfg.IncludeGrok, cfg.IncludeClaude) if err != nil { return nil, err } diff --git a/database/postgres.go b/database/postgres.go index bc2e3d0f2..31d6eb2ba 100644 --- a/database/postgres.go +++ b/database/postgres.go @@ -1216,6 +1216,7 @@ func (db *DB) migrate(ctx context.Context) error { site_logo TEXT DEFAULT '', background_config TEXT DEFAULT '{}', grok_config TEXT DEFAULT '{}', + claude_config TEXT DEFAULT '{}', antigravity_oauth_config TEXT DEFAULT '{}', invite_guide_config TEXT DEFAULT '{}', max_concurrency INT DEFAULT 2, @@ -1266,6 +1267,7 @@ func (db *DB) migrate(ctx context.Context) error { ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS site_logo TEXT DEFAULT ''; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS background_config TEXT DEFAULT '{}'; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS grok_config TEXT DEFAULT '{}'; + ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS claude_config TEXT DEFAULT '{}'; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS antigravity_oauth_config TEXT DEFAULT '{}'; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS invite_guide_config TEXT DEFAULT '{}'; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS test_content TEXT DEFAULT 'hi'; @@ -1690,7 +1692,8 @@ type APIKeyLimits struct { // - ""/auto: 不限(默认,按模型路由) // - codex: 仅 Codex OAuth / OpenAI Responses 中转账号 // - grok: 仅 Grok 账号(此时不再要求账号声明模型,直接透传请求模型) - // - antigravity: 预留的 Antigravity 管理渠道;推理适配完成前 fail closed + // - antigravity: Antigravity 管理渠道 + // - claude: 仅 Claude OAuth / Anthropic Messages 账号 UpstreamChannel string `json:"upstream_channel,omitempty"` // ScopeLimits 是「该 Key × 某账号分组 / 某账号」维度的用量上限(issue #439)。 // 与上面的 Cost/Token 限额不同,它只统计该 Key 打到对应 scope 的用量,超额后默认 @@ -1711,6 +1714,7 @@ const ( UpstreamChannelCodex = "codex" UpstreamChannelGrok = "grok" UpstreamChannelAntigravity = "antigravity" + UpstreamChannelClaude = "claude" ) // ResolveUpstreamChannel 归一 Key 的上游渠道限定;未知值一律视为不限(auto)。 @@ -1722,6 +1726,8 @@ func (l APIKeyLimits) ResolveUpstreamChannel() string { return UpstreamChannelGrok case UpstreamChannelAntigravity: return UpstreamChannelAntigravity + case UpstreamChannelClaude: + return UpstreamChannelClaude } return UpstreamChannelAuto } @@ -1734,9 +1740,11 @@ func accountChannelFilterSQL(channel, upstreamTypeExpr string) string { return ` AND ` + upstreamTypeExpr + ` = 'grok'` case UpstreamChannelAntigravity: return ` AND ` + upstreamTypeExpr + ` = 'antigravity'` + case UpstreamChannelClaude: + return ` AND ` + upstreamTypeExpr + ` = 'claude'` case UpstreamChannelCodex: // Blank legacy rows and OpenAI Responses relays remain in the Codex view. - return ` AND ` + upstreamTypeExpr + ` NOT IN ('grok', 'antigravity')` + return ` AND ` + upstreamTypeExpr + ` NOT IN ('grok', 'antigravity', 'claude')` default: return "" } @@ -2173,6 +2181,7 @@ type SystemSettings struct { SiteLogo string BackgroundConfig string // JSON: {"image":"...","opacity":18,"blur":0} GrokConfig string // JSON: {"affinity_mode":"strict"} + ClaudeConfig string // JSON: {"fingerprint_mode":"preserve","default_timezone":"","session_window_limit":0} MaxConcurrency int GlobalRPM int TestModel string @@ -2530,7 +2539,8 @@ func (db *DB) GetSystemSettings(ctx context.Context) (*SystemSettings, error) { COALESCE(session_slot_buffer_enabled, false), COALESCE(session_slot_buffer_seconds, 10), COALESCE(models_list_read_max_bytes, 8388608), - COALESCE(auto_activate_5h_window_enabled, false) + COALESCE(auto_activate_5h_window_enabled, false), + COALESCE(claude_config, '{}') FROM system_settings WHERE id = 1 `).Scan( &s.SiteName, &s.SiteLogo, @@ -2610,6 +2620,7 @@ func (db *DB) GetSystemSettings(ctx context.Context) (*SystemSettings, error) { &s.SessionSlotBufferSeconds, &s.ModelsListReadMaxBytes, &s.AutoActivate5hWindowEnabled, + &s.ClaudeConfig, ) if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -3239,6 +3250,26 @@ func normalizeAffinityMode(mode string) string { } } +// normalizeClaudeConfig 校验 claude_config JSON,非法或空则回落到默认 {}。 +func normalizeClaudeConfig(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" || !json.Valid([]byte(raw)) { + return "{}" + } + return raw +} + +// UpdateClaudeConfig 定向更新 claude_config 单列(不回写整行设置,避免触碰大 UPSERT)。 +func (db *DB) UpdateClaudeConfig(ctx context.Context, raw string) error { + value := normalizeClaudeConfig(raw) + return db.withSQLiteWriteLock(ctx, func() error { + _, err := db.conn.ExecContext(ctx, ` + INSERT INTO system_settings (id, claude_config) VALUES (1, $1) + ON CONFLICT (id) DO UPDATE SET claude_config = EXCLUDED.claude_config`, value) + return err + }) +} + // normalizeGrokConfig 校验 grok_config JSON,非法或空则回落到默认 {}。 func normalizeGrokConfig(raw string) string { raw = strings.TrimSpace(raw) @@ -4742,7 +4773,7 @@ type TrafficSnapshot struct { // 当 rangeStart 为零值时回落到"今日"(本地 0 点起),与历史行为一致; // 当传入显式区间时,today_* 字段语义变为"该区间内的统计",total_* 字段始终是全量累计。 // rangeEnd 为零值表示"至今"。 -// GetUsageStats 聚合用量统计。channel 非空(codex/grok)时按渠道过滤; +// GetUsageStats 聚合用量统计。channel 非空(codex/grok/antigravity/claude)时按渠道过滤; // 渠道视图下的「累计」只覆盖现存 usage_logs(清空日志前的 baseline 无渠道维度,不计入)。 func (db *DB) GetUsageStats(ctx context.Context, rangeStart, rangeEnd time.Time, channel string) (*UsageStats, error) { return db.getUsageStats(ctx, rangeStart, rangeEnd, channel, true) @@ -6699,7 +6730,7 @@ func (db *DB) UpdateAccountSchedulerConfig(ctx context.Context, id int64, scoreB merged := mergeCredentialMaps(decodeCredentials(currentRaw), map[string]interface{}{ "allowed_api_key_ids": normalizePositiveInt64Slice(allowedAPIKeyIDs.Values), }) - credJSON, err := json.Marshal(merged) + credJSON, err := json.Marshal(encryptSensitiveCredentials(merged)) if err != nil { return fmt.Errorf("序列化 credentials 失败: %w", err) } @@ -6780,7 +6811,7 @@ func (db *DB) UpdateAccountSchedulerMetadata(ctx context.Context, id int64, scor current := decodeCredentials(currentRaw) merged := mergeCredentialMaps(cloneCredentialUpdates(current), credentialUpdates) identityChanged := grokIdentityCredentialChanged(current, merged) - credJSON, err := json.Marshal(merged) + credJSON, err := json.Marshal(encryptSensitiveCredentials(merged)) if err != nil { return fmt.Errorf("序列化 credentials 失败: %w", err) } @@ -6999,7 +7030,7 @@ func (db *DB) batchUpdateAccountCredentials(ctx context.Context, tx *sql.Tx, cur // generation bump. merged := mergeCredentialMaps(cloneCredentialUpdates(credentials), updates) identityChanged := grokIdentityCredentialChanged(credentials, merged) - credJSON, err := json.Marshal(merged) + credJSON, err := json.Marshal(encryptSensitiveCredentials(merged)) if err != nil { return fmt.Errorf("序列化 credentials 失败: %w", err) } @@ -7183,7 +7214,7 @@ func (db *DB) updateCredentialsReadMerge(ctx context.Context, id int64, credenti merged := mergeCredentialMaps(decodeCredentials(currentRaw), credentials) identityChanged := grokIdentityCredentialChanged(decodeCredentials(currentRaw), merged) - credJSON, err := json.Marshal(merged) + credJSON, err := json.Marshal(encryptSensitiveCredentials(merged)) if err != nil { return fmt.Errorf("序列化 credentials 失败: %w", err) } @@ -7218,6 +7249,12 @@ func (db *DB) updateCredentialsSQLite(ctx context.Context, id int64, credentials if !sqliteJSONSetKeySupported(key) { return db.updateCredentialsReadMergeSQLiteUnlocked(ctx, id, credentials) } + // SQLite 逐键写:敏感字段在此处按键加密(密钥未设时 no-op)。 + if _, sensitive := sensitiveCredentialKeys[key]; sensitive { + if s, isStr := value.(string); isStr { + value = encryptCredentialValue(key, s) + } + } valueJSON, err := json.Marshal(value) if err != nil { return fmt.Errorf("序列化 credentials 失败: %w", err) @@ -7268,7 +7305,7 @@ func (db *DB) updateCredentialsReadMergeSQLiteUnlocked(ctx context.Context, id i current := decodeCredentials(currentRaw) merged := mergeCredentialMaps(decodeCredentials(currentRaw), credentials) identityChanged := grokIdentityCredentialChanged(current, merged) - credJSON, err := json.Marshal(merged) + credJSON, err := json.Marshal(encryptSensitiveCredentials(merged)) if err != nil { return fmt.Errorf("序列化 credentials 失败: %w", err) } @@ -7353,7 +7390,7 @@ func (db *DB) UpdateOpenAIResponsesAccount(ctx context.Context, id int64, name s current := decodeCredentials(currentRaw) merged := mergeCredentialMaps(cloneCredentialUpdates(current), credentials) identityChanged := openAIResponsesIdentityCredentialChanged(current, merged) - credJSON, err := json.Marshal(merged) + credJSON, err := json.Marshal(encryptSensitiveCredentials(merged)) if err != nil { return fmt.Errorf("序列化 credentials 失败: %w", err) } @@ -7403,7 +7440,7 @@ func (db *DB) UpdateOAuthAccountCredentials(ctx context.Context, id int64, crede } merged := mergeCredentialMaps(decodeCredentials(currentRaw), credentials) - credJSON, err := json.Marshal(merged) + credJSON, err := json.Marshal(encryptSensitiveCredentials(merged)) if err != nil { return fmt.Errorf("序列化 credentials 失败: %w", err) } @@ -7812,7 +7849,7 @@ func (db *DB) InsertAccount(ctx context.Context, name string, refreshToken strin credentials := map[string]interface{}{ "refresh_token": refreshToken, } - credJSON, err := json.Marshal(credentials) + credJSON, err := json.Marshal(encryptSensitiveCredentials(credentials)) if err != nil { return 0, err } @@ -7858,7 +7895,7 @@ func (db *DB) InsertATAccount(ctx context.Context, name string, accessToken stri credentials := map[string]interface{}{ "access_token": accessToken, } - credJSON, err := json.Marshal(credentials) + credJSON, err := json.Marshal(encryptSensitiveCredentials(credentials)) if err != nil { return 0, err } @@ -7875,7 +7912,7 @@ func (db *DB) InsertAccountWithCredentials(ctx context.Context, name string, cre if credentials == nil { credentials = map[string]interface{}{} } - credJSON, err := json.Marshal(credentials) + credJSON, err := json.Marshal(encryptSensitiveCredentials(credentials)) if err != nil { return 0, err } @@ -7892,7 +7929,7 @@ func (db *DB) InsertOpenAIResponsesAccount(ctx context.Context, name string, cre if credentials == nil { credentials = map[string]interface{}{} } - credJSON, err := json.Marshal(credentials) + credJSON, err := json.Marshal(encryptSensitiveCredentials(credentials)) if err != nil { return 0, err } @@ -7917,7 +7954,7 @@ func (db *DB) InsertAccountWithUpstream(ctx context.Context, name, platform, acc if strings.TrimSpace(accountType) == "" { accountType = "api" } - credJSON, err := json.Marshal(credentials) + credJSON, err := json.Marshal(encryptSensitiveCredentials(credentials)) if err != nil { return 0, err } diff --git a/database/sqlite.go b/database/sqlite.go index 4f1170032..cffd1e992 100644 --- a/database/sqlite.go +++ b/database/sqlite.go @@ -253,6 +253,7 @@ func (db *DB) migrateSQLite(ctx context.Context) error { site_logo TEXT DEFAULT '', background_config TEXT DEFAULT '{}', grok_config TEXT DEFAULT '{}', + claude_config TEXT DEFAULT '{}', antigravity_oauth_config TEXT DEFAULT '{}', invite_guide_config TEXT DEFAULT '{}', max_concurrency INTEGER DEFAULT 2, @@ -570,6 +571,7 @@ func (db *DB) migrateSQLite(ctx context.Context) error { {"system_settings", "site_logo", "TEXT DEFAULT ''"}, {"system_settings", "background_config", "TEXT DEFAULT '{}'"}, {"system_settings", "grok_config", "TEXT DEFAULT '{}'"}, + {"system_settings", "claude_config", "TEXT DEFAULT '{}'"}, {"system_settings", "antigravity_oauth_config", "TEXT DEFAULT '{}'"}, {"system_settings", "invite_guide_config", "TEXT DEFAULT '{}'"}, {"system_settings", "test_content", "TEXT DEFAULT 'hi'"}, diff --git a/docs/API.md b/docs/API.md index 3ffca4891..5b26ad3db 100644 --- a/docs/API.md +++ b/docs/API.md @@ -16,6 +16,7 @@ - [管理 API](#管理-api) - [统计接口](#统计接口) - [账号管理](#账号管理) — 添加 RT / AT 账号、批量导入、导出、迁移 + - [Claude OAuth 与原生 Messages](#claude-oauth-与原生-messages) — 导入、采样、模型与指纹配置 - [用量统计](#用量统计) - [API Key 管理](#api-key-管理) - [系统设置](#系统设置) @@ -34,7 +35,7 @@ Codex2API 提供兼容 OpenAI 风格的 API 接口,同时包含完整的管理后台 API。 -Anthropic `/v1/messages` 仅将官方 `speed:"fast"` 映射为上游 Codex `service_tier:"priority"`;Anthropic 请求侧 `service_tier`(Priority Tier)不在此映射范围内。用量日志的 `service_tier` / `fast` 过滤反映该解析结果。 +Anthropic `/v1/messages` 在没有可用 Claude OAuth 账号时,才将官方 `speed:"fast"` 映射为上游 Codex `service_tier:"priority"`;Claude OAuth 账号优先走原生 Anthropic Messages 透传,不经过该转换。Anthropic 请求侧 `service_tier`(Priority Tier)不在此映射范围内。用量日志的 `service_tier` / `fast` 过滤反映该解析结果。 **Service Tier 语义说明**:请求侧 `fast` / `priority` 会统一以 `priority` 转发上游,其余取值(`auto`/`default`/`flex`/`scale` 等)不转发。用量日志区分三个字段:`requested_service_tier`(客户端请求意图)、`actual_service_tier`(上游回传 Tier,原样取自 `response.completed.response.service_tier`)、`billing_service_tier`(计费采用值,由 Tier 计费策略 `BillingTierPolicy` 决定)。默认 `actual` 以请求 Tier 为上限:上游只可用更便宜档位降低计费,不能把未请求 Fast 的调用抬升为 Fast,也不能用未知档位改变计费;`requested` 始终按请求意图计费。注意:在 ChatGPT OAuth / Codex backend 路径上,Fast 由上游服务端路由处理,`service_tier` 不是端到端可校验字段——上游回传 `default` 并不代表 Fast 未生效(openai/codex#14204 官方说明;#494 的交错 A/B 实测在回传 `default` 时仍有约 1.5× 生成吞吐提升)。因此"上游回传 Tier"仅反映上游申报值,不能单独用于判断加速是否生效。 @@ -755,6 +756,106 @@ Grok 账号编辑页支持账号级模型映射,可让只请求 GPT 模型名 } ``` +### Claude OAuth 与原生 Messages + +Claude Code OAuth 账号使用原生 Anthropic Messages 上游,不会进入 Codex WHAM +或 Responses 探针。以下端点均受现有 `X-Admin-Key` 管理鉴权保护;请求示例中的 +Token、授权码和账号 ID 仅为占位符,服务端不会在响应或日志中回显 access/refresh +token。 + +#### POST /api/admin/accounts/claude/oauth/auth-url + +创建一次性 PKCE 登录会话,返回授权地址与 `state`。`state` 默认 15 分钟有效且只能 +兑换一次。 + +#### POST /api/admin/accounts/claude/oauth/exchange-code + +使用 `state` 与回调 `code` 换取 Claude OAuth 凭据并入库。可选 `proxy_url`、 +`use_proxy_pool`、`timezone` 和 `name`;入库后会异步执行一次受控原生 Messages +用量采样。 + +#### POST /api/admin/accounts/claude/import + +直接导入 `cmd/claude_login -out` 生成的 JSON,或下面导出端点生成的 version 1 +Claude 凭据。`access_token` 与 `refresh_token` 必填;同时接受单对象、对象数组和 +`{"accounts":[...]}`。单对象保持历史 `{message,id,email}` 响应,批量导入返回 +`total`、`imported`、`failed` 与逐账号 `items/warnings`。`auth_kind` 仅允许 +`oauth`,模型列表仅允许 `claude-*`。 + +导入文件可恢复账号名称、代理、时区、标签、启用状态、账号级指纹模式和受限身份头。 +分组使用 `group_refs: [{"name":"...","channel":"claude"}]` 按名称映射;不会复用 +另一实例的数字分组 ID,不存在的组会作为 warning 返回且不会自动创建。锁定、冷却和 +历史用量属于目标实例运行状态,不随凭据迁移。 + +#### GET /api/admin/accounts/claude/export + +导出管理员专用的完整 Claude OAuth 凭据。`ids=1,2` 可精确选择账号,省略时导出全部; +`filter=all|healthy` 控制是否只包含当前健康账号;`format=auto|json|zip` 控制输出格式 +(默认 auto:单条 JSON、多条 ZIP;`format=json` 可得到可直接再次导入的对象数组)。响应设置 `Content-Disposition`、实际数量 +`X-Export-Count`、`Cache-Control: no-store, max-age=0`、`Pragma: no-cache` 和 +`X-Content-Type-Options: nosniff`。 + +version 1 文档包含 `type=claude`、`auth_kind=oauth`、access/refresh token、账号 ID、 +过期时间、套餐、模型、代理、时区、`claude_fingerprint_mode`、标签、启用状态及 +`group_refs`。`fingerprint_headers` 只允许 `User-Agent`、`X-App` 和 +`X-Stainless-*` 身份头;任意 `Authorization`、Cookie、API Key 或其它自定义头均不会 +进入导出文件。下载内容为明文高敏凭据,下载后应立即加密保存或在迁移完成后删除。 + +#### POST /api/admin/accounts/:id/claude/models + +刷新单个 Claude 账号的上游模型目录并保存到账号凭据。该操作只接受 Claude OAuth +账号,返回 `models` 与 `count`。 + +#### POST /api/admin/accounts/claude/models/refresh + +批量刷新启用的 Claude 账号模型目录,返回 `refreshed`、`failed` 和去重后的 +`model_count`。单账号失败不会回滚其他成功结果。 + +#### POST /api/admin/accounts/:id/models/sync-upstream + +只读拉取指定 Claude 账号的上游模型目录,不覆盖账号白名单。确认后可用下面的 +PATCH 端点保存。 + +#### PATCH /api/admin/accounts/:id/models + +设置账号级 Claude 模型白名单。非空数组只能包含 `claude-*` 模型;传空数组清除 +覆盖,恢复按账号目录/默认目录准入。服务端会拒绝跨 provider 的模型名。 + +```json +{ + "models": ["claude-haiku-4-5", "claude-sonnet-4-5"] +} +``` + +#### POST /api/admin/accounts/:id/usage/refresh + +执行一次有界的原生 Messages 用量探针,返回 5 小时/7 天窗口、重置时间和 +`claude_usage_probe_at` / `claude_usage_probe_error`。缺少上游用量头时仍记录采样 +时间;失败不会把未知用量伪造成 `0%`。 + +Claude 账号详情还会返回脱敏的 `claude_user_agent` 指纹摘要;不会返回 OAuth token, +也不会把任意自定义请求头暴露给管理页面。 + +#### POST /api/admin/accounts/:id/models/probe + +只读并发探测账号可见的 `claude-*` 文本模型,返回 `available` 与逐模型 +`outcome`(`available`、`unsupported`、`throttled`、`error`)。模型探测不会写入 +账号冷却、错误或调度状态;追加 `?stream=true` 可接收 SSE 进度。 + +#### GET /api/admin/accounts/:id/test + +执行一次手动原生 Messages 测连并以 SSE 返回 `test_start`、`content`、`error`、 +`test_complete`。与只读模型探测不同,手动测连会同步真实账号的用量/限流与错误 +状态;上游明确 rejected/耗尽时不会被“成功”结果清除。 + +#### GET/PUT /api/admin/settings/claude-config + +读取或更新 Claude 全局默认配置:`fingerprint_mode`(`preserve`/`force`)、 +`default_timezone` 与 `session_window_limit`。账号级调度设置可覆盖这些默认值; +更新会热应用到运行时且不会改变 OAuth token。`force` 会把最终 User-Agent 与 +X-Stainless 身份头收敛为账号绑定指纹;显式修改账号时区会轮换该账号的身份指纹, +最终上游 User-Agent 会写入 UsageLog 审计字段。 + ### Antigravity credential and state administration Every endpoint in this section is registered under the existing `/api/admin` authentication middleware and requires the configured admin secret. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dc9108d2a..2977025c2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -255,7 +255,7 @@ func TranslateStreamChunk(data []byte, model, chunkID string) ([]byte, bool) - `messages` → `input` - `max_tokens/temperature` → 删除(Codex 不支持) - `reasoning_effort` → `reasoning.effort` -- Anthropic `/v1/messages` 的 `speed:"fast"` → Codex `service_tier:"priority"`(Anthropic 入参 `service_tier` 为 Priority Tier,不参与 fast mode 映射) +- Anthropic `/v1/messages` 在无可用 Claude OAuth 账号时的 `speed:"fast"` → Codex `service_tier:"priority"`(Anthropic 入参 `service_tier` 为 Priority Tier,不参与 fast mode 映射);Claude OAuth 账号优先走原生 Anthropic Messages 透传,不进入 Codex 转换链 - SSE 事件类型转换 --- diff --git a/docs/superpowers/plans/2026-08-29-claude-parity.md b/docs/superpowers/plans/2026-08-29-claude-parity.md new file mode 100644 index 000000000..0f0a7a188 --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-claude-parity.md @@ -0,0 +1,125 @@ +# Claude 渠道对等适配实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让已导入的 Claude OAuth 账号自动产生真实用量快照,并在统计、路由和管理页面中与支持范围匹配地展示。 + +**Architecture:** 保留现有导入探针队列和生命周期管理,新增 Claude 专用 Anthropic Messages 采样器;所有统计通过 `upstream_type`/运行时账号统一归属。Claude 只走原生 Messages,Dashboard/Usage/API Key/代理/调度页面共享同一渠道枚举和模型目录。 + +**Tech Stack:** Go、SQLite/PostgreSQL、React、TypeScript、Node test runner、GitNexus。 + +--- + +### Task 1: Claude provider-aware sampling + +**Files:** +- Modify: `admin/claude_accounts.go:308-393` +- Modify: `admin/usage_probe.go:54-180` +- Modify: `auth/store.go:2950-2970,10170-10320` +- Modify: `auth/account.go` provider predicates +- Test: `admin/usage_probe_test.go`, `admin/claude_accounts_test.go`, `auth/store_scheduler_test.go` + +- [ ] **Step 1: Write failing tests** + +Add tests asserting that a Claude import enqueues exactly one provider-specific probe, that the probe never calls WHAM/Responses, and that Anthropic rate-limit headers persist 5h/7d state. + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run `go test ./admin ./auth -run 'Claude|UsageProbe' -count=1`. Expected failure: no Claude probe is scheduled or the generic probe rejects the provider. + +- [ ] **Step 3: Implement the minimal provider path** + +Add `ProbeClaudeUsageSnapshot(ctx, account)` using `proxy.ExecuteClaudeMessagesRequest` with a bounded minimal request, call `proxy.SyncClaudeUsageState`, and route `insertClaudeAccount` through `scheduleImportedAccountWarmup`. Keep Claude out of `ProbeUsageSnapshot`'s WHAM/Responses branches and add in-flight deduplication through the existing import queue. + +- [ ] **Step 4: Verify GREEN and regression coverage** + +Run `go test ./admin ./auth -run 'Claude|UsageProbe' -count=1`, then `go test ./...`. Expected: focused tests and all existing tests pass. + +- [ ] **Step 5: Commit** + +`git add admin/claude_accounts.go admin/usage_probe.go auth/account.go auth/store.go admin/*test.go auth/*test.go && git commit -m "fix(claude): sample usage after account import"` + +### Task 2: Backend channel attribution and analysis + +**Files:** +- Modify: `admin/handler.go:1360-1495` +- Modify: `admin/account_analysis.go:300-500` +- Modify: `admin/accounts_paged.go:1180-1220` +- Modify: `proxy/handler.go` UsageLog channel selection +- Modify: `proxy/handler_anthropic.go` Claude success/error log paths +- Modify: `proxy/handler.go:370-410` provider channel filter +- Modify: `proxy/model_registry.go`, `admin/handler.go` model catalog response +- Test: `admin/handler_test.go`, `admin/account_analysis_test.go`, `proxy/handler_test.go` + +- [ ] **Step 1: Write failing tests** + +Cover Claude-only dashboard counts, Claude not being treated as an unsampled Codex account after a valid snapshot, Claude 5h plan families, UsageLog `channel=claude`, and exclusion of Claude from Responses/Chat routing. + +- [ ] **Step 2: Run focused tests and verify RED** + +Run `go test ./admin ./proxy -run 'Claude|Dashboard|UsageLog|Channel' -count=1`. Expected failures show Claude counted as Codex or routed to the wrong protocol. + +- [ ] **Step 3: Implement provider-aware classification** + +Initialize `channelCounts` with `database.UpstreamChannelClaude`; detect `auth.UpstreamClaude` before the Codex fallback; treat Claude snapshots as sampled; add a Claude-specific subscription/5h capability predicate; set UsageLog channel from the account provider; and return `claude_models` from the model catalog while rejecting unsupported protocol routes. + +- [ ] **Step 4: Verify GREEN** + +Run the focused command and `go test ./...`; assert no Codex/Grok regression. + +- [ ] **Step 5: Commit** + +`git add admin proxy && git commit -m "fix(claude): keep channel stats and routing isolated"` + +### Task 3: Frontend channel and page parity + +**Files:** +- Modify: `frontend/src/components/ChannelFilter.tsx` +- Modify: `frontend/src/pages/Dashboard.tsx` +- Modify: `frontend/src/pages/Usage.tsx` +- Modify: `frontend/src/pages/APIKeys.tsx` +- Modify: `frontend/src/pages/Proxies.tsx`, `frontend/src/components/SchedulerBoard.tsx` +- Modify: `frontend/src/pages/ClaudeAccounts.tsx`, `frontend/src/types.ts`, `frontend/src/locales/en.json`, `frontend/src/locales/zh.json`, `frontend/src/locales/zh-TW.json` +- Test: `frontend/src/lib/claudeParity.test.mjs`, existing page helper tests + +- [ ] **Step 1: Write failing frontend tests** + +Assert that shared channel options include Claude, Dashboard breakdown renders Claude, Usage model filters and log badges recognize Claude, and Claude rows display sampled/unsampled/error state with last sample time. + +- [ ] **Step 2: Run `npm test` and verify RED** + +Run `npm test -- src/lib/claudeParity.test.mjs`; expected failures show missing Claude options and labels. + +- [ ] **Step 3: Implement UI parity** + +Extend `UsageChannel` and shared options to `"claude"`; add Claude to Dashboard breakdown and Usage model catalogs; add channel/logo labels to API Keys, Proxies, Scheduler; expose provider-specific empty/loading/sample states in `ClaudeAccounts`; hide unsupported Codex-only actions with localized explanations. + +- [ ] **Step 4: Verify GREEN** + +Run `npm test`, `npm run typecheck`, and `npm run build`. + +- [ ] **Step 5: Commit** + +`git add frontend && git commit -m "feat(ui): expose Claude channel across admin views"` + +### Task 4: Existing-environment verification and handoff + +**Files:** +- Modify: `docs/CLAUDE.md` or `docs/CONFIGURATION.md` only if an actual setting/API changed +- Test: existing local environment, no new port + +- [ ] **Step 1: Run complete verification** + +Run `go test ./...`, `go vet ./...`, `cd frontend && npm run typecheck && npm test && npm run build`. + +- [ ] **Step 2: Verify existing local service** + +Probe the already-running local service URL/port discovered from the current environment; check Claude account list, Dashboard Claude filter, Usage Claude filter, and one account's sample state. Do not create another listener or re-import credentials. + +- [ ] **Step 3: Run GitNexus change detection** + +Run `gitnexus_detect_changes(scope: "all")`, review affected flows, and ensure only Claude provider, statistics, sampling, and UI modules changed. + +- [ ] **Step 4: Commit documentation if needed** + +Only commit an actual documentation change with `git add docs/... && git commit -m "docs(claude): document provider parity and sampling"`. diff --git a/docs/superpowers/plans/2026-08-30-claude-sub2api-security.md b/docs/superpowers/plans/2026-08-30-claude-sub2api-security.md new file mode 100644 index 000000000..c11d0bf8c --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-claude-sub2api-security.md @@ -0,0 +1,69 @@ +# Claude/Sub2API 安全增强 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 让 ClaudeCode 原生透传与 NewAPI/Sub2API 渠道共享同一套规范化审核、出口安全策略和按渠道隔离的人物画像。 + +**Architecture:** 保留入口原始 body 用于 NewAPI 签名校验,将规范化 body 作为 Prompt 审核和 Claude 上游发送的唯一内容;Claude 全局配置提供默认拒绝的敏感字段/Beta Header/工具与输出限制。已验证的 NewAPI `channel_id` 只加入运行时风险和 session scope,持久化人物画像继续以平台用户身份聚合,避免同一平台同一用户跨渠道丢失画像。 + +**Tech Stack:** Go、SQLite/PostgreSQL、React/TypeScript、现有 Prompt Filter、NewAPI 签名元数据和 GitNexus。 + +--- + +### Task 1: 扩展 ClaudeCode 全局安全配置 + +**Files:** +- Modify: `auth/claude_fingerprint_mode.go` +- Modify: `auth/store.go` +- Modify: `admin/claude_config.go` +- Modify: `frontend/src/types.ts` +- Modify: `frontend/src/pages/Settings.tsx` +- Modify: `frontend/src/locales/zh.json` +- Modify: `frontend/src/locales/zh-TW.json` +- Modify: `frontend/src/locales/en.json` +- Test: `auth/claude_fingerprint_mode_test.go`, `admin/claude_config_test.go` + +- [ ] **Step 1: Write failing tests** for parsing secure defaults, Beta allowlist normalization, output/tool limits, and round-trip admin configuration. +- [ ] **Step 2: Run the focused tests** and confirm they fail because the new fields and accessors do not exist. +- [ ] **Step 3: Add immutable runtime config accessors** backed by `atomic.Value`; normalize empty config to secure defaults without changing existing fingerprint/timezone behavior. +- [ ] **Step 4: Extend the admin DTO/API/UI** with clear labels and bounded numeric inputs; reject invalid limits and unsafe header names. +- [ ] **Step 5: Run focused Go and frontend tests** and confirm all pass. + +### Task 2: Canonical Claude request and egress policy + +**Files:** +- Modify: `proxy/claude_upstream.go` +- Modify: `proxy/handler_anthropic.go` +- Modify: `proxy/prompt_filter.go` +- Test: `proxy/claude_upstream_test.go`, `proxy/prompt_filter_test.go`, `proxy/anthropic_test.go` + +- [ ] **Step 1: Write failing tests** proving zero-width/bidi normalization occurs before Prompt Filter, final upstream body matches audited canonical body, sensitive fields are removed by default, allowed fields survive, and disallowed Beta tokens are removed. +- [ ] **Step 2: Run the tests** and confirm they fail on the current raw-before-normalize flow. +- [ ] **Step 3: Add a Claude request canonicalizer** that preserves JSON structure, normalizes text, removes configured sensitive fields, bounds tools/output, and returns a redacted audit digest. +- [ ] **Step 4: Route `/v1/messages` through canonical body** for Prompt Filter, model extraction, learning evidence, and Claude upstream; keep the original ingress body for signature verification and source evidence. +- [ ] **Step 5: Make `anthropic-beta` required-plus-allowlist** and keep `x-api-key`, cookies, authorization overrides, and hop-by-hop headers outside the Claude upstream boundary. +- [ ] **Step 6: Run focused tests and inspect audit fields** to ensure no raw credential or unbounded payload is logged. + +### Task 3: Channel-aware NewAPI runtime risk and session isolation + +**Files:** +- Modify: `proxy/newapi_policy.go` +- Modify: `proxy/prompt_filter_advanced.go` +- Modify: `proxy/prompt_guard_extensions.go` +- Test: `proxy/newapi_policy_test.go`, `proxy/prompt_guard_extensions_test.go`, `proxy/prompt_conversation_lock_test.go` + +- [ ] **Step 1: Write failing tests** showing two signed requests with the same platform/user but different `channel_id` receive distinct runtime risk/session scopes, while the persisted person identity remains discoverable by platform/user. +- [ ] **Step 2: Run the tests** and confirm current scope keys collide because `channel_id` is ignored. +- [ ] **Step 3: Add a normalized channel component** to runtime scope keys and session correlation keys only when signed, valid channel metadata exists; retain a legacy-compatible scope for channel `0`. +- [ ] **Step 4: Ensure verified channel metadata is carried into incident/audit metadata** without exposing secrets or changing unsigned-request behavior. +- [ ] **Step 5: Run the focused risk, lock, and identity tests.** + +### Task 4: Full verification and change-scope review + +**Files:** +- No production file additions beyond Tasks 1–3. + +- [ ] **Step 1: Run** `gofmt -w` on changed Go files and `git diff --check`. +- [ ] **Step 2: Run** `go test ./... -count=1`, `go vet ./...`, `npm test`, `npm run typecheck`, `npm run build`, and `npm run audit:ci`. +- [ ] **Step 3: Run** `npx gitnexus detect-changes --scope unstaged --repo codex2api` and review the affected flows. +- [ ] **Step 4: Verify** no production deployment, credential output, or Git commit occurs unless separately requested. diff --git a/docs/superpowers/specs/2026-08-29-claude-parity-design.md b/docs/superpowers/specs/2026-08-29-claude-parity-design.md new file mode 100644 index 000000000..86305332b --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-claude-parity-design.md @@ -0,0 +1,59 @@ +# Claude 渠道对等适配设计 + +## 目标 + +让 Claude Code OAuth 账号在账号管理、用量采样、看板统计、Usage、模型目录、API Key/代理/调度筛选中拥有与其能力相匹配的完整可见性;保证 Claude 请求只进入 Anthropic Messages 原生链路,不被误归类或误送到 Codex/Grok/ChatGPT 探针。 + +## 已确认根因 + +1. `insertClaudeAccount` 写入账号后没有进入导入预热队列;`Account.NeedsUsageProbe` 又把 Claude 当 relay 跳过,导致新账号永远没有初始用量快照。 +2. `summarizeDashboardAccounts`、Usage 日志归因和通用渠道过滤没有统一识别 Claude,Claude 账号会混入 Codex 统计。 +3. Claude 的 5h 分析复用了 Codex 套餐名判断,Claude 的 `max-*`、`enterprise` 和默认档位会被排除。 +4. Dashboard/Usage/ API Key/代理/调度页面的渠道选项和模型目录缺少 Claude,部分账号操作没有明确的 provider 能力边界。 + +## 方案 + +### 采样与状态 + +- 新增 Claude 专用异步采样入口,复用现有导入探针队列、并发闸和生命周期管理。 +- 采样只调用 Anthropic 原生 Messages 能力,使用最小、明确的测试请求读取统一限流头;绝不调用 ChatGPT WHAM 或 Codex Responses 探针。 +- 成功后持久化 5h/7d 用量快照、采样时间和 provider 状态;失败保留 `unsampled`/错误原因,按现有重试策略排队,不改变请求转发结果。 +- OAuth 导入、Token 导入、批量导入和手动刷新统一触发一次采样;并发去重,避免重复扣费。 + +### Provider 归属 + +- 后端所有账号统计和 UsageLog channel 统一通过 `upstream_type`/运行时账号判定 Claude。 +- API Key、Responses、Chat Completions 等不支持 Claude 的路径明确排除 Claude;原生 `/v1/messages` 保持 Claude 路由。 +- Claude 的模型目录从账号真实模型集合和缓存生成,空账号时返回明确空状态。 + +### 页面 + +- Dashboard/Usage 的共享渠道筛选加入 Claude,渠道徽标、模型过滤和空状态同步加入。 +- Claude 账号页展示采样状态、最后采样时间、失败原因和 5h/7d/今日数据;已有分析卡片复用 provider-aware 数据。 +- API Key、代理、调度和模型目录筛选加入 Claude;不适用的 Codex 专属操作继续隐藏并给出原因。 + +## 数据流 + +```text +Claude OAuth/Token 导入 + -> insertClaudeAccount + -> store.AddAccount + -> Claude probe queue (deduplicated) + -> Anthropic Messages probe + -> SyncClaudeUsageState + -> DB/runtime snapshot + cache invalidation + -> Dashboard / Usage / ClaudeAccounts +``` + +## 错误与安全边界 + +- 采样失败不封禁账号、不阻塞导入响应、不将 Claude 凭据发送给其他 provider。 +- API 错误只保存脱敏状态和截断原因,不记录 access/refresh token。 +- 真实请求的限流、失败和成功状态仍由 Claude 原生响应处理;采样仅作为额度可见性补充。 + +## 验收 + +- 新增 Claude 账号后在不重新导入的情况下从 `unsampled` 进入 `sampled` 或显示明确失败状态。 +- Dashboard/Usage 按 Claude 筛选时统计、模型、日志渠道和图标均正确,Codex 统计不增加 Claude 数据。 +- Claude 5h/7d 分析覆盖 Max/Enterprise/默认档位;API Key/代理/调度筛选不会把 Claude 当 Codex。 +- Go 全量测试、前端类型检查/测试/构建通过;使用现有本地环境验证,不新增端口。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4f589c669..f95b9355e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -61,6 +61,7 @@ function AdminApp() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5dd9ca8e3..044420c7d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -81,6 +81,12 @@ import type { ModelsResponse, OAuthExchangeResponse, OAuthURLResponse, + ClaudeAuthURLResponse, + ClaudeExchangeCodeRequest, + ClaudeImportTokenRequest, + ClaudeCredentialExportEntry, + ClaudeImportBundleResponse, + ClaudeAddAccountResponse, OpsErrorSummary, OpsOverviewResponse, PromptFilterLog, @@ -127,6 +133,7 @@ import type { CreateAccountGroupRequest, UpdateAccountGroupRequest, UpstreamChannel, + ClaudeGlobalConfig, } from './types' const BASE = '/api/admin' @@ -594,7 +601,7 @@ export const api = { if (params.order) searchParams.set('order', params.order) return request(`/accounts?${searchParams.toString()}`, { signal }) }, - getAccountAnalysis: (channel: 'codex' | 'grok' | 'antigravity' = 'codex', signal?: AbortSignal) => + getAccountAnalysis: (channel: 'codex' | 'grok' | 'antigravity' | 'claude' = 'codex', signal?: AbortSignal) => request(`/accounts/analysis?channel=${channel}`, { signal }), getAccountPageStats: (ids: number[], signal?: AbortSignal) => { const query = new URLSearchParams({ ids: ids.join(',') }) @@ -725,6 +732,52 @@ export const api = { request(`/accounts/antigravity/oauth/${encodeURIComponent(sessionId)}`, { method: 'DELETE', }), + // Claude Code OAuth:第一步取授权 URL(服务端暂存 state→verifier)。 + generateClaudeAuthURL: () => + request('/accounts/claude/oauth/auth-url', { + method: 'POST', + body: JSON.stringify({}), + timeoutMs: 15_000, + }), + // 第二步:用 state+code 换取 token 并入库(可选从代理池分配代理)。 + exchangeClaudeOAuthCode: (data: ClaudeExchangeCodeRequest) => + request('/accounts/claude/oauth/exchange-code', { + method: 'POST', + body: JSON.stringify(data), + timeoutMs: 90_000, + }), + // CLI 直导:吃 cmd/claude_login -out 产出的 token JSON。 + importClaudeToken: (data: ClaudeImportTokenRequest) => + request('/accounts/claude/import', { + method: 'POST', + body: JSON.stringify(data), + timeoutMs: 20_000, + }), + /** Import a versioned Claude credential object or bundle. */ + importClaudeCredentialBundle: ( + data: ClaudeCredentialExportEntry | ClaudeCredentialExportEntry[] | { accounts: ClaudeCredentialExportEntry[] }, + ) => + request('/accounts/claude/import', { + method: 'POST', + body: JSON.stringify(data), + timeoutMs: 120_000, + }), + /** Download one Claude JSON credential or a ZIP for multiple accounts. */ + exportClaudeAccounts: (ids?: number[], filter: 'all' | 'healthy' = 'all', format: 'auto' | 'json' | 'zip' = 'auto') => { + const params = new URLSearchParams({ filter, format }) + if (ids && ids.length > 0) params.set('ids', ids.join(',')) + return requestNamedBlob(`/accounts/claude/export?${params.toString()}`) + }, + refreshClaudeModels: (id: number) => + request<{ message: string; models: string[]; count: number }>(`/accounts/${id}/claude/models`, { + method: 'POST', + timeoutMs: 30_000, + }), + refreshAllClaudeModels: () => + request<{ message: string; refreshed: number; failed: number; model_count: number }>('/accounts/claude/models/refresh', { + method: 'POST', + timeoutMs: 60_000, + }), batchUpdateGrokModels: (data: BatchUpdateGrokModelsRequest) => request('/accounts/grok/batch-models', { method: 'POST', @@ -772,6 +825,8 @@ export const api = { reset_5h_at?: string reset_7d_at?: string reset_spark_at?: string + claude_usage_probe_at?: string + claude_usage_probe_error?: string }>(`/accounts/${id}/usage/refresh`, { method: 'POST' }), updateAccountScheduler: (id: number, data: UpdateAccountSchedulerRequest) => request(`/accounts/${id}/scheduler`, { method: 'PATCH', body: JSON.stringify(data) }), @@ -1149,6 +1204,13 @@ export const api = { request('/usage/logs', { method: 'DELETE' }), getSetupHints: () => request('/setup-hints'), getSettings: () => request('/settings'), + getClaudeConfig: () => + request('/settings/claude-config'), + updateClaudeConfig: (data: ClaudeGlobalConfig) => + request<{ message: string } & ClaudeGlobalConfig>('/settings/claude-config', { + method: 'PUT', + body: JSON.stringify(data), + }), getObservedInstructions: () => request('/settings/observed-instructions'), updateSettings: (data: Partial) => @@ -1325,6 +1387,7 @@ export const api = { request<{ models: Array<{ model: string + channel?: string source: string pricing: ModelPricingOverride canonical_model?: string @@ -1347,12 +1410,12 @@ export const api = { method: 'POST', body: JSON.stringify({ url: url ?? '' }), }), - updateOfficialPricingSyncConfig: (config: Pick) => + updateOfficialPricingSyncConfig: (config: Pick) => request('/model-pricing/official-sync/config', { method: 'PUT', body: JSON.stringify(config), }), - syncOfficialModelPricing: (sources: { include_openai: boolean; include_grok: boolean }) => + syncOfficialModelPricing: (sources: { include_openai: boolean; include_grok: boolean; include_claude?: boolean }) => request('/model-pricing/official-sync', { method: 'POST', body: JSON.stringify(sources), @@ -1427,7 +1490,7 @@ export const api = { request<{ message: string; deleted: number }>('/proxies/batch-delete', { method: 'POST', body: JSON.stringify({ ids }) }), cleanErrorProxies: () => request<{ message: string; cleaned: number; unbound: number }>('/proxies/clean-error', { method: 'POST' }), - autoBalanceProxies: (data: { channel?: 'codex' | 'grok'; mode?: 'unbound' | 'all'; max_per_proxy?: number; proxy_ids?: number[] }) => + autoBalanceProxies: (data: { channel?: 'codex' | 'grok' | 'claude'; mode?: 'unbound' | 'all'; max_per_proxy?: number; proxy_ids?: number[] }) => request('/proxies/auto-balance', { method: 'POST', body: JSON.stringify(data) }), testProxy: (url: string, id?: number, lang?: string) => request('/proxies/test', { method: 'POST', body: JSON.stringify({ url, id, lang }) }), diff --git a/frontend/src/components/AccountDetailSheet.tsx b/frontend/src/components/AccountDetailSheet.tsx index b25dc4d28..024d6d34c 100644 --- a/frontend/src/components/AccountDetailSheet.tsx +++ b/frontend/src/components/AccountDetailSheet.tsx @@ -290,6 +290,7 @@ export default function AccountDetailSheet({ ); const rateWindow = account ? getRateLimitWindow(account) : null; const isGrok = Boolean(account?.grok_api); + const isClaude = Boolean(account?.claude_api); // Grok API Key 无 refresh_token;Codex AT-only / Responses 也不走 AT 刷新。 const refreshDisabled = Boolean( account && @@ -298,12 +299,13 @@ export default function AccountDetailSheet({ account.openai_responses_api || (isGrok && account.grok_auth_kind !== "oauth")), ); - // auth.json / 额度券是 Codex 订阅路径专属,Grok 不展示。 + // 凭据导出由各 provider 自己决定格式;Claude 使用专用安全导出端点, + // Grok 仍由其专用页面处理。旧的 Codex auth.json 行为保持不变。 const showAuthJson = Boolean(account && !isGrok); - const showResetCredits = Boolean(account && !isGrok); + const showResetCredits = Boolean(account && !isGrok && !isClaude); const authJsonDisabled = Boolean( account && - (authJsonExporting || account.at_only || account.openai_responses_api), + (authJsonExporting || (!isClaude && (account.at_only || account.openai_responses_api))), ); const resetCredits = account?.rate_limit_reset_credits ?? 0; const healthLabel = (() => { @@ -348,7 +350,9 @@ export default function AccountDetailSheet({
- {account.grok_api ? ( + {account.claude_api ? ( + + ) : account.grok_api ? ( ) : account.openai_responses_api ? ( @@ -757,6 +761,7 @@ export default function AccountDetailSheet({ account.at_only || account.openai_responses_api || account.grok_api || + account.claude_api || account.base_url || (!account.openai_responses_api && (account.models?.length ?? 0) > 0)) && ( @@ -782,6 +787,14 @@ export default function AccountDetailSheet({
)} + {isClaude && ( +
+ + {t("accounts.detailAuthType")} + + {t("claude.authOAuth")} +
+ )} {isGrok && (
@@ -877,7 +890,9 @@ export default function AccountDetailSheet({ - {isGrok + {isClaude + ? t("claude.actionRefresh") + : isGrok ? t("grok.actionRefresh") : t("accounts.actionRefreshAT")} @@ -890,7 +905,7 @@ export default function AccountDetailSheet({ onClick={onGenerateAuthJson} > - {t("accounts.actionAuthJson")} + {isClaude ? t("claude.exportCredential") : t("accounts.actionAuthJson")} )} + +
+ } + > +
+ {/* 左:创建/编辑表单 */} +
+
+ {draft.id === null ? t("accountGroups.newGroup") : t("accountGroups.editGroup")} +
+
+ {t("accountGroups.name")} + setDraft({ ...draft, name: e.target.value })} placeholder={t("accountGroups.namePlaceholder")} /> +
+
+ {t("accountGroups.color")} +
+ {ACCOUNT_GROUP_COLORS.map((c) => ( +
+
+
+ {t("accountGroups.description")} + setDraft({ ...draft, description: e.target.value })} placeholder={t("accountGroups.descriptionPlaceholder")} /> +
+
+
+ {t("accountGroups.baseConcurrency")} + setDraft({ ...draft, baseConcurrency: e.target.value })} placeholder={t("accountGroups.followGlobal")} inputMode="numeric" /> +
+
+ {t("accountGroups.autoPause5h")} + setDraft({ ...draft, autoPause5h: e.target.value })} placeholder="0" inputMode="numeric" /> +
+
+ {t("accountGroups.autoPause7d")} + setDraft({ ...draft, autoPause7d: e.target.value })} placeholder="0" inputMode="numeric" /> +
+
+
+ {t("accountGroups.proxyUrls")} +