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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,8 @@ grok-build-main/
.superpowers/
CLAUDE.md
.cursor
diagrams/
diagrams/
# local run artifacts
/data/
/codex2api_local
/server.log
8 changes: 4 additions & 4 deletions admin/account_analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -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++
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
5 changes: 5 additions & 0 deletions admin/account_groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -521,6 +524,8 @@ func groupChannelDisplayName(channel string) string {
return "Grok"
case database.AccountGroupChannelAntigravity:
return "Antigravity"
case database.AccountGroupChannelClaude:
return "Claude"
default:
return "Codex"
}
Expand Down
35 changes: 32 additions & 3 deletions admin/account_response_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")) != "" {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 只用来算生效空间,
// 摘要响应仍会剥掉原文。
Expand All @@ -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{
Expand All @@ -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,
Expand All @@ -174,6 +197,7 @@ func (h *Handler) buildAccountResponse(
OpenAIResponsesAPI: isOpenAIResponsesAccount,
GrokAPI: isGrokAccount,
AntigravityAPI: isAntigravityAccount,
ClaudeAPI: isClaudeAccount,
AntigravityAuthKind: antigravityAuthKind,
AgentIdentity: isAgentIdentityCredentialRow(row),
GrokAuthKind: grokAuthKind,
Expand All @@ -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,
Expand All @@ -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,
}
Expand Down
91 changes: 83 additions & 8 deletions admin/accounts_paged.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ type accountListSnapshotItem struct {
DynamicConcurrency int64
OpenAIResponses bool
Antigravity bool
Claude bool
ClaudeUsageProbeAt string
ClaudeUsageProbeErr string
SearchText string
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1392,14 +1425,17 @@ 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++
} else {
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 {
Expand Down Expand Up @@ -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)
}
Loading
Loading