+ {/* 统计 + 调度视图 + 搜索 */}
+ {accounts.length > 0 || summary ? (
+
+
+ {statChips.map((chip) => {
+ const active = statusFilter === chip.id;
+ return (
+
+ );
+ })}
+
+
+
+ {t("claude.schedulingView")}
+
+ {healthChips.map((h) => (
+
+
+ {h.label}
+ {h.count}
+
+ ))}
+
+
setQuery(e.target.value)}
+ placeholder={t("claude.searchPlaceholder")}
+ className="max-w-md"
+ />
+
+ ) : null}
+
{loading ? (
{t("common.loading")}
@@ -135,47 +285,83 @@ export default function ClaudeAccounts({
{t("claude.empty")}
+ ) : filteredAccounts.length === 0 ? (
+
+ {t("claude.emptyFiltered")}
+
) : (
- {accounts.map((acc) => (
-
-
-
-
-
- {acc.email || acc.name || `#${acc.id}`}
+ {filteredAccounts.map((acc) => {
+ const pct5h = claudeUsagePct(acc.usage_percent_5h);
+ const pct7d = claudeUsagePct(acc.usage_percent_7d);
+ const modelCount = (acc.models || []).length;
+ const cooldownReason = (acc.status || "").toLowerCase().includes("rate")
+ ? acc.error_message
+ : "";
+ return (
+
+
+
+
+
+ {acc.email || acc.name || `#${acc.id}`}
+
+
+ {acc.plan_type || "claude"}
+ {modelCount > 0 ? ` · ${t("claude.modelCount", { count: modelCount })}` : ""}
+ {acc.proxy_url ? ` · ${acc.proxy_url}` : ""}
+
-
- {acc.plan_type || "claude"}
- {acc.proxy_url ? ` · ${acc.proxy_url}` : ""}
+
+
+ {/* 5h / 7d 用量 */}
+ {pct5h !== null || pct7d !== null ? (
+
+ {pct5h !== null ? (
+
+ 5h
+
+ = 90 ? "bg-rose-500" : pct5h >= 70 ? "bg-amber-500" : "bg-emerald-500")}
+ style={{ width: `${pct5h}%` }}
+ />
+
+ {pct5h}%
+
+ ) : null}
+ {pct7d !== null ? (
+
+ 7d
+
+ = 90 ? "bg-rose-500" : pct7d >= 70 ? "bg-amber-500" : "bg-emerald-500")}
+ style={{ width: `${pct7d}%` }}
+ />
+
+ {pct7d}%
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+
-
-
-
-
-
-
- ))}
+ );
+ })}
)}
diff --git a/frontend/src/pages/ModelPricing.tsx b/frontend/src/pages/ModelPricing.tsx
index 3fc24085d..80dea98a7 100644
--- a/frontend/src/pages/ModelPricing.tsx
+++ b/frontend/src/pages/ModelPricing.tsx
@@ -19,7 +19,9 @@ import {
} from 'lucide-react'
import { api } from '@/api'
+import ChannelLogo from '../components/ChannelLogo'
import ModelLogo from '../components/ModelLogo'
+import Modal from '../components/Modal'
import PageHeader from '../components/PageHeader'
import StateShell from '../components/StateShell'
import { StatTile } from '../components/StatTile'
@@ -37,12 +39,46 @@ import {
type Row = {
model: string
+ channel?: string
source: string
pricing: ModelPricingOverride
canonical_model?: string
is_alias?: boolean
}
type SourceFilter = 'all' | 'custom' | 'synced' | 'default' | 'unsaved'
+type ChannelFilter = 'all' | 'codex' | 'grok' | 'antigravity' | 'claude'
+const CHANNEL_ORDER: Array
> = ['codex', 'grok', 'antigravity', 'claude']
+const CHANNEL_LABEL: Record, string> = {
+ codex: 'Codex',
+ grok: 'Grok',
+ antigravity: 'Antigravity',
+ claude: 'Claude',
+}
+function rowChannel(r: Row): Exclude {
+ const c = (r.channel || '').toLowerCase()
+ if (c === 'grok' || c === 'antigravity' || c === 'claude') return c
+ return 'codex'
+}
+// 已见过的模型集(localStorage):用于给新出现的模型打"新"标。首次加载会播种、不标新。
+const SEEN_MODELS_KEY = 'model-pricing-seen-models-v1'
+function readSeenModels(): Set | null {
+ if (typeof window === 'undefined') return new Set()
+ const raw = window.localStorage.getItem(SEEN_MODELS_KEY)
+ if (raw == null) return null
+ try {
+ return new Set((JSON.parse(raw) as string[]).map((m) => m.toLowerCase()))
+ } catch {
+ return new Set()
+ }
+}
+function writeSeenModels(models: string[]) {
+ if (typeof window === 'undefined') return
+ try {
+ window.localStorage.setItem(SEEN_MODELS_KEY, JSON.stringify(models.map((m) => m.toLowerCase())))
+ } catch {
+ // ignore
+ }
+}
type FieldDef = {
key: keyof ModelPricingOverride
@@ -414,6 +450,124 @@ function BillingRulePreview({ pricing }: { pricing: ModelPricingOverride }) {
)
}
+// ModelCatalogModal 是"模型目录"弹窗:按 provider 分组、可搜索、点击某模型直接定位到
+// 价格行;可刷新账号真实可用模型;新出现的模型标"新",便于快速锁定。
+function ModelCatalogModal({
+ open,
+ onClose,
+ rows,
+ newModels,
+ query,
+ onQueryChange,
+ onJump,
+ onRefresh,
+ refreshing,
+ onAcknowledge,
+}: {
+ open: boolean
+ onClose: () => void
+ rows: Row[]
+ newModels: Set
+ query: string
+ onQueryChange: (v: string) => void
+ onJump: (model: string) => void
+ onRefresh: () => void
+ refreshing: boolean
+ onAcknowledge: () => void
+}) {
+ const { t } = useTranslation()
+ const q = query.trim().toLowerCase()
+ const groups = useMemo(() => {
+ const map = new Map()
+ for (const r of rows) {
+ if (q && !r.model.toLowerCase().includes(q)) continue
+ const c = rowChannel(r)
+ const arr = map.get(c) || []
+ arr.push(r)
+ map.set(c, arr)
+ }
+ for (const arr of map.values()) arr.sort((a, b) => compareModelsNewestFirst(a.model, b.model))
+ return CHANNEL_ORDER.filter((c) => map.has(c)).map((c) => ({ channel: c, rows: map.get(c)! }))
+ }, [rows, q])
+
+ return (
+
+
+ {t('settings.pricing.catalogCount', { count: rows.length })}
+
+
+ {newModels.size > 0 ? (
+
+ ) : null}
+
+
+
+ }
+ >
+
+
+
+ onQueryChange(e.target.value)}
+ placeholder={t('settings.pricing.catalogSearch')}
+ className="pl-8"
+ />
+
+ {groups.length === 0 ? (
+
{t('settings.pricing.emptyFiltered')}
+ ) : (
+ groups.map((group) => (
+
+
+
+ {CHANNEL_LABEL[group.channel]}
+ {group.rows.length}
+
+
+ {group.rows.map((r) => {
+ const isNew = newModels.has(r.model.toLowerCase())
+ return (
+
+ )
+ })}
+
+
+ ))
+ )}
+
+
+ )
+}
+
export default function ModelPricing() {
const { t } = useTranslation()
const { showToast } = useToast()
@@ -435,10 +589,17 @@ export default function ModelPricing() {
interval_minutes: 1440,
include_openai: true,
include_grok: true,
+ include_claude: true,
})
const [savingModel, setSavingModel] = useState('')
const [query, setQuery] = useState('')
const [sourceFilter, setSourceFilter] = useState
('all')
+ const [channelFilter, setChannelFilter] = useState('all')
+ const [catalogOpen, setCatalogOpen] = useState(false)
+ const [catalogQuery, setCatalogQuery] = useState('')
+ const [jumpedModel, setJumpedModel] = useState('')
+ const [refreshingModels, setRefreshingModels] = useState(false)
+ const [seenBump, setSeenBump] = useState(0)
const [syncOpen, setSyncOpen] = useState(false)
const [expandedAdvanced, setExpandedAdvanced] = useState>({})
@@ -575,6 +736,7 @@ export default function ModelPricing() {
const result = await api.syncOfficialModelPricing({
include_openai: officialConfig.include_openai,
include_grok: officialConfig.include_grok,
+ include_claude: officialConfig.include_claude,
})
showToast(t('settings.pricing.officialSyncDone', { applied: result.applied, skipped: result.skipped }))
await load()
@@ -609,10 +771,19 @@ export default function ModelPricing() {
const dirtyCount = counts.unsaved
+ // 各 provider(渠道)模型数量:仅当存在多于一个渠道时才显示渠道过滤条。
+ const channelCounts = useMemo(() => {
+ const m: Record = { codex: 0, grok: 0, antigravity: 0, claude: 0 }
+ for (const r of rows) m[rowChannel(r)] += 1
+ return m
+ }, [rows])
+ const activeChannels = CHANNEL_ORDER.filter((c) => channelCounts[c] > 0)
+
const filteredRows = useMemo(() => {
const q = query.trim().toLowerCase()
return rows
.filter((r) => {
+ if (channelFilter !== 'all' && rowChannel(r) !== channelFilter) return false
if (sourceFilter === 'unsaved') {
if (!isDirty(drafts[r.model], r.pricing)) return false
} else if (sourceFilter !== 'all' && r.source !== sourceFilter) {
@@ -623,7 +794,70 @@ export default function ModelPricing() {
})
.slice()
.sort((a, b) => compareModelsNewestFirst(a.model, b.model))
- }, [drafts, query, rows, sourceFilter])
+ }, [drafts, query, rows, sourceFilter, channelFilter])
+
+ // 当前视图下按 provider 分组(用于分组小标题)。
+ const groupedRows = useMemo(() => {
+ const groups = new Map()
+ for (const r of filteredRows) {
+ const c = rowChannel(r)
+ const arr = groups.get(c) || []
+ arr.push(r)
+ groups.set(c, arr)
+ }
+ return CHANNEL_ORDER.filter((c) => groups.has(c)).map((c) => ({ channel: c, rows: groups.get(c)! }))
+ }, [filteredRows])
+
+ // 新模型集:localStorage 里没见过的模型。首次加载(localStorage 为空)时播种、不标新。
+ const newModels = useMemo(() => {
+ const set = new Set()
+ if (rows.length === 0) return set
+ const seen = readSeenModels()
+ if (seen === null) return set
+ for (const r of rows) {
+ if (!seen.has(r.model.toLowerCase())) set.add(r.model.toLowerCase())
+ }
+ return set
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [rows, seenBump])
+
+ useEffect(() => {
+ // 首次加载后播种"已见"集,使后续新出现的模型才被标"新"。
+ if (rows.length > 0 && readSeenModels() === null) {
+ writeSeenModels(rows.map((r) => r.model))
+ }
+ }, [rows])
+
+ const jumpToModel = useCallback((model: string) => {
+ setCatalogOpen(false)
+ setChannelFilter('all')
+ setSourceFilter('all')
+ setQuery('')
+ setJumpedModel(model.toLowerCase())
+ requestAnimationFrame(() => {
+ const el = document.getElementById(`pricing-row-${model.toLowerCase()}`)
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ window.setTimeout(() => setJumpedModel(''), 2200)
+ })
+ }, [])
+
+ const refreshCatalogModels = useCallback(async () => {
+ setRefreshingModels(true)
+ try {
+ const res = await api.refreshAllClaudeModels()
+ showToast(t('settings.pricing.catalogRefreshed', { count: res.model_count }))
+ await load()
+ } catch (error) {
+ showToast(getErrorMessage(error), 'error')
+ } finally {
+ setRefreshingModels(false)
+ }
+ }, [load, showToast, t])
+
+ const acknowledgeNewModels = useCallback(() => {
+ writeSeenModels(rows.map((r) => r.model))
+ setSeenBump((n) => n + 1)
+ }, [rows])
const sourceFilters: Array<{ id: SourceFilter; label: string; count: number }> = [
{ id: 'all', label: t('settings.pricing.filterAll'), count: counts.total },
@@ -645,18 +879,46 @@ export default function ModelPricing() {
description={t('settings.pricing.desc')}
onRefresh={() => void load()}
actions={
-
+
+
+
+
}
/>
+ setCatalogOpen(false)}
+ rows={rows}
+ newModels={newModels}
+ query={catalogQuery}
+ onQueryChange={setCatalogQuery}
+ onJump={jumpToModel}
+ onRefresh={() => void refreshCatalogModels()}
+ refreshing={refreshingModels}
+ onAcknowledge={acknowledgeNewModels}
+ />
xAI
-
- void saveOfficialConfig()} disabled={officialSaving || (!officialConfig.include_openai && !officialConfig.include_grok)}>
+ void saveOfficialConfig()} disabled={officialSaving || (!officialConfig.include_openai && !officialConfig.include_grok && !officialConfig.include_claude)}>
{officialSaving ? : }
{t('common.save')}
@@ -889,6 +1155,56 @@ export default function ModelPricing() {
+ {activeChannels.length > 1 ? (
+
+ setChannelFilter('all')}
+ className={cn(
+ 'inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-2.5 text-xs font-semibold transition-all',
+ channelFilter === 'all' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
+ )}
+ >
+ {t('settings.pricing.filterAll')}
+
+ {counts.total}
+
+
+ {activeChannels.map((c) => {
+ const active = channelFilter === c
+ return (
+ setChannelFilter(c)}
+ className={cn(
+ 'inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-2.5 text-xs font-semibold transition-all',
+ active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
+ )}
+ >
+
+ {CHANNEL_LABEL[c]}
+
+ {channelCounts[c]}
+
+
+ )
+ })}
+
+ ) : null}
- {filteredRows.map((r) => {
+ {groupedRows.map((group) => (
+
+ {channelFilter === 'all' && activeChannels.length > 1 ? (
+
+
+ {CHANNEL_LABEL[group.channel]}
+ {group.rows.length}
+
+ ) : null}
+ {group.rows.map((r) => {
const draft = drafts[r.model] ?? {}
const dirty = isDirty(draft, r.pricing)
const advDirty = isAdvancedDirty(draft, r.pricing)
@@ -979,9 +1304,11 @@ export default function ModelPricing() {
return (
@@ -994,6 +1321,11 @@ export default function ModelPricing() {
{r.model}
+ {newModels.has(r.model.toLowerCase()) ? (
+
+ {t('settings.pricing.newBadge')}
+
+ ) : null}
{r.is_alias && r.canonical_model ? (
{t('settings.pricing.aliasOf', {
@@ -1170,7 +1502,9 @@ export default function ModelPricing() {
)
- })}
+ })}
+
+ ))}
)}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index bcfb12636..db29547e4 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -3083,6 +3083,7 @@ export interface OfficialPricingSyncConfig {
interval_minutes: number
include_openai: boolean
include_grok: boolean
+ include_claude: boolean
last_attempt_at?: string
last_success_at?: string
last_error?: string
diff --git a/proxy/claude_upstream.go b/proxy/claude_upstream.go
index c04ce4ab6..6b8df69f5 100644
--- a/proxy/claude_upstream.go
+++ b/proxy/claude_upstream.go
@@ -39,6 +39,30 @@ const (
// 与官方客户端一致)。
const claudeCodeSystemBlockJSON = `{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude.","cache_control":{"type":"ephemeral"}}`
+// defaultClaudeModelIDs 是未设白名单时对外暴露的当前 Claude 模型集(别名形式,
+// Anthropic 侧会解析到带日期的具体版本)。模型演进时可在此维护,或用账号 Models
+// 白名单 / 定价页覆盖。
+var defaultClaudeModelIDs = []string{
+ "claude-opus-4-5",
+ "claude-sonnet-4-5",
+ "claude-haiku-4-5",
+}
+
+// DefaultClaudeModelIDsForAccount 返回该 Claude 账号对外可见的模型:优先账号 Models
+// 白名单,否则用当前默认集。用于 /v1/models 账号维度暴露。
+func DefaultClaudeModelIDsForAccount(account *auth.Account) []string {
+ if account == nil {
+ return nil
+ }
+ account.Mu().RLock()
+ whitelist := append([]string(nil), account.Models...)
+ account.Mu().RUnlock()
+ if len(whitelist) > 0 {
+ return whitelist
+ }
+ return append([]string(nil), defaultClaudeModelIDs...)
+}
+
// claudeAccountSupportsModel 判断 Claude Code OAuth 账号能否服务指定模型。
// 若账号设置了显式 Models 白名单,以白名单为准;否则默认放行 claude-* 模型。
func claudeAccountSupportsModel(account *auth.Account, model string) bool {
diff --git a/proxy/handler.go b/proxy/handler.go
index 2024b426b..0e3dd5073 100644
--- a/proxy/handler.go
+++ b/proxy/handler.go
@@ -8378,6 +8378,11 @@ func (h *Handler) supportedModelIDs(ctx context.Context) []string {
}
declared = antigravityPublicModelsForAccount(account)
}
+ // Claude Code OAuth 账号:账号维度暴露 claude 模型,使其进入 /v1/models
+ // 且被 resolveAnthropicModel 视为已知模型(保持原生路由,不降级为 Codex)。
+ if account.IsClaudeOAuth() {
+ declared = DefaultClaudeModelIDsForAccount(account)
+ }
// 未声明 models 白名单的 Grok 账号:补默认 Grok 模型集,让 grok-4.5 等
// 出现在 /v1/models(否则下游客户端拉不到可用的 Grok 模型名)。
if len(declared) == 0 && account.IsGrokAPI() {
diff --git a/proxy/official_model_pricing.go b/proxy/official_model_pricing.go
index 0e1e2b549..8ed30883c 100644
--- a/proxy/official_model_pricing.go
+++ b/proxy/official_model_pricing.go
@@ -24,6 +24,16 @@ type OfficialPricingSyncOptions struct {
Models []string
IncludeOpenAI bool
IncludeGrok bool
+ IncludeClaude bool
+}
+
+// OfficialAnthropicPricingURL 是 Anthropic 官方价格参考页(仅用于前端展示链接)。
+const OfficialAnthropicPricingURL = "https://www.anthropic.com/pricing"
+
+// isClaudeBillingModel 判断某规范计费键是否为 Claude 模型。
+func isClaudeBillingModel(model string) bool {
+ return strings.Contains(model, "claude") || strings.Contains(model, "opus") ||
+ strings.Contains(model, "sonnet") || strings.Contains(model, "haiku")
}
type OfficialPricingSyncResult struct {
@@ -102,16 +112,43 @@ func SyncOfficialModelPricing(ctx context.Context, db *database.DB, proxyURL str
}
}
+ // Claude:Anthropic 无可解析的官方价目文档,且账号真实模型是动态发现的(可能含
+ // opus-5 / sonnet-5 等新版)。因此对账号当前的每个 claude 模型,用内置家族定价规则
+ // (database.GetModelPricing,已含 opus/sonnet/haiku 现代档)算出权威价并落为 synced,
+ // 动态覆盖全部模型、不写死具体清单。用户仍可在定价页覆盖。
+ if options.IncludeClaude {
+ result.Sources = append(result.Sources, OfficialAnthropicPricingURL)
+ for model := range allowed {
+ if !isClaudeBillingModel(model) {
+ continue
+ }
+ base := database.GetModelPricing(model)
+ if base == nil {
+ continue
+ }
+ pricing[model] = database.ModelPricingOverrideFromPricing(base, "")
+ }
+ }
+
result.Fetched = len(pricing)
if len(pricing) == 0 {
return result, fmt.Errorf("官方页面未解析到当前模型的价格,已保留现有价格")
}
+ // 未命中判定按 provider 归类:仅对"已启用来源"的模型报缺失。
for model := range allowed {
- if strings.HasPrefix(model, "grok-") && !options.IncludeGrok {
- continue
- }
- if !strings.HasPrefix(model, "grok-") && !options.IncludeOpenAI {
- continue
+ switch {
+ case strings.HasPrefix(model, "grok-"):
+ if !options.IncludeGrok {
+ continue
+ }
+ case isClaudeBillingModel(model):
+ if !options.IncludeClaude {
+ continue
+ }
+ default:
+ if !options.IncludeOpenAI {
+ continue
+ }
}
if _, ok := pricing[model]; !ok {
result.Missing = append(result.Missing, model)
diff --git a/proxy/scoped_models.go b/proxy/scoped_models.go
index 3d6f2c690..963fc4cdd 100644
--- a/proxy/scoped_models.go
+++ b/proxy/scoped_models.go
@@ -27,6 +27,7 @@ const (
modelBackingGrok
modelBackingRelay
modelBackingAntigravity
+ modelBackingClaude
)
type scopedModelRecord struct {
@@ -76,6 +77,8 @@ func scopedModelOwner(record *scopedModelRecord) string {
return "openai"
case modelBackingAntigravity:
return "google"
+ case modelBackingClaude:
+ return "anthropic"
default:
return "codex2api"
}
@@ -222,6 +225,14 @@ func (h *Handler) scopedModelRecords(ctx context.Context, row *database.APIKeyRo
addTarget(id)
}
+ case account.IsClaudeOAuth():
+ // Claude Code OAuth 账号:账号维度暴露 claude 模型(owner=anthropic),
+ // 供下游客户端发现;调度/透传由 claude 原生路径处理。
+ for _, id := range DefaultClaudeModelIDsForAccount(account) {
+ addScopedModel(records, id, modelBackingClaude, time.Time{}, false)
+ addTarget(id)
+ }
+
default:
for _, item := range catalog.Items {
if !item.Enabled || !account.SupportsCodexModel(item.ID) {
From 116897649e67930c39553ddf0d8fe0b1ef74eff0 Mon Sep 17 00:00:00 2001
From: hu <187184415@qq.com>
Date: Mon, 31 Aug 2026 20:36:50 +0800
Subject: [PATCH 4/9] feat(claude): add backend provider parity
---
.gitignore | 6 +-
admin/account_analysis.go | 8 +-
admin/account_groups.go | 5 +
admin/account_response_builder.go | 17 +-
admin/accounts_paged.go | 91 +++++-
admin/accounts_paged_test.go | 178 ++++++++++++
admin/claude_accounts.go | 55 +++-
admin/claude_accounts_test.go | 62 +++-
admin/claude_config.go | 84 ++++++
admin/grok_export.go | 7 +-
admin/grok_export_test.go | 14 +
admin/handler.go | 196 +++++++++++--
admin/handler_test.go | 39 +++
admin/model_pricing.go | 15 +-
admin/model_probe.go | 213 +++++++++++++-
admin/model_probe_claude_test.go | 204 +++++++++++++
admin/plan_allow_grok_test.go | 8 +
admin/proxy_balance.go | 9 +-
admin/proxy_balance_test.go | 11 +
admin/responses.go | 2 +-
admin/test_connection.go | 271 +++++++++++++++++-
admin/usage_probe.go | 136 +++++++++
admin/usage_probe_test.go | 151 ++++++++++
admin/wham_daily_probe.go | 5 +-
admin/wham_daily_probe_test.go | 4 +
api/README.md | 10 +
auth/claude_account.go | 17 ++
auth/claude_fingerprint_mode.go | 149 ++++++++++
auth/claude_oauth.go | 44 ++-
auth/premium_rate_limit.go | 8 +-
auth/premium_rate_limit_test.go | 8 +
auth/scheduler_outbox_consumer.go | 5 +
auth/scheduler_outbox_consumer_test.go | 4 +
auth/store.go | 70 ++++-
auth/store_scheduler_test.go | 38 +++
auth/workspace_linked_error.go | 6 +-
auth/workspace_linked_error_test.go | 9 +
database/account_channel_test.go | 15 +
database/account_groups.go | 3 +
database/account_list_projection.go | 19 +-
database/claude_provider_migration_test.go | 117 ++++++++
database/data_migrations.go | 135 ++++++++-
database/postgres.go | 32 ++-
database/sqlite.go | 2 +
docs/API.md | 76 ++++-
docs/ARCHITECTURE.md | 2 +-
.../plans/2026-08-29-claude-parity.md | 125 ++++++++
.../specs/2026-08-29-claude-parity-design.md | 59 ++++
proxy/anthropic_test.go | 129 +++++++++
proxy/claude_upstream.go | 234 ++++++++++++++-
proxy/claude_upstream_test.go | 20 +-
proxy/claude_usage_state_test.go | 261 +++++++++++++++++
proxy/executor_test.go | 16 ++
proxy/grok_native_passthrough_test.go | 23 ++
proxy/handler.go | 41 ++-
proxy/handler_anthropic.go | 242 +++++++++++++++-
.../handler_anthropic_stream_failure_test.go | 30 ++
proxy/internal_response_test.go | 31 ++
proxy/model_registry.go | 1 +
proxy/scoped_models.go | 3 +-
proxy/scoped_models_test.go | 18 ++
61 files changed, 3673 insertions(+), 120 deletions(-)
create mode 100644 admin/claude_config.go
create mode 100644 admin/model_probe_claude_test.go
create mode 100644 auth/claude_fingerprint_mode.go
create mode 100644 database/claude_provider_migration_test.go
create mode 100644 docs/superpowers/plans/2026-08-29-claude-parity.md
create mode 100644 docs/superpowers/specs/2026-08-29-claude-parity-design.md
create mode 100644 proxy/claude_usage_state_test.go
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..8b89ef266 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 {
@@ -165,7 +173,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 +182,7 @@ func (h *Handler) buildAccountResponse(
OpenAIResponsesAPI: isOpenAIResponsesAccount,
GrokAPI: isGrokAccount,
AntigravityAPI: isAntigravityAccount,
+ ClaudeAPI: isClaudeAccount,
AntigravityAuthKind: antigravityAuthKind,
AgentIdentity: isAgentIdentityCredentialRow(row),
GrokAuthKind: grokAuthKind,
@@ -191,6 +200,8 @@ func (h *Handler) buildAccountResponse(
ModelMapping: modelMapping,
CodexClientMetadataMode: codexClientMetadataMode,
CodexFingerprintMode: codexFingerprintMode,
+ ClaudeFingerprintMode: claudeFingerprintMode,
+ Timezone: accountTimezone,
CustomHeaders: customHeaders,
ProxyURL: row.ProxyURL,
Enabled: row.Enabled,
@@ -206,6 +217,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..2662edc7b 100644
--- a/admin/accounts_paged_test.go
+++ b/admin/accounts_paged_test.go
@@ -557,6 +557,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 +605,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 +1191,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
index 0983f1016..1f5dac4ae 100644
--- a/admin/claude_accounts.go
+++ b/admin/claude_accounts.go
@@ -223,7 +223,7 @@ func (h *Handler) RefreshClaudeModels(c *gin.Context) {
writeError(c, http.StatusBadRequest, "账号缺少 access_token,请先刷新或重新导入")
return
}
- models, ferr := auth.NewClaudeAuth(strings.TrimSpace(row.ProxyURL)).FetchModels(ctx, accessToken)
+ models, ferr := auth.NewClaudeAuth(h.resolveClaudeModelProxy(id, row.ProxyURL)).FetchModels(ctx, accessToken)
if ferr != nil {
writeError(c, http.StatusBadGateway, "拉取可用模型失败: "+ferr.Error())
return
@@ -244,6 +244,7 @@ func (h *Handler) RefreshClaudeModels(c *gin.Context) {
acc.Mu().Unlock()
}
}
+ h.invalidateClaudeCatalogCaches()
c.JSON(http.StatusOK, gin.H{"message": "已更新可用模型", "models": models, "count": len(models)})
}
@@ -265,7 +266,7 @@ func (h *Handler) RefreshAllClaudeModels(c *gin.Context) {
failed++
continue
}
- models, ferr := auth.NewClaudeAuth(strings.TrimSpace(row.ProxyURL)).FetchModels(ctx, accessToken)
+ models, ferr := auth.NewClaudeAuth(h.resolveClaudeModelProxy(row.ID, row.ProxyURL)).FetchModels(ctx, accessToken)
if ferr != nil || len(models) == 0 {
failed++
continue
@@ -286,6 +287,9 @@ func (h *Handler) RefreshAllClaudeModels(c *gin.Context) {
}
refreshed++
}
+ if refreshed > 0 {
+ h.invalidateClaudeCatalogCaches()
+ }
c.JSON(http.StatusOK, gin.H{
"message": "已刷新 Claude 账号可用模型",
"refreshed": refreshed,
@@ -294,9 +298,44 @@ func (h *Handler) RefreshAllClaudeModels(c *gin.Context) {
})
}
+// 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 (h *Handler) insertClaudeAccount(c *gin.Context, ctx context.Context, name, proxyURL, timezone string, td *auth.ClaudeTokenData, source string) {
email := strings.TrimSpace(td.Email)
accountUUID := strings.TrimSpace(td.AccountUUID)
@@ -308,6 +347,11 @@ func (h *Handler) insertClaudeAccount(c *gin.Context, ctx context.Context, name,
name = "claude"
}
+ // 未显式指定时区时,回退到 ClaudeCode 全局默认(系统设置里配置)。
+ if strings.TrimSpace(timezone) == "" {
+ timezone = h.store.ClaudeDefaultTimezone()
+ }
+
// 生成稳定指纹(UA / x-app / x-stainless-*),存进 custom_headers 供请求期套用。
fingerprint := auth.GenerateClaudeFingerprint(timezone)
customHeaders := fingerprint.Headers()
@@ -328,7 +372,7 @@ func (h *Handler) insertClaudeAccount(c *gin.Context, ctx context.Context, name,
"expires_at": td.ExpiresAt.Format(time.RFC3339),
"email": email,
"account_id": accountUUID,
- "plan_type": "claude",
+ "plan_type": claudePlanOrDefault(td.PlanType),
"custom_headers": customHeaders,
"timezone": fingerprint.Timezone,
}
@@ -366,12 +410,15 @@ func (h *Handler) insertClaudeAccount(c *gin.Context, ctx context.Context, name,
ExpiresAt: td.ExpiresAt,
AccountID: accountUUID,
Email: email,
- PlanType: "claude",
+ PlanType: claudePlanOrDefault(td.PlanType),
CustomHeaders: customHeaders,
Models: claudeModels,
})
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.
+ h.scheduleImportedAccountWarmup(h.store.FindByID(id), id, source)
security.SecurityAuditLog("CLAUDE_ACCOUNT_ADDED", fmt.Sprintf("account_id=%d ip=%s", id, c.ClientIP()))
c.JSON(http.StatusOK, gin.H{
"message": "成功添加 Claude 账号",
diff --git a/admin/claude_accounts_test.go b/admin/claude_accounts_test.go
index 1edc5adc8..95ca19396 100644
--- a/admin/claude_accounts_test.go
+++ b/admin/claude_accounts_test.go
@@ -1,6 +1,66 @@
package admin
-import "testing"
+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")
diff --git a/admin/claude_config.go b/admin/claude_config.go
new file mode 100644
index 000000000..7c0e9ea59
--- /dev/null
+++ b/admin/claude_config.go
@@ -0,0 +1,84 @@
+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=跟随全局)
+}
+
+// GetClaudeConfig 返回当前 ClaudeCode 全局配置(取自运行时 Store 访问器)。
+func (h *Handler) GetClaudeConfig(c *gin.Context) {
+ c.JSON(http.StatusOK, claudeGlobalConfigDTO{
+ FingerprintMode: h.store.ClaudeFingerprintModeDefault(),
+ DefaultTimezone: h.store.ClaudeDefaultTimezone(),
+ SessionWindowLimit: h.store.ClaudeSessionWindowLimit(),
+ })
+}
+
+// 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
+ }
+
+ cfg := auth.ClaudeConfig{
+ FingerprintMode: mode,
+ DefaultTimezone: tz,
+ SessionWindowLimit: window,
+ }
+ 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)
+
+ c.JSON(http.StatusOK, gin.H{
+ "message": "已保存 ClaudeCode 全局配置",
+ "fingerprint_mode": mode,
+ "default_timezone": tz,
+ "session_window_limit": window,
+ })
+}
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 ae9e26b1e..10f9125b0 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 ""
}
@@ -1164,6 +1178,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)
@@ -1388,6 +1404,7 @@ func summarizeDashboardAccounts(rows []*database.AccountRow, runtimeAccounts []*
database.UpstreamChannelCodex: {},
database.UpstreamChannelGrok: {},
database.UpstreamChannelAntigravity: {},
+ database.UpstreamChannelClaude: {},
}
counts.total = len(rows)
for _, row := range rows {
@@ -1402,6 +1419,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]
@@ -1415,6 +1434,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]
@@ -1456,6 +1477,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 {
@@ -1471,6 +1497,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
}
@@ -1514,6 +1545,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"`
@@ -1533,6 +1565,8 @@ 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"`
+ Timezone string `json:"timezone,omitempty"`
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
HealthTier string `json:"health_tier"`
SchedulerScore float64 `json:"scheduler_score"`
@@ -1547,6 +1581,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"`
@@ -1897,6 +1933,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"`
}
@@ -1920,6 +1957,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")) != "" {
@@ -1948,9 +1986,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,
})
@@ -1975,6 +2014,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 {
@@ -1994,6 +2035,8 @@ type accountSchedulerUpdate struct {
ProxyURL database.OptionalString
CustomHeaders optionalCustomHeaders
CodexFingerprintMode database.OptionalString
+ ClaudeFingerprintMode database.OptionalString
+ Timezone database.OptionalString
CredentialUpdates map[string]interface{}
}
@@ -2065,6 +2108,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)
}
@@ -2075,6 +2129,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
}
@@ -2129,10 +2189,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) {
@@ -2157,7 +2239,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 {
@@ -2389,6 +2473,9 @@ func (h *Handler) applyAccountSchedulerRuntimeUpdate(id int64, update accountSch
if update.CustomHeaders.Set {
h.store.ApplyAccountCustomHeaders(id, update.CustomHeaders.Values)
}
+ if update.ClaudeFingerprintMode.Set {
+ h.store.ApplyAccountClaudeFingerprintMode(id, update.ClaudeFingerprintMode.Value)
+ }
if update.CodexFingerprintMode.Set {
h.store.ApplyAccountCodexFingerprintMode(id, update.CodexFingerprintMode.Value)
}
@@ -4056,8 +4143,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 {
@@ -4086,7 +4174,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
}
@@ -4102,6 +4194,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) {
@@ -4134,6 +4244,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
@@ -5557,6 +5679,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)
}
@@ -5575,7 +5710,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
@@ -5641,6 +5776,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"`
@@ -5663,7 +5799,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 == "" {
@@ -5678,9 +5816,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),
@@ -8206,6 +8345,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 归一账号套餐白名单:小写去空白、丢弃未知值并去重。
@@ -11707,6 +11850,7 @@ func (h *Handler) ListModels(c *gin.Context) {
catalog, _ := proxy.ListModelCatalog(c.Request.Context(), h.db)
catalog.GrokModels = h.grokChannelModels()
catalog.AntigravityModels = h.antigravityChannelModels()
+ catalog.ClaudeModels = h.claudeChannelModels()
c.JSON(http.StatusOK, catalog)
}
diff --git a/admin/handler_test.go b/admin/handler_test.go
index 41c0f8a08..a4e6b940e 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,44 @@ 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)
+ 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)
+ }
+}
+
// 积分顶着限流的账号 RuntimeStatus 仍是 rate_limited(用量窗口客观上打满了),
// 但它照常参与调度,仪表盘该把它算进「可用」而不是「限流」。
func TestSummarizeDashboardAccountsCountsCreditBackedAsNormal(t *testing.T) {
diff --git a/admin/model_pricing.go b/admin/model_pricing.go
index e77f0fcfe..6445a7400 100644
--- a/admin/model_pricing.go
+++ b/admin/model_pricing.go
@@ -155,6 +155,16 @@ 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"`
@@ -189,6 +199,7 @@ func (h *Handler) claudeChannelModels() []string {
models = append(models, model)
}
}
+ sort.Strings(models)
return models
}
@@ -236,7 +247,9 @@ 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())
diff --git a/admin/model_probe.go b/admin/model_probe.go
index a13190b7d..1b16de839 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,210 @@ 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()),
+ )
+ 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:
+ 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..243fd6a72
--- /dev/null
+++ b/admin/model_probe_claude_test.go
@@ -0,0 +1,204 @@
+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, "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, "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, "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 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)
+ }
+}
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..92d4bcb35 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()))
+ } 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,192 @@ 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
+ }
+ proxy.SyncClaudeUsageState(usageStore, account, resp)
+ 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))
+ if !isTransient {
+ 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
+ }
+ 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, 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, status, detail string, resp *http.Response) {
+ if h == nil || h.store == nil || account == nil || !account.IsClaudeOAuth() {
+ return
+ }
+ switch status {
+ case "rate_limited":
+ // 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))
+ }
+ }
+}
+
+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 +778,26 @@ 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 != "" {
+ 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 strings.Contains(strings.ToLower(candidate), "haiku") {
+ return strings.TrimSpace(candidate), nil
+ }
+ }
+ return strings.TrimSpace(models[0]), nil
+ }
if account == nil || !account.IsRelayStyle() {
if requested == "" {
return h.connectionTestModel(ctx), nil
@@ -1070,7 +1286,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()))
+ } 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 +1307,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, 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 +1344,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 +1358,9 @@ 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() {
+ 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 +1412,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()))
+ } 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 +1435,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..31d7d66c3 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,135 @@ func (h *Handler) ProbeUsageSnapshot(ctx context.Context, account *auth.Account)
return h.probeUsageViaResponses(ctx, account)
}
+// 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; only an
+// explicit rejected/rate-limit response is reflected by SyncClaudeUsageState.
+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 := "claude-haiku-4-5"
+ if models := proxy.DefaultClaudeModelIDsForAccount(account); len(models) > 0 {
+ // Prefer a Haiku alias for the bounded probe so an account catalog
+ // ordered by premium models does not spend an Opus request merely to
+ // populate quota metadata.
+ foundHaiku := false
+ for _, candidate := range models {
+ if strings.Contains(strings.ToLower(candidate), "haiku") && strings.TrimSpace(candidate) != "" {
+ model = strings.TrimSpace(candidate)
+ foundHaiku = true
+ break
+ }
+ }
+ if !foundHaiku {
+ for _, candidate := range models {
+ candidate = strings.TrimSpace(candidate)
+ if strings.HasPrefix(strings.ToLower(candidate), "claude-") {
+ model = candidate
+ break
+ }
+ }
+ }
+ } else {
+ account.Mu().RLock()
+ explicitInvalidCatalog := len(account.Models) > 0
+ account.Mu().RUnlock()
+ if explicitInvalidCatalog {
+ return errors.New("Claude 账号模型白名单没有有效的 claude-* 模型")
+ }
+ }
+ 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 := ""
+ if h != nil && h.store != nil {
+ proxyURL = h.store.ResolveProxyForAccount(account)
+ fingerprintMode = account.EffectiveClaudeFingerprintMode(h.store.ClaudeFingerprintModeDefault())
+ }
+ resp, err = proxy.ExecuteClaudeMessagesRequest(ctx, account, body, proxyURL, nil, fingerprintMode)
+ }
+ 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 {
+ 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..89f0de8b5 100644
--- a/admin/usage_probe_test.go
+++ b/admin/usage_probe_test.go
@@ -24,6 +24,157 @@ 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 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 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..3f2fff8e7 100644
--- a/api/README.md
+++ b/api/README.md
@@ -106,6 +106,15 @@ 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 |
+| `/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) |
@@ -140,6 +149,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
index 1631202f3..5cf39db1e 100644
--- a/auth/claude_account.go
+++ b/auth/claude_account.go
@@ -19,6 +19,16 @@ import (
// 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)
@@ -95,6 +105,10 @@ func (s *Store) refreshClaudeAccount(ctx context.Context, acc *Account, forceRef
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
}
@@ -117,6 +131,9 @@ func (s *Store) refreshClaudeAccount(ctx context.Context, acc *Account, forceRef
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{}
diff --git a/auth/claude_fingerprint_mode.go b/auth/claude_fingerprint_mode.go
new file mode 100644
index 000000000..0158ebc89
--- /dev/null
+++ b/auth/claude_fingerprint_mode.go
@@ -0,0 +1,149 @@
+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"
+
+// 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 ""
+}
+
+// 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)
+}
+
+// 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
+ }
+ 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)
+}
diff --git a/auth/claude_oauth.go b/auth/claude_oauth.go
index 034f7a137..0d672de8d 100644
--- a/auth/claude_oauth.go
+++ b/auth/claude_oauth.go
@@ -76,6 +76,8 @@ type ClaudeTokenData struct {
AccountUUID string
OrganizationUUID string
OrganizationName string
+ // PlanType 是由 profile 推导的订阅档位(pro / max-5x / max-20x / team / …)。
+ PlanType string
// ExpiresAt 是本次 access token 的过期时刻(本地时钟)。
ExpiresAt time.Time
}
@@ -99,15 +101,51 @@ type claudeTokenResponse struct {
// claudeOAuthProfile 映射 profile 端点的响应体。
type claudeOAuthProfile struct {
Account struct {
- UUID string `json:"uuid"`
- Email string `json:"email"`
+ 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 {
@@ -333,6 +371,7 @@ func (o *ClaudeAuth) ExchangeCode(ctx context.Context, code, state, verifier str
if v := strings.TrimSpace(profile.Organization.Name); v != "" {
td.OrganizationName = v
}
+ td.PlanType = DeriveClaudePlanType(profile)
}
return td, nil
}
@@ -382,6 +421,7 @@ func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*C
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
}
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 ed6299b7d..d8cc9c8f4 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,9 @@ 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 时区
+ 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 +3859,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 +5089,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 +5120,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 +8744,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 +8841,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
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/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/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/data_migrations.go b/database/data_migrations.go
index 990b80461..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()
diff --git a/database/postgres.go b/database/postgres.go
index 62ec585c2..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 的用量,超额后默认
@@ -2178,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
@@ -2535,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,
@@ -2615,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
@@ -3244,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)
@@ -4747,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)
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..f48ffe437 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,79 @@ 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。`access_token` 与 `refresh_token`
+必填;导入成功后同样会进入后台采样队列。
+
+#### 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%`。
+
+#### 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 凭据。
+
### 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/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/proxy/anthropic_test.go b/proxy/anthropic_test.go
index 8c83e6250..3049ee4b3 100644
--- a/proxy/anthropic_test.go
+++ b/proxy/anthropic_test.go
@@ -6,8 +6,10 @@ import (
"strconv"
"strings"
"testing"
+ "time"
"github.com/codex2api/auth"
+ "github.com/codex2api/database"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
)
@@ -1265,3 +1267,130 @@ func TestResolveMessagesRoutingBodySkipsFullTranslation(t *testing.T) {
t.Fatalf("speed=fast should set service_tier: %s", got)
}
}
+
+func TestNativeClaudeRoutingRespectsAvailabilityAndAPIKeyChannel(t *testing.T) {
+ store := auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 2})
+ defer store.Stop()
+ account := &auth.Account{
+ DBID: 91,
+ UpstreamType: auth.UpstreamClaude,
+ AccessToken: "claude-token",
+ Status: auth.StatusReady,
+ Models: []string{"claude-sonnet-4-5"},
+ }
+ store.AddAccount(account)
+ h := &Handler{store: store}
+
+ if !h.hasNativeClaudeAccountForModel("claude-sonnet-4-5") {
+ t.Fatal("available Claude account should enable native routing")
+ }
+
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Set(contextAPIKeyRow, &database.APIKeyRow{ID: 7, Limits: database.APIKeyLimits{UpstreamChannel: database.UpstreamChannelCodex}})
+ c.Set(contextAPIKeyID, int64(7))
+ if h.hasNativeClaudeAccountForRequest(c, "claude-sonnet-4-5") {
+ t.Fatal("a Codex-only API key must not force native Claude routing")
+ }
+
+ c.Set(contextAPIKeyRow, &database.APIKeyRow{ID: 8, Limits: database.APIKeyLimits{UpstreamChannel: database.UpstreamChannelClaude}})
+ c.Set(contextAPIKeyID, int64(8))
+ if !h.hasNativeClaudeAccountForRequest(c, "claude-sonnet-4-5") {
+ t.Fatal("a Claude API key should allow native Claude routing")
+ }
+
+ store.MarkCooldown(account, time.Minute, "rate_limited")
+ if h.hasNativeClaudeAccountForModel("claude-sonnet-4-5") {
+ t.Fatal("a cooled-down Claude account must not force native routing")
+ }
+}
+
+func TestClaudeAccountMappingDoesNotValidateOpenAIProtocolModels(t *testing.T) {
+ store := auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 2})
+ defer store.Stop()
+ store.AddAccount(&auth.Account{
+ DBID: 92,
+ UpstreamType: auth.UpstreamClaude,
+ AccessToken: "claude-token",
+ Status: auth.StatusReady,
+ Models: []string{"claude-sonnet-4-5"},
+ ModelMapping: `{"claude-alias":"claude-sonnet-4-5"}`,
+ })
+ h := &Handler{store: store}
+ if h.modelSupportedByAccountMapping("claude-alias") {
+ t.Fatal("Claude native aliases must not validate Responses/Chat/Compact models")
+ }
+}
+
+func TestModelValidatorRejectsNativeClaudeIDsOnOpenAIProtocols(t *testing.T) {
+ h := &Handler{}
+ rule := h.modelValidator([]string{"gpt-5.4", "claude-sonnet-4-5"})
+ if err := rule(gjson.Parse(`"claude-sonnet-4-5"`), "model"); err == nil {
+ t.Fatal("native Claude IDs must not pass Responses/Chat model validation")
+ }
+ if err := rule(gjson.Parse(`"gpt-5.4"`), "model"); err != nil {
+ t.Fatalf("Codex model unexpectedly rejected: %v", err)
+ }
+}
+
+func TestSupportedModelIDsDoesNotExposeClaudeAccountAliases(t *testing.T) {
+ store := auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 2})
+ defer store.Stop()
+ store.AddAccount(&auth.Account{
+ DBID: 93,
+ UpstreamType: auth.UpstreamClaude,
+ AccessToken: "claude-token",
+ Status: auth.StatusReady,
+ Models: []string{"claude-sonnet-4-5"},
+ ModelMapping: `{"client-alias":"claude-sonnet-4-5"}`,
+ })
+ h := &Handler{store: store}
+ models := h.supportedModelIDs(nil)
+ seen := make(map[string]bool, len(models))
+ for _, model := range models {
+ seen[strings.ToLower(strings.TrimSpace(model))] = true
+ }
+ if !seen["claude-sonnet-4-5"] {
+ t.Fatal("native Claude model should remain discoverable")
+ }
+ if seen["client-alias"] {
+ t.Fatal("Claude account mapping aliases must not enter the shared OpenAI catalog")
+ }
+}
+
+func TestNativeClaudeRoutingDoesNotReapplyCodexModelMapping(t *testing.T) {
+ store := auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 2})
+ defer store.Stop()
+ store.SetModelMapping(`{"claude-sonnet-4-5":"gpt-5.4"}`)
+ store.AddAccount(&auth.Account{
+ DBID: 94,
+ UpstreamType: auth.UpstreamClaude,
+ AccessToken: "claude-token",
+ Status: auth.StatusReady,
+ Models: []string{"claude-sonnet-4-5"},
+ })
+ h := &Handler{store: store}
+ body := h.resolveMessagesRoutingBodyForRequest(nil, []byte(`{"model":"claude-sonnet-4-5","messages":[]}`), "claude-sonnet-4-5", []string{"claude-sonnet-4-5", "gpt-5.4"})
+ if got := gjson.GetBytes(body, "model").String(); got != "claude-sonnet-4-5" {
+ t.Fatalf("native Claude routing model = %q, want native ID", got)
+ }
+}
+
+func TestNativeClaudeRoutingResolvesClaudeAliasBeforePassthrough(t *testing.T) {
+ store := auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 2})
+ defer store.Stop()
+ store.SetModelMapping(`{"client-alias":"claude-sonnet-4-5"}`)
+ store.AddAccount(&auth.Account{
+ DBID: 95, UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token", Status: auth.StatusReady,
+ Models: []string{"claude-sonnet-4-5"},
+ })
+ h := &Handler{store: store}
+ if !h.hasNativeClaudeAccountForRequest(nil, "client-alias") {
+ t.Fatal("Claude alias should resolve to a native Claude account")
+ }
+ body := h.resolveMessagesRoutingBodyForRequest(nil, []byte(`{"model":"client-alias","messages":[]}`), "client-alias", []string{"client-alias", "claude-sonnet-4-5"})
+ if got := gjson.GetBytes(body, "model").String(); got != "claude-sonnet-4-5" {
+ t.Fatalf("Claude alias routing model = %q, want native target", got)
+ }
+}
diff --git a/proxy/claude_upstream.go b/proxy/claude_upstream.go
index 6b8df69f5..886a27ac8 100644
--- a/proxy/claude_upstream.go
+++ b/proxy/claude_upstream.go
@@ -17,8 +17,11 @@ package proxy
import (
"bytes"
"context"
+ "math"
"net/http"
+ "strconv"
"strings"
+ "time"
"github.com/codex2api/auth"
"github.com/tidwall/gjson"
@@ -48,8 +51,9 @@ var defaultClaudeModelIDs = []string{
"claude-haiku-4-5",
}
-// DefaultClaudeModelIDsForAccount 返回该 Claude 账号对外可见的模型:优先账号 Models
-// 白名单,否则用当前默认集。用于 /v1/models 账号维度暴露。
+// DefaultClaudeModelIDsForAccount 返回该 Claude 账号对外可见的原生模型:优先
+// 账号 Models 白名单,否则用当前默认集。历史/误配的非 claude-* 条目必须在
+// 目录源头过滤,避免 /v1/models 发布一个调度器随后必然拒绝的模型。
func DefaultClaudeModelIDsForAccount(account *auth.Account) []string {
if account == nil {
return nil
@@ -58,7 +62,21 @@ func DefaultClaudeModelIDsForAccount(account *auth.Account) []string {
whitelist := append([]string(nil), account.Models...)
account.Mu().RUnlock()
if len(whitelist) > 0 {
- return whitelist
+ visible := make([]string, 0, len(whitelist))
+ seen := make(map[string]struct{}, len(whitelist))
+ for _, model := range whitelist {
+ model = strings.TrimSpace(model)
+ key := strings.ToLower(model)
+ if !strings.HasPrefix(key, "claude-") {
+ continue
+ }
+ if _, exists := seen[key]; exists {
+ continue
+ }
+ seen[key] = struct{}{}
+ visible = append(visible, model)
+ }
+ return visible
}
return append([]string(nil), defaultClaudeModelIDs...)
}
@@ -73,6 +91,9 @@ func claudeAccountSupportsModel(account *auth.Account, model string) bool {
if model == "" {
return false
}
+ if !strings.HasPrefix(strings.ToLower(model), "claude-") {
+ return false
+ }
account.Mu().RLock()
whitelist := append([]string(nil), account.Models...)
account.Mu().RUnlock()
@@ -98,7 +119,7 @@ func markClaudeNativeRoute(resp *http.Response) {
// ExecuteClaudeMessagesRequest 把入站 Anthropic Messages 请求透传给 Claude Code
// OAuth 账号对应的上游,返回原始上游响应。
-func ExecuteClaudeMessagesRequest(ctx context.Context, account *auth.Account, requestBody []byte, proxyOverride string, headers http.Header) (*http.Response, error) {
+func ExecuteClaudeMessagesRequest(ctx context.Context, account *auth.Account, requestBody []byte, proxyOverride string, headers http.Header, fingerprintMode string) (*http.Response, error) {
if ctx == nil {
ctx = context.Background()
}
@@ -129,7 +150,7 @@ func ExecuteClaudeMessagesRequest(ctx context.Context, account *auth.Account, re
if err != nil {
return nil, ErrInternalError("创建 Claude 请求失败", err)
}
- applyClaudeMessagesHeaders(req, accessToken, headers, stream, fingerprint)
+ applyClaudeMessagesHeaders(req, accessToken, headers, stream, fingerprint, fingerprintMode)
resp, err := client.Do(req)
if err != nil {
@@ -143,14 +164,14 @@ func ExecuteClaudeMessagesRequest(ctx context.Context, account *auth.Account, re
// applyClaudeMessagesHeaders 设置透传请求头。
//
-// 指纹一致性策略:
-// - 若入站是**真实 Claude Code 客户端**(自带 user-agent / x-stainless-* 身份头),
-// 原样保留其身份——它本身就是一致的,伪造反而破坏一致性。
-// - 若入站缺该身份头(如 OpenAI SDK 等非原生客户端),用该账号绑定的稳定指纹补齐,
-// 使这个账号对外始终呈现同一套 Claude Code 身份。
+// 指纹一致性策略(由 fingerprintMode 决定,来自账号级覆盖 > 全局默认):
+// - preserve(默认):入站真实 Claude Code 客户端的身份头优先保留,缺失才用账号
+// 绑定指纹补齐——它本身就是一致的,伪造反而破坏一致性。
+// - force:无条件用账号绑定指纹覆盖入站身份头,保证该账号对 Anthropic 始终呈现
+// 同一套 Claude Code 身份(强制替换,防跨客户端指纹漂移)。
//
// fingerprint 为账号绑定指纹头(规范化头名→值),来自 credentials.custom_headers。
-func applyClaudeMessagesHeaders(req *http.Request, accessToken string, incoming http.Header, stream bool, fingerprint map[string]string) {
+func applyClaudeMessagesHeaders(req *http.Request, accessToken string, incoming http.Header, stream bool, fingerprint map[string]string, fingerprintMode string) {
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
// anthropic-version:优先保留入站真实客户端的值。
@@ -173,14 +194,21 @@ func applyClaudeMessagesHeaders(req *http.Request, accessToken string, incoming
for k, v := range fingerprint {
fpLower[strings.ToLower(strings.TrimSpace(k))] = v
}
- // 身份头:入站有则保留,无则用账号指纹补齐。
+ // force 模式:账号指纹优先,无条件覆盖入站身份头(有指纹才覆盖,避免抹成空)。
+ // preserve 模式:入站有则保留,无则用账号指纹补齐。
+ force := auth.NormalizeClaudeFingerprintMode(fingerprintMode) == auth.ClaudeFingerprintModeForce
for _, name := range auth.ClaudeIdentityHeaderNames {
+ fpVal := strings.TrimSpace(fpLower[name])
+ if force && fpVal != "" {
+ req.Header.Set(name, fpVal)
+ continue
+ }
if v := strings.TrimSpace(incoming.Get(name)); v != "" {
req.Header.Set(name, v)
continue
}
- if v := strings.TrimSpace(fpLower[name]); v != "" {
- req.Header.Set(name, v)
+ if fpVal != "" {
+ req.Header.Set(name, fpVal)
}
}
// 保底:连指纹都没有(老账号未生成指纹)时,给一个稳定的默认 UA,避免空 UA 破绽。
@@ -207,7 +235,7 @@ func claudeInvisibleRune(r rune) bool {
switch r {
case 0x200B, 0x200C, 0x200D, // zero-width space / non-joiner / joiner
0x2060, 0xFEFF, // word joiner / BOM (zero-width no-break space)
- 0x180E, // mongolian vowel separator
+ 0x180E, // mongolian vowel separator
0x202A, 0x202B, 0x202C, 0x202D, 0x202E, // bidi embedding / override / pop
0x2066, 0x2067, 0x2068, 0x2069: // bidi isolates
return true
@@ -326,3 +354,179 @@ func injectClaudeCodeSystemPrompt(body []byte) []byte {
}
return body
}
+
+// ── Claude 统一限流头 → 账号用量快照 ─────────────────────────────────────────
+//
+// Anthropic 对 Claude Code OAuth 账号的每个响应都带统一限流头(实测 2026-08):
+// anthropic-ratelimit-unified-5h-utilization: 0.01 ← 5h 滚动窗口利用率
+// anthropic-ratelimit-unified-5h-reset: 1787943000 (unix 秒)
+// anthropic-ratelimit-unified-7d-utilization: 0.0 ← 周窗口利用率
+// anthropic-ratelimit-unified-7d-reset: 1788253200
+// anthropic-ratelimit-unified-status: allowed | rejected
+// 该族头为 0-1 小数约定(同响应的 fallback-percentage: 0.5 即 50%)。
+
+// claudeRatelimitHeaderPct 解析 utilization 头为百分数(0-100)。
+// 保守起见 >1.5 的值视作上游已改用百分数,不再 ×100,避免进度条爆表。
+func claudeRatelimitHeaderPct(v string) (float64, bool) {
+ v = strings.TrimSpace(v)
+ if v == "" {
+ return 0, false
+ }
+ f, err := strconv.ParseFloat(v, 64)
+ if err != nil || math.IsNaN(f) || math.IsInf(f, 0) || f < 0 {
+ return 0, false
+ }
+ if f <= 1.5 {
+ f *= 100
+ }
+ if f > 100 {
+ f = 100
+ }
+ return f, true
+}
+
+// claudeRatelimitHeaderTime 解析 unix 秒时间戳头(如 *-reset)。
+func claudeRatelimitHeaderTime(v string) time.Time {
+ v = strings.TrimSpace(v)
+ sec, err := strconv.ParseInt(v, 10, 64)
+ if err == nil && sec > 0 {
+ // Some compatible gateways serialize epoch milliseconds instead of the
+ // Anthropic epoch-seconds contract. Normalize that form and reject
+ // implausible values so a malformed header cannot create a multi-century
+ // account cooldown.
+ if sec > 100_000_000_000 {
+ sec /= 1000
+ }
+ if sec >= 946684800 && sec <= 4102444800 { // 2000-01-01 .. 2100-01-01
+ return time.Unix(sec, 0)
+ }
+ }
+ for _, layout := range []string{time.RFC3339, time.RFC3339Nano} {
+ if parsed, parseErr := time.Parse(layout, v); parseErr == nil {
+ return parsed
+ }
+ }
+ return time.Time{}
+}
+
+// SyncClaudeUsageState 解析 Claude 响应的统一限流头,把 5h/7d 窗口利用率与重置
+// 时刻写入与 Codex 同源的账号快照字段并持久化——管理页用量进度条/重置倒计时
+// 直接生效。429 或 unified-status=rejected 时按上游给的重置时刻精确冷却。
+// 持久化调用与 SyncCodexUsageState 同构:persist 在 ApplyUsageObservation 闭包内,
+// MarkResponsesPremium5hRateLimited 自带观察序,必须留在闭包外(usageSyncMu 不可重入)。
+func SyncClaudeUsageState(store *auth.Store, account *auth.Account, resp *http.Response) {
+ if account == nil || resp == nil {
+ return
+ }
+ h := resp.Header
+ if h == nil {
+ h = make(http.Header)
+ }
+ pct5h, ok5h := claudeRatelimitHeaderPct(h.Get("anthropic-ratelimit-unified-5h-utilization"))
+ reset5h := claudeRatelimitHeaderTime(h.Get("anthropic-ratelimit-unified-5h-reset"))
+ pct7d, ok7d := claudeRatelimitHeaderPct(h.Get("anthropic-ratelimit-unified-7d-utilization"))
+ reset7d := claudeRatelimitHeaderTime(h.Get("anthropic-ratelimit-unified-7d-reset"))
+ observedAt := time.Now()
+ if !ok5h && !ok7d {
+ // A valid native response without quota metadata is still evidence that
+ // the token was observed. Record freshness without inventing a quota
+ // percentage, otherwise the scheduler would repeat a paid probe forever.
+ account.MarkClaudeUsageObservation(observedAt)
+ }
+
+ if ok5h || ok7d {
+ account.ApplyUsageObservation(observedAt, func() {
+ if ok5h {
+ account.SetUsageSnapshot5hAt(pct5h, reset5h, observedAt)
+ }
+ if ok7d && !reset7d.IsZero() {
+ account.SetReset7dAt(reset7d)
+ }
+ if store == nil {
+ return
+ }
+ if ok7d {
+ store.PersistUsageSnapshot(account, pct7d)
+ } else if ok5h {
+ store.PersistUsageSnapshot5hOnly(account)
+ }
+ })
+ // A 7d-only unified response is still authoritative for the long
+ // window, and therefore also authoritative evidence that a previously
+ // cached 5h window is absent. Use the same observation timestamp so a
+ // newer concurrent response wins and cannot be erased by this cleanup.
+ if ok7d && !ok5h && store != nil {
+ if _, hasStale5h := account.GetUsagePercent5h(); hasStale5h {
+ store.ClearAbsentUsageSnapshot5hAt(account, observedAt)
+ }
+ }
+ }
+
+ // 上游拒绝(429 / unified-status=rejected)时,必须**按真实耗尽的窗口精确归因**,
+ // 否则会把通用/边缘/周窗口的限流一律误标成「5h 窗口 100% 耗尽」并长时间冷却。
+ // 注意不匹配 overage-status(那是溢出计费开关,200 响应上也会是 rejected)。
+ rejected := resp.StatusCode == http.StatusTooManyRequests ||
+ strings.EqualFold(strings.TrimSpace(h.Get("anthropic-ratelimit-unified-status")), "rejected")
+ if rejected && store != nil {
+ claim := strings.ToLower(strings.TrimSpace(h.Get("anthropic-ratelimit-unified-representative-claim")))
+ fiveHourExhausted := (ok5h && pct5h >= 100) || claim == "five_hour" || claim == "five-hour" || claim == "5h"
+ sevenDayExhausted := (ok7d && pct7d >= 100) || claim == "seven_day" || claim == "seven-day" || claim == "7d"
+ switch {
+ case sevenDayExhausted:
+ // 周窗口耗尽:记到 7d 窗口(冷却到 7d 重置),不动 5h。上面已按 7d-utilization
+ // 持久化;若上游只给了 representative-claim 而无 utilization,则补写 7d=100。
+ if !(ok7d && pct7d >= 100) {
+ r7 := reset7d
+ if r7.IsZero() {
+ r7 = claudeRatelimitHeaderTime(h.Get("anthropic-ratelimit-unified-reset"))
+ }
+ account.ApplyUsageObservation(time.Now(), func() {
+ account.SetUsageSnapshot(100, time.Now())
+ if !r7.IsZero() {
+ account.SetReset7dAt(r7)
+ }
+ store.PersistUsageSnapshot(account, 100)
+ })
+ }
+ store.MarkUsage7dRateLimited(account)
+ case fiveHourExhausted:
+ // 5h 窗口确实耗尽:标 5h 限流,冷却到 5h 重置。
+ resetAt := reset5h
+ if resetAt.IsZero() {
+ resetAt = claudeRatelimitHeaderTime(h.Get("anthropic-ratelimit-unified-reset"))
+ }
+ store.MarkResponsesPremium5hRateLimited(account, resetAt)
+ default:
+ // 无任何窗口耗尽信号(通用/边缘/IP 限流,如 rate_limit_error,常无 unified 头)→
+ // 只做短退避,绝不标 5h=100%。优先用 Retry-After,否则给保守默认。
+ store.MarkCooldown(account, claudeGenericRateLimitBackoff(h), "rate_limited")
+ }
+ }
+}
+
+// claudeGenericRateLimitBackoff 返回通用限流(非窗口耗尽)的短冷却时长:
+// 优先取 Retry-After(秒或 HTTP-date),否则默认 1 分钟;上限 15 分钟避免误封过久。
+func claudeGenericRateLimitBackoff(h http.Header) time.Duration {
+ const def = time.Minute
+ const max = 15 * time.Minute
+ ra := strings.TrimSpace(h.Get("Retry-After"))
+ if ra == "" {
+ return def
+ }
+ if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
+ d := time.Duration(secs) * time.Second
+ if d > max {
+ return max
+ }
+ return d
+ }
+ if t, err := http.ParseTime(ra); err == nil {
+ if d := time.Until(t); d > 0 {
+ if d > max {
+ return max
+ }
+ return d
+ }
+ }
+ return def
+}
diff --git a/proxy/claude_upstream_test.go b/proxy/claude_upstream_test.go
index e8d0bec1c..36a6cff15 100644
--- a/proxy/claude_upstream_test.go
+++ b/proxy/claude_upstream_test.go
@@ -119,7 +119,7 @@ func TestApplyClaudeMessagesHeaders_PreservesIncoming(t *testing.T) {
incoming.Set("user-agent", "claude-cli/9.9.9 (external, cli)")
incoming.Set("x-stainless-os", "MacOS")
fp := map[string]string{"User-Agent": "claude-cli/1.0.0 (external, cli)", "X-Stainless-OS": "Linux"}
- applyClaudeMessagesHeaders(req, "tok", incoming, false, fp)
+ applyClaudeMessagesHeaders(req, "tok", incoming, false, fp, "")
// 入站真实客户端头应优先保留,不被指纹覆盖。
if req.Header.Get("User-Agent") != "claude-cli/9.9.9 (external, cli)" {
t.Fatalf("应保留入站 UA, got %s", req.Header.Get("User-Agent"))
@@ -139,7 +139,7 @@ func TestApplyClaudeMessagesHeaders_UsesFingerprintWhenAbsent(t *testing.T) {
"X-App": "cli",
"X-Stainless-OS": "Linux",
}
- applyClaudeMessagesHeaders(req, "tok", http.Header{}, false, fp)
+ applyClaudeMessagesHeaders(req, "tok", http.Header{}, false, fp, "")
if req.Header.Get("User-Agent") != "claude-cli/2.1.220 (external, cli)" {
t.Fatalf("缺入站头时应用指纹 UA, got %s", req.Header.Get("User-Agent"))
}
@@ -150,3 +150,19 @@ func TestApplyClaudeMessagesHeaders_UsesFingerprintWhenAbsent(t *testing.T) {
t.Fatal("anthropic-beta 应含 oauth")
}
}
+
+func TestApplyClaudeMessagesHeaders_ForceOverridesIncoming(t *testing.T) {
+ req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil)
+ incoming := http.Header{}
+ incoming.Set("user-agent", "claude-cli/9.9.9 (external, cli)")
+ incoming.Set("x-stainless-os", "MacOS")
+ fp := map[string]string{"User-Agent": "claude-cli/1.0.0 (external, cli)", "X-Stainless-OS": "Linux"}
+ applyClaudeMessagesHeaders(req, "tok", incoming, false, fp, "force")
+ // force 模式:账号指纹无条件覆盖入站身份头。
+ if req.Header.Get("User-Agent") != "claude-cli/1.0.0 (external, cli)" {
+ t.Fatalf("force 应用指纹 UA, got %s", req.Header.Get("User-Agent"))
+ }
+ if req.Header.Get("X-Stainless-Os") != "Linux" {
+ t.Fatalf("force 应用指纹 x-stainless-os, got %s", req.Header.Get("X-Stainless-Os"))
+ }
+}
diff --git a/proxy/claude_usage_state_test.go b/proxy/claude_usage_state_test.go
new file mode 100644
index 000000000..96fe473b8
--- /dev/null
+++ b/proxy/claude_usage_state_test.go
@@ -0,0 +1,261 @@
+package proxy
+
+import (
+ "net/http"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/codex2api/auth"
+ "github.com/codex2api/database"
+)
+
+// respWith 构造一个带指定状态码与限流头的假响应,用于离线验证 SyncClaudeUsageState 的归因。
+func respWith(status int, headers map[string]string) *http.Response {
+ h := http.Header{}
+ for k, v := range headers {
+ h.Set(k, v)
+ }
+ return &http.Response{StatusCode: status, Header: h}
+}
+
+func newSyncTestStore() *auth.Store {
+ return auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 2})
+}
+
+// 通用/边缘限流(rate_limit_error,无 unified 配额头)→ 只做短退避,绝不标 5h=100%。
+func TestSyncClaudeUsageState_GenericRateLimit_ShortBackoff_No5h(t *testing.T) {
+ store := newSyncTestStore()
+ defer store.Stop()
+ acc := &auth.Account{UpstreamType: auth.UpstreamClaude}
+
+ // 真实的 Cloudflare/Anthropic 通用 rate_limit_error 429:带边缘头但无 unified 配额头。
+ SyncClaudeUsageState(store, acc, respWith(http.StatusTooManyRequests, map[string]string{
+ "content-type": "application/json",
+ "cf-ray": "a32a38ebdcecf343-BOS",
+ }))
+
+ if pct, ok := acc.GetUsagePercent5h(); ok && pct >= 100 {
+ t.Fatalf("通用 429 不应把 5h 置 100,实际 pct=%v ok=%v", pct, ok)
+ }
+ if acc.Status != auth.StatusCooldown {
+ t.Fatalf("通用 429 应进入短冷却,status=%v", acc.Status)
+ }
+ // 短退避:冷却应在 ~1 分钟量级,远小于 5h。
+ if until := time.Until(acc.CooldownUtil); until <= 0 || until > 20*time.Minute {
+ t.Fatalf("通用 429 冷却应为短退避(<=20m),实际 until=%v", until)
+ }
+ t.Logf("通用限流: status=cooldown, 冷却剩余=%v, 5h 未被误置", time.Until(acc.CooldownUtil).Round(time.Second))
+}
+
+func TestSyncClaudeUsageState_RateLimitWithoutResponseHeadersStillBacksOff(t *testing.T) {
+ store := newSyncTestStore()
+ defer store.Stop()
+ acc := &auth.Account{UpstreamType: auth.UpstreamClaude}
+ SyncClaudeUsageState(store, acc, &http.Response{StatusCode: http.StatusTooManyRequests})
+ if acc.Status != auth.StatusCooldown {
+ t.Fatalf("headerless Claude 429 status = %v, want cooldown", acc.Status)
+ }
+}
+
+func TestSyncClaudeUsageState_HeaderlessSuccessUpdatesProbeFreshness(t *testing.T) {
+ store := newSyncTestStore()
+ defer store.Stop()
+ acc := &auth.Account{UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token", Status: auth.StatusReady}
+ SyncClaudeUsageState(store, acc, respWith(http.StatusOK, nil))
+ if acc.NeedsUsageProbe(10 * time.Minute) {
+ t.Fatal("a successful native Claude response without quota headers should count as a fresh observation")
+ }
+}
+
+// 5h 窗口真实耗尽(utilization=100 + representative-claim=five_hour)→ 标 5h=100,冷却到 5h 重置。
+func TestSyncClaudeUsageState_FiveHourExhausted_Marks5h(t *testing.T) {
+ store := newSyncTestStore()
+ defer store.Stop()
+ acc := &auth.Account{UpstreamType: auth.UpstreamClaude}
+ reset5h := time.Now().Add(3 * time.Hour).Unix()
+
+ SyncClaudeUsageState(store, acc, respWith(http.StatusTooManyRequests, map[string]string{
+ "anthropic-ratelimit-unified-status": "rejected",
+ "anthropic-ratelimit-unified-representative-claim": "five_hour",
+ "anthropic-ratelimit-unified-5h-utilization": "1.0",
+ "anthropic-ratelimit-unified-5h-reset": itoa(reset5h),
+ }))
+
+ if pct, ok := acc.GetUsagePercent5h(); !ok || pct < 100 {
+ t.Fatalf("5h 耗尽应标 5h=100,实际 pct=%v ok=%v", pct, ok)
+ }
+ if acc.Status != auth.StatusCooldown {
+ t.Fatalf("5h 耗尽应进入冷却,status=%v", acc.Status)
+ }
+ t.Logf("5h 耗尽: 5h=100, 冷却剩余≈%v", time.Until(acc.CooldownUtil).Round(time.Minute))
+}
+
+// 周窗口真实耗尽(7d-utilization=100 + representative-claim=seven_day)→ 记 7d,不砸 5h。
+func TestSyncClaudeUsageState_SevenDayExhausted_Marks7dNot5h(t *testing.T) {
+ store := newSyncTestStore()
+ defer store.Stop()
+ acc := &auth.Account{UpstreamType: auth.UpstreamClaude}
+ reset7d := time.Now().Add(3 * 24 * time.Hour).Unix()
+
+ SyncClaudeUsageState(store, acc, respWith(http.StatusTooManyRequests, map[string]string{
+ "anthropic-ratelimit-unified-status": "rejected",
+ "anthropic-ratelimit-unified-representative-claim": "seven_day",
+ "anthropic-ratelimit-unified-7d-utilization": "1.0",
+ "anthropic-ratelimit-unified-7d-reset": itoa(reset7d),
+ }))
+
+ if pct, ok := acc.GetUsagePercent5h(); ok && pct >= 100 {
+ t.Fatalf("周窗口耗尽不应把 5h 置 100,实际 pct=%v ok=%v", pct, ok)
+ }
+ if pct, ok := acc.GetUsagePercent7d(); !ok || pct < 100 {
+ t.Fatalf("周窗口耗尽应标 7d=100,实际 pct=%v ok=%v", pct, ok)
+ }
+ if acc.Status != auth.StatusCooldown {
+ t.Fatalf("周窗口耗尽应进入冷却,status=%v", acc.Status)
+ }
+ t.Logf("周窗口耗尽: 7d=100, 5h 未被误置, 冷却剩余≈%v", time.Until(acc.CooldownUtil).Round(time.Hour))
+}
+
+// 200 正常响应携带利用率头 → 只更新快照,不进入任何冷却。
+func TestSyncClaudeUsageState_OK200_UpdatesSnapshotNoCooldown(t *testing.T) {
+ store := newSyncTestStore()
+ defer store.Stop()
+ acc := &auth.Account{UpstreamType: auth.UpstreamClaude}
+
+ SyncClaudeUsageState(store, acc, respWith(http.StatusOK, map[string]string{
+ "anthropic-ratelimit-unified-status": "allowed",
+ "anthropic-ratelimit-unified-5h-utilization": "0.01",
+ "anthropic-ratelimit-unified-7d-utilization": "0.0",
+ }))
+
+ if pct, ok := acc.GetUsagePercent5h(); !ok || pct != 1 {
+ t.Fatalf("200 响应应写入 5h=1(0.01→1%%),实际 pct=%v ok=%v", pct, ok)
+ }
+ if acc.Status == auth.StatusCooldown {
+ t.Fatalf("200 响应不应进入冷却")
+ }
+}
+
+func TestSyncClaudeUsageState_SevenDayOnlyClearsStaleFiveHourSnapshot(t *testing.T) {
+ store := newSyncTestStore()
+ defer store.Stop()
+ acc := &auth.Account{UpstreamType: auth.UpstreamClaude}
+ acc.SetUsageSnapshot5hAt(100, time.Now().Add(2*time.Hour), time.Now().Add(-time.Minute))
+
+ SyncClaudeUsageState(store, acc, respWith(http.StatusOK, map[string]string{
+ "anthropic-ratelimit-unified-status": "allowed",
+ "anthropic-ratelimit-unified-7d-utilization": "0.2",
+ }))
+
+ if _, ok := acc.GetUsagePercent5h(); ok {
+ t.Fatal("authoritative 7d-only response must clear a stale 5h snapshot")
+ }
+ if pct, ok := acc.GetUsagePercent7d(); !ok || pct != 20 {
+ t.Fatalf("7d snapshot = (%v, %v), want 20%% valid", pct, ok)
+ }
+}
+
+func TestSyncClaudeUsageState_BothWindowsExhaustedPrefersSevenDayReset(t *testing.T) {
+ store := newSyncTestStore()
+ defer store.Stop()
+ acc := &auth.Account{UpstreamType: auth.UpstreamClaude}
+ reset5h := time.Now().Add(2 * time.Hour).Unix()
+ reset7d := time.Now().Add(48 * time.Hour).Unix()
+
+ SyncClaudeUsageState(store, acc, respWith(http.StatusTooManyRequests, map[string]string{
+ "anthropic-ratelimit-unified-5h-utilization": "1.0",
+ "anthropic-ratelimit-unified-5h-reset": itoa(reset5h),
+ "anthropic-ratelimit-unified-7d-utilization": "1.0",
+ "anthropic-ratelimit-unified-7d-reset": itoa(reset7d),
+ }))
+
+ if remaining := time.Until(acc.CooldownUtil); remaining < 47*time.Hour {
+ t.Fatalf("both exhausted cooldown = %v, want seven-day reset", remaining)
+ }
+}
+
+func TestClaudeRatelimitHeaderTimeAcceptsRFC3339(t *testing.T) {
+ want := time.Date(2026, 8, 29, 12, 34, 56, 0, time.UTC)
+ if got := claudeRatelimitHeaderTime(want.Format(time.RFC3339)); !got.Equal(want) {
+ t.Fatalf("RFC3339 reset = %v, want %v", got, want)
+ }
+}
+
+func TestClaudeRatelimitHeaderTimeNormalizesMillisecondsAndRejectsOutliers(t *testing.T) {
+ want := time.Date(2026, 8, 29, 12, 34, 56, 0, time.UTC)
+ if got := claudeRatelimitHeaderTime(strconv.FormatInt(want.Unix()*1000, 10)); !got.Equal(want) {
+ t.Fatalf("epoch-millisecond reset = %v, want %v", got, want)
+ }
+ if got := claudeRatelimitHeaderTime("999999999999999999"); !got.IsZero() {
+ t.Fatalf("outlier reset = %v, want zero", got)
+ }
+}
+
+func TestClaudeRatelimitHeaderPctRejectsNonFiniteValues(t *testing.T) {
+ for _, raw := range []string{"NaN", "+Inf", "-Inf"} {
+ if value, ok := claudeRatelimitHeaderPct(raw); ok || value != 0 {
+ t.Fatalf("utilization %q parsed as (%v, %v), want invalid", raw, value, ok)
+ }
+ }
+}
+
+func TestClaudeAccountSupportsOnlyNativeModelIDs(t *testing.T) {
+ account := &auth.Account{UpstreamType: auth.UpstreamClaude, Models: []string{"gpt-5.4", "claude-sonnet-4-5"}}
+ if claudeAccountSupportsModel(account, "gpt-5.4") {
+ t.Fatal("Claude account must not claim an OpenAI model")
+ }
+ if !claudeAccountSupportsModel(account, "claude-sonnet-4-5") {
+ t.Fatal("Claude account should support its native model")
+ }
+}
+
+func TestClaudeNativeBodyOnlyAuthFailureCoolsAccount(t *testing.T) {
+ store := newSyncTestStore()
+ defer store.Stop()
+ account := &auth.Account{UpstreamType: auth.UpstreamClaude, AccessToken: "token", Status: auth.StatusReady}
+ h := &Handler{store: store}
+ outcome := streamOutcome{
+ logStatusCode: http.StatusUnauthorized,
+ failurePayload: []byte(`{"type":"error","error":{"type":"authentication_error","message":"token expired"}}`),
+ }
+ _ = h.applyClaudeNativeFailureCooldown(account, outcome, &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)}, "claude-sonnet-4-5")
+ reason, _ := account.GetCooldownSnapshot()
+ if reason != "unauthorized" {
+ t.Fatalf("body-only Claude auth failure reason = %q, want unauthorized", reason)
+ }
+}
+
+func TestClaudeNativeBodyOnlyRateLimitDoesNotOverwriteAuthoritativeWindowCooldown(t *testing.T) {
+ store := newSyncTestStore()
+ defer store.Stop()
+ account := &auth.Account{UpstreamType: auth.UpstreamClaude, AccessToken: "token", Status: auth.StatusReady}
+ reset := time.Now().Add(4 * time.Hour)
+ SyncClaudeUsageState(store, account, respWith(http.StatusOK, map[string]string{
+ "anthropic-ratelimit-unified-status": "rejected",
+ "anthropic-ratelimit-unified-representative-claim": "five_hour",
+ "anthropic-ratelimit-unified-5h-utilization": "1",
+ "anthropic-ratelimit-unified-5h-reset": strconv.FormatInt(reset.Unix(), 10),
+ }))
+ _, before := account.GetCooldownSnapshot()
+ outcome := streamOutcome{logStatusCode: http.StatusTooManyRequests, failurePayload: []byte(`{"type":"error","error":{"type":"rate_limit_error"}}`)}
+ _ = (&Handler{store: store}).applyClaudeNativeFailureCooldown(account, outcome, &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)}, "claude-sonnet-4-5")
+ reason, after := account.GetCooldownSnapshot()
+ if reason != auth.ResponsesRateLimitedCooldownReason || after.Before(before.Add(-time.Second)) || after.After(before.Add(time.Second)) {
+ t.Fatalf("body-only fallback overwrote authoritative cooldown: reason=%q before=%v after=%v", reason, before, after)
+ }
+}
+
+func TestDefaultClaudeModelIDsFiltersInvalidAndDuplicateEntries(t *testing.T) {
+ account := &auth.Account{UpstreamType: auth.UpstreamClaude, Models: []string{
+ "gpt-5.4", "Claude-Sonnet-4-5", "claude-sonnet-4-5", "gemini-2.5-pro",
+ }}
+ got := DefaultClaudeModelIDsForAccount(account)
+ if len(got) != 1 || got[0] != "Claude-Sonnet-4-5" {
+ t.Fatalf("filtered Claude model catalog = %v, want one native deduplicated ID", got)
+ }
+}
+
+func itoa(v int64) string {
+ return strconv.FormatInt(v, 10)
+}
diff --git a/proxy/executor_test.go b/proxy/executor_test.go
index 42f3fc89e..053d363e7 100644
--- a/proxy/executor_test.go
+++ b/proxy/executor_test.go
@@ -286,6 +286,22 @@ func TestClassifyResponseFailedOutcomeDeterministicClientErrors(t *testing.T) {
}
}
+func TestClassifyResponseFailedOutcomeAnthropicAuthAndPermissionErrors(t *testing.T) {
+ for _, tc := range []struct {
+ typ string
+ want int
+ }{
+ {typ: "authentication_error", want: http.StatusUnauthorized},
+ {typ: "invalid_token", want: http.StatusUnauthorized},
+ {typ: "permission_error", want: http.StatusForbidden},
+ } {
+ payload := []byte(`{"type":"error","error":{"type":"` + tc.typ + `","message":"failure"}}`)
+ if got := classifyResponseFailedOutcome(payload).logStatusCode; got != tc.want {
+ t.Errorf("error type %s: status = %d, want %d", tc.typ, got, tc.want)
+ }
+ }
+}
+
func TestShouldRecyclePooledClient(t *testing.T) {
tests := []struct {
name string
diff --git a/proxy/grok_native_passthrough_test.go b/proxy/grok_native_passthrough_test.go
index bfcfa8e93..11e4ad4e0 100644
--- a/proxy/grok_native_passthrough_test.go
+++ b/proxy/grok_native_passthrough_test.go
@@ -39,6 +39,29 @@ func TestForwardGrokNativeNonStreamPreservesJSONAndFiltersHeaders(t *testing.T)
}
}
+func TestCopyClaudeNativeResponseHeadersPreservesUsageMetadata(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ header := http.Header{
+ "anthropic-ratelimit-unified-5h-utilization": []string{"0.42"},
+ "anthropic-ratelimit-unified-5h-reset": []string{"4102444800"},
+ "anthropic-ratelimit-unified-status": []string{"allowed"},
+ "anthropic-version": []string{"2023-06-01"},
+ "Authorization": []string{"Bearer secret"},
+ "Set-Cookie": []string{"secret=1"},
+ "X-Leak": []string{"nope"},
+ }
+ copyClaudeNativeResponseHeaders(ctx, header)
+ if recorder.Header().Get("anthropic-ratelimit-unified-5h-utilization") != "0.42" || recorder.Header().Get("anthropic-version") != "2023-06-01" {
+ t.Fatalf("Claude usage headers were not forwarded: %#v", recorder.Header())
+ }
+ if recorder.Header().Get("Authorization") != "" || recorder.Header().Get("Set-Cookie") != "" || recorder.Header().Get("X-Leak") != "" {
+ t.Fatalf("sensitive/unallowlisted headers leaked: %#v", recorder.Header())
+ }
+}
+
func TestProtocolNonStreamFailureRejectsPseudoSuccessPayloads(t *testing.T) {
tests := []struct {
name string
diff --git a/proxy/handler.go b/proxy/handler.go
index 0e3dd5073..74f8849ee 100644
--- a/proxy/handler.go
+++ b/proxy/handler.go
@@ -378,9 +378,11 @@ func (h *Handler) applyUpstreamChannelFilter(c *gin.Context, effectiveModel stri
return combine(grokChannelAccountFilter(effectiveModel))
case database.UpstreamChannelAntigravity:
return combine(antigravityChannelAccountFilter(effectiveModel))
+ case database.UpstreamChannelClaude:
+ return combine(claudeChannelAccountFilter(effectiveModel))
case database.UpstreamChannelCodex:
return func(account *auth.Account) bool {
- if account == nil || account.IsGrokAPI() || account.IsAntigravityAPI() {
+ if account == nil || account.IsGrokAPI() || account.IsAntigravityAPI() || account.IsClaudeOAuth() {
return false
}
return filter == nil || filter(account)
@@ -389,6 +391,22 @@ func (h *Handler) applyUpstreamChannelFilter(c *gin.Context, effectiveModel stri
return filter
}
+func claudeChannelAccountFilter(model string) auth.AccountFilter {
+ model = strings.TrimSpace(model)
+ return func(account *auth.Account) bool {
+ return account != nil && account.IsClaudeOAuth() &&
+ !account.IsModelRateLimited(model) && claudeAccountSupportsModel(account, model)
+ }
+}
+
+// excludeClaudeAccountsFilter fences the native-Messages-only Claude provider
+// from OpenAI Responses and Chat Completions routes.
+func excludeClaudeAccountsFilter(filter auth.AccountFilter) auth.AccountFilter {
+ return func(account *auth.Account) bool {
+ return account != nil && !account.IsClaudeOAuth() && (filter == nil || filter(account))
+ }
+}
+
// grokChannelAccountFilter 是 grok 渠道 Key 的账号过滤器:仅 Grok 账号;
// mapping 先行,再按账号可见目录准入;显式 Models 白名单只会进一步收窄。
func grokChannelAccountFilter(model string) auth.AccountFilter {
@@ -441,7 +459,7 @@ func accountFilterForCompactResponsesModelWithOriginal(originalModel string, eff
return func(account *auth.Account) bool {
// Grok/Antigravity 上游都没有 Responses compact 适配器。尤其不能让
// Antigravity Google bearer 落入官方 Codex executor。
- if account.IsGrokAPI() || account.IsAntigravityAPI() {
+ if account.IsGrokAPI() || account.IsAntigravityAPI() || account.IsClaudeOAuth() {
return false
}
return inner(account)
@@ -550,7 +568,7 @@ func (h *Handler) modelSupportedByAccountMapping(model string) bool {
return false
}
for _, account := range h.store.Accounts() {
- if account == nil || !account.IsRelayStyle() {
+ if account == nil || !account.IsRelayStyle() || account.IsClaudeOAuth() {
continue
}
if account.IsAntigravityAPI() {
@@ -570,6 +588,12 @@ func (h *Handler) modelSupportedByAccountMapping(model string) bool {
func (h *Handler) modelValidator(supportedModels []string) api.ValidationRule {
validModels := make(map[string]bool, len(supportedModels))
for _, model := range supportedModels {
+ // Native Claude model IDs belong exclusively to /v1/messages. A
+ // configured Claude->Codex mapping is applied before validation, so a
+ // successfully mapped request arrives here under its Codex target ID.
+ if strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "claude-") {
+ continue
+ }
validModels[model] = true
}
return func(value gjson.Result, path string) *api.ValidationError {
@@ -1328,6 +1352,8 @@ func (h *Handler) logUsage(input *database.UsageLogInput) {
input.Channel = database.UpstreamChannelGrok
case acc.IsAntigravityAPI():
input.Channel = database.UpstreamChannelAntigravity
+ case acc.IsClaudeOAuth():
+ input.Channel = database.UpstreamChannelClaude
}
}
}
@@ -2395,11 +2421,11 @@ func responseFailedStatusCodeWithEvidence(payload []byte) (int, bool) {
return http.StatusTooManyRequests, true
case strings.Contains(codeOrType, "rate_limit"):
return http.StatusTooManyRequests, true
- case strings.Contains(codeOrType, "unauthorized") || strings.Contains(codeOrType, "invalid_api_key"):
+ case strings.Contains(codeOrType, "unauthorized") || strings.Contains(codeOrType, "authentication") || strings.Contains(codeOrType, "invalid_api_key") || strings.Contains(codeOrType, "invalid_token"):
return http.StatusUnauthorized, true
case strings.Contains(codeOrType, "payment"):
return http.StatusPaymentRequired, true
- case strings.Contains(codeOrType, "forbidden"):
+ case strings.Contains(codeOrType, "forbidden") || strings.Contains(codeOrType, "permission"):
return http.StatusForbidden, true
case strings.Contains(codeOrType, "previous_response_not_found"):
return http.StatusBadRequest, true
@@ -3720,6 +3746,7 @@ func (h *Handler) Responses(c *gin.Context) {
accountFilter = relayOnlyAccountFilter(accountFilter)
}
accountFilter = h.applyUpstreamChannelFilter(c, effectiveModel, accountFilter)
+ accountFilter = excludeClaudeAccountsFilter(accountFilter)
accountFilter = applyAffinityGroupRouting(c, sessionIdentity, accountFilter)
accountFilter = h.applyScopeBudgetFilter(c, accountFilter)
// resolveCompactionAffinity 只在已知来源相互冲突时报错;缓存故障按未知
@@ -5674,6 +5701,7 @@ func (h *Handler) ResponsesCompact(c *gin.Context) {
// 中转账号会命中上游自身的 /responses/compact,使仅接入中转的用户也能压缩(issue #174)。
accountFilter := accountFilterForCompactResponsesModelWithOriginal(routingModel, effectiveModel, modelIDInList(effectiveModel, SupportedModelIDs(c.Request.Context(), h.db)))
accountFilter = h.withModelCooldownFilter(effectiveModel, accountFilter)
+ accountFilter = excludeClaudeAccountsFilter(accountFilter)
if continuationUnavailable {
accountFilter = relayOnlyAccountFilter(accountFilter)
}
@@ -6478,6 +6506,7 @@ func (h *Handler) ChatCompletions(c *gin.Context) {
accountFilter = h.withModelCooldownFilter(effectiveModel, accountFilter)
accountFilter = h.applyUpstreamChannelFilter(c, effectiveModel, accountFilter)
accountFilter = excludeAntigravityAccountsFilter(accountFilter)
+ accountFilter = excludeClaudeAccountsFilter(accountFilter)
accountFilter = h.applyScopeBudgetFilter(c, accountFilter)
// scope 并发位在选中账号后才能占,请求退出时统一释放(issue #439 v2)。
defer h.ReleaseAPIKeyScopeConcurrency(c)
@@ -8404,7 +8433,7 @@ func (h *Handler) supportedModelIDs(ctx context.Context) []string {
models = append(models, model)
}
aliases := accountModelMappingAliases(account)
- if account.IsAntigravityAPI() {
+ if account.IsAntigravityAPI() || account.IsClaudeOAuth() {
aliases = nil
}
for _, alias := range aliases {
diff --git a/proxy/handler_anthropic.go b/proxy/handler_anthropic.go
index 699caaafa..793ed32d3 100644
--- a/proxy/handler_anthropic.go
+++ b/proxy/handler_anthropic.go
@@ -23,6 +23,99 @@ import (
const upstreamErrorBodyReadMaxBytes = 1 << 20
+var claudeDownstreamResponseHeaders = map[string]struct{}{
+ "anthropic-ratelimit-unified-5h-utilization": {},
+ "anthropic-ratelimit-unified-5h-reset": {},
+ "anthropic-ratelimit-unified-7d-utilization": {},
+ "anthropic-ratelimit-unified-7d-reset": {},
+ "anthropic-ratelimit-unified-reset": {},
+ "anthropic-ratelimit-unified-status": {},
+ "anthropic-ratelimit-unified-representative-claim": {},
+ "anthropic-ratelimit-unified-overage-status": {},
+ "anthropic-version": {},
+}
+
+// copyClaudeNativeResponseHeaders forwards only non-sensitive Anthropic
+// response metadata. The shared native-forwarder intentionally has a Grok
+// header allowlist, so Claude's unified quota headers need a provider-specific
+// opt-in to remain visible to an Anthropic client.
+func copyClaudeNativeResponseHeaders(c *gin.Context, header http.Header) {
+ if c == nil {
+ return
+ }
+ for name, values := range header {
+ if _, ok := claudeDownstreamResponseHeaders[strings.ToLower(strings.TrimSpace(name))]; !ok {
+ continue
+ }
+ for _, value := range values {
+ if !strings.ContainsAny(value, "\r\n") {
+ c.Writer.Header().Add(name, value)
+ }
+ }
+ }
+}
+
+// syncAnthropicUsageStateForAccount keeps the Anthropic Messages execution
+// path provider-aware. Claude OAuth responses expose Anthropic's unified
+// rate-limit headers; all other accounts use the existing Codex header
+// semantics. This helper is used on success, failure, and retry paths so a
+// Claude response can never be parsed as a Codex snapshot.
+func syncAnthropicUsageStateForAccount(store *auth.Store, account *auth.Account, resp *http.Response) {
+ if account != nil && account.IsClaudeOAuth() {
+ SyncClaudeUsageState(store, account, resp)
+ return
+ }
+ SyncCodexUsageState(store, account, resp)
+}
+
+// normalizeNativeFailureMessageForAccount keeps the shared native forwarder
+// compatible with Claude without leaking its historical Grok fallback text to
+// Anthropic clients. Structured upstream messages remain untouched.
+func normalizeNativeFailureMessageForAccount(account *auth.Account, outcome streamOutcome) streamOutcome {
+ if account != nil && account.IsClaudeOAuth() && strings.EqualFold(strings.TrimSpace(outcome.failureMessage), "Grok upstream stream failed") {
+ outcome.failureMessage = "Claude upstream stream failed"
+ }
+ return outcome
+}
+
+// applyClaudeNativeFailureCooldown handles provider errors embedded in an
+// otherwise-200 native SSE stream. Claude's relay-style model policy may be
+// configured off, but a body-only rate-limit signal still needs a short account
+// backoff so the scheduler does not immediately hammer the same token again.
+func (h *Handler) applyClaudeNativeFailureCooldown(account *auth.Account, outcome streamOutcome, resp *http.Response, model string) streamOutcome {
+ if h == nil || h.store == nil || account == nil || !account.IsClaudeOAuth() || len(outcome.failurePayload) == 0 || outcome.logStatusCode == http.StatusOK {
+ return outcome
+ }
+ decision := h.applyResponseFailedCooldown(account, outcome.failurePayload, resp, model)
+ lowerPayload := strings.ToLower(string(outcome.failurePayload))
+ if decision.ResetAt.IsZero() && !claudeHasAuthoritativeQuotaCooldown(account) && (outcome.logStatusCode == http.StatusTooManyRequests || strings.Contains(lowerPayload, "rate_limit") || strings.Contains(lowerPayload, "overloaded")) {
+ // Relay model cooldown is intentionally optional. Keep a bounded account
+ // backoff for native Anthropic rate_limit/overloaded frames even in that mode.
+ var headers http.Header
+ if resp != nil {
+ headers = resp.Header
+ }
+ backoff := claudeGenericRateLimitBackoff(headers)
+ h.store.MarkCooldown(account, backoff, "rate_limited")
+ }
+ return applyResponseFailedDecisionKind(outcome, outcome.failurePayload, decision)
+}
+
+func claudeHasAuthoritativeQuotaCooldown(account *auth.Account) bool {
+ if account == nil || !account.HasActiveCooldown() {
+ return false
+ }
+ reason, _ := account.GetCooldownSnapshot()
+ switch strings.ToLower(strings.TrimSpace(reason)) {
+ case auth.ResponsesRateLimitedCooldownReason, "rate_limited_5h", "rate_limited_7d", "usage_limited", "usage_limit":
+ return true
+ }
+ // A generic rate-limited cooldown may still carry a provider Retry-After
+ // value. It is safer to preserve any active cooldown than to replace it with
+ // the fallback one-minute delay while handling a second body-only frame.
+ return true
+}
+
// sendAnthropicError 发送 Anthropic 格式的错误响应
func sendAnthropicError(c *gin.Context, statusCode int, errType, message string) {
if !claimContinuousRetryTerminal(c, continuousRetryProtocolAnthropic) {
@@ -105,35 +198,99 @@ func (h *Handler) applyMessagesModelMapping(codexBody []byte, supportedModels []
// hasNativeClaudeAccountForModel 判断池中是否有能服务该模型的 Claude Code OAuth
// 账号(据此决定 /v1/messages 是走原生 claude 透传还是 Codex 翻译兜底)。
+//
+// 保留这个无请求上下文的版本供内部/旧测试调用;真实 HTTP 请求使用下面的
+// hasNativeClaudeAccountForRequest,它会额外应用 API Key 的渠道、分组、套餐和
+// 账号可用性边界,避免一个全局存在但当前 Key 不可用的 Claude 账号把请求锁死
+// 在原生路径上。
func (h *Handler) hasNativeClaudeAccountForModel(model string) bool {
+ return h.hasNativeClaudeAccountForRequest(nil, model)
+}
+
+// hasNativeClaudeAccountForRequest 判断当前请求是否真的有可调度的 Claude
+// 原生账号。Claude 模型优先原生,但只有在当前 API Key 能看到至少一个健康
+// 账号时才锁定原生路由;否则保留既有 Codex 翻译兜底。
+func (h *Handler) hasNativeClaudeAccountForRequest(c *gin.Context, model string) bool {
if h == nil || h.store == nil {
return false
}
- model = strings.TrimSpace(model)
+ model = h.resolveNativeClaudeRequestModel(c, model)
if model == "" {
return false
}
+ requestedChannel := requestUpstreamChannel(c)
+ if requestedChannel != "" && requestedChannel != database.UpstreamChannelClaude {
+ return false
+ }
+ apiKeyID := requestAPIKeyID(c)
+ accountFilter := claudeChannelAccountFilter(model)
+ accountFilter = h.withModelCooldownFilter(model, accountFilter)
+ if c != nil && c.Request != nil {
+ // The full Messages filter is assembled immediately after this routing
+ // stub. Apply the request's session affinity here as well, so a native
+ // Claude account hidden from this session does not force an unusable
+ // native route before the final selector runs.
+ rawBody, _ := rawRequestBodyFromContext(c)
+ rawBody = ingressRequestBody(c, rawBody)
+ identity := resolveRequestSessionIdentity(c.Request.Header, rawBody)
+ accountFilter = applyAffinityGroupRouting(c, identity, accountFilter)
+ }
for _, account := range h.store.Accounts() {
- if account != nil && account.IsClaudeOAuth() && claudeAccountSupportsModel(account, model) {
- return true
+ if account == nil || !account.IsClaudeOAuth() || !claudeAccountSupportsModel(account, model) {
+ continue
+ }
+ if accountFilter != nil && !accountFilter(account) {
+ continue
}
+ if !account.IsAvailable() {
+ continue
+ }
+ if c != nil && (!account.AllowsAPIKey(apiKeyID) || !h.store.APIKeyAllowsAccount(apiKeyID, account)) {
+ continue
+ }
+ return true
}
return false
}
+// resolveNativeClaudeRequestModel resolves an optional client alias to a
+// Claude-native target for the native Messages path. OpenAI/Codex mappings are
+// intentionally ignored when the requested ID is already claude-*.
+func (h *Handler) resolveNativeClaudeRequestModel(c *gin.Context, requested string) string {
+ requested = strings.TrimSpace(requested)
+ if strings.HasPrefix(strings.ToLower(requested), "claude-") || h == nil || h.store == nil {
+ return requested
+ }
+ ctx := context.Background()
+ if c != nil && c.Request != nil {
+ ctx = c.Request.Context()
+ }
+ mapped, ok := resolveConfiguredModelMapping(requested, h.store.GetModelMapping(), h.supportedModelIDs(ctx))
+ if ok && strings.HasPrefix(strings.ToLower(strings.TrimSpace(mapped)), "claude-") {
+ return strings.TrimSpace(mapped)
+ }
+ return requested
+}
+
// resolveMessagesRoutingBody 用廉价 stub 完成模型映射与 effort/tier 提取,
// 避免在选号前把整段 Anthropic messages 转成有损 Codex Responses。
func (h *Handler) resolveMessagesRoutingBody(rawBody []byte, requestedModel string, supportedModels []string) []byte {
+ return h.resolveMessagesRoutingBodyForRequest(nil, rawBody, requestedModel, supportedModels)
+}
+
+func (h *Handler) resolveMessagesRoutingBodyForRequest(c *gin.Context, rawBody []byte, requestedModel string, supportedModels []string) []byte {
mappingJSON := ""
if h != nil && h.store != nil {
mappingJSON = h.store.GetModelMapping()
}
+ nativeClaudeModel := h.resolveNativeClaudeRequestModel(c, requestedModel)
+ nativeClaudeRoute := h.hasNativeClaudeAccountForRequest(c, requestedModel)
mapped := resolveAnthropicModel(requestedModel, mappingJSON, supportedModels)
// 原生 Claude 路由:若存在能服务该模型的 Claude Code OAuth 账号,则保持原生
// 模型 ID,交由 claude 账号原生透传;否则维持既有 Codex 翻译兜底(claude-* →
// gpt-5.4),不影响没有 claude 账号、靠 Codex 服务 /v1/messages 的用户。
- if h.hasNativeClaudeAccountForModel(requestedModel) {
- mapped = strings.TrimSpace(requestedModel)
+ if nativeClaudeRoute {
+ mapped = nativeClaudeModel
}
stub, err := sjson.SetBytes([]byte(`{}`), "model", mapped)
if err != nil {
@@ -149,6 +306,13 @@ func (h *Handler) resolveMessagesRoutingBody(rawBody []byte, requestedModel stri
stub, _ = sjson.SetBytes(stub, "service_tier", upstreamTier)
}
}
+ if nativeClaudeRoute {
+ // A Claude-native attempt must not be remapped again through the global
+ // Codex table (for example claude-sonnet-* -> gpt-*). Keep only the
+ // normalized effort field in the routing stub.
+ stub, _ = sjson.DeleteBytes(stub, "reasoning_effort")
+ return stub
+ }
return h.applyMessagesModelMapping(stub, supportedModels)
}
@@ -238,7 +402,7 @@ func (h *Handler) Messages(c *gin.Context) {
// Grok 账号选中后再走一次 TranslateAnthropicToResponsesForGrok;
// Codex / OpenAI 中转仍按需翻译成 Codex-safe Responses。
supportedModels := h.supportedModelIDs(c.Request.Context())
- routingBody := h.resolveMessagesRoutingBody(rawBody, model, supportedModels)
+ routingBody := h.resolveMessagesRoutingBodyForRequest(c, rawBody, model, supportedModels)
originalModel := model
effectiveModel := effectiveRequestModel(routingBody, model)
if isMediaOnlyModel(effectiveModel) {
@@ -354,7 +518,11 @@ func (h *Handler) Messages(c *gin.Context) {
attemptEffectiveModel := effectiveModel
useWebsocket := h.shouldUseWebsocketForHTTP() && !wsHTTPFallback.ForceHTTP() && !isRelayAccount
upstreamEndpoint := "/v1/responses"
- if isRelayAccount {
+ if account.IsClaudeOAuth() {
+ // Native Claude accounts do not use the relay/Codex endpoint even
+ // though IsRelayStyle is true for scheduler isolation.
+ upstreamEndpoint = "/v1/messages"
+ } else if isRelayAccount {
upstreamEndpoint = relayUpstreamEndpointForProtocol(account, GrokProtocolMessages, attemptEffectiveModel)
}
@@ -392,8 +560,15 @@ func (h *Handler) Messages(c *gin.Context) {
// Claude Code OAuth 账号本身说 Anthropic Messages API:不翻译成 Codex,
// 直接把原始入站 body 透传到 api.anthropic.com/v1/messages;返回的响应
// 已是原生 Anthropic SSE,打上原生路由标记复用既有透传链路。
+ claudeRequestBody := rawBody
+ if nativeModel := h.resolveNativeClaudeRequestModel(c, model); nativeModel != "" && !strings.EqualFold(nativeModel, model) {
+ if rewritten, rewriteErr := sjson.SetBytes(rawBody, "model", nativeModel); rewriteErr == nil {
+ claudeRequestBody = rewritten
+ }
+ }
resp, reqErr = executeHTTPWithContinuousRetryKeepalive(upstreamCtx, func() (*http.Response, error) {
- r, e := ExecuteClaudeMessagesRequest(upstreamCtx, account, rawBody, proxyURL, downstreamHeaders)
+ claudeFpMode := account.EffectiveClaudeFingerprintMode(h.store.ClaudeFingerprintModeDefault())
+ r, e := ExecuteClaudeMessagesRequest(upstreamCtx, account, claudeRequestBody, proxyURL, downstreamHeaders, claudeFpMode)
if e == nil {
markClaudeNativeRoute(r)
}
@@ -544,7 +719,7 @@ func (h *Handler) Messages(c *gin.Context) {
if kind := classifyHTTPFailure(resp.StatusCode); kind != "" {
h.store.ReportRequestFailure(account, kind, time.Duration(durationMs)*time.Millisecond)
}
- SyncCodexUsageState(h.store, account, resp)
+ syncAnthropicUsageStateForAccount(h.store, account, resp)
h.store.Release(account)
h.store.UnbindSessionAffinity(affinityKey, account.ID())
retryExclusions.MarkHTTPFailure(account.ID(), resp.StatusCode, errBody, maxRetries, attemptMaxRateLimitRetries, continuousRetryPolicy)
@@ -654,7 +829,32 @@ func (h *Handler) Messages(c *gin.Context) {
if isGrokNativeRouteResponse(resp) {
downstreamFlusher, _ := c.Writer.(http.Flusher)
streamAttempt := h.newContinuousRetryStreamAttempt(isStream && continuousRetryBuffersAttempts(continuousRetryPolicy), c.Writer, downstreamFlusher)
+ // Non-stream responses are committed by forwardGrokNativeResponseTo;
+ // copy Claude's safe headers before that commit so net/http can send
+ // them. Stream headers are copied after the successful attempt below
+ // to avoid exposing a buffered/retried attempt.
+ if account.IsClaudeOAuth() && (!isStream || !continuousRetryBuffersAttempts(continuousRetryPolicy)) {
+ copyClaudeNativeResponseHeaders(c, resp.Header)
+ }
usage, outcome, wroteAnyBody, firstTokenMs := forwardGrokNativeResponseTo(c, resp, GrokProtocolMessages, isStream, start, ttftGuard.Stop, streamAttempt.writerOr(c.Writer), streamAttempt.flusherOr(downstreamFlusher))
+ outcome = normalizeNativeFailureMessageForAccount(account, outcome)
+ // The native forwarder consumes the body before returning. Synchronize
+ // Anthropic's unified quota headers now, once per attempt, so Claude
+ // usage remains fresh without adding a write before first token.
+ syncAnthropicUsageStateForAccount(h.store, account, resp)
+ promptPolicyIncidentID := ""
+ if account.IsClaudeOAuth() && outcome.logStatusCode != http.StatusOK && len(outcome.failurePayload) > 0 {
+ // Native Claude error frames can be HTTP 200, so the normal HTTP
+ // error branch never gets a chance to apply model cooldowns or
+ // create an incident. Reuse the response.failed classifier here.
+ if isExplicitUpstreamCyberPolicy(outcome.failurePayload) {
+ promptPolicyIncidentID = acceptedPromptPolicyIncidentID(h.logUpstreamCyberPolicy(c, "/v1/messages", model, responseFailedErrorBody(outcome.failurePayload), upstreamCyberPolicyAttempt{
+ Transport: upstreamPromptPolicyTransport(isStream, useWebsocket), StatusCode: outcome.logStatusCode,
+ AccountID: account.ID(), AttemptIndex: attempt + 1,
+ }))
+ }
+ outcome = h.applyClaudeNativeFailureCooldown(account, outcome, resp, attemptEffectiveModel)
+ }
totalDuration := int(time.Since(start).Milliseconds())
ttftGuard.Stop()
resp.Body.Close()
@@ -662,6 +862,22 @@ func (h *Handler) Messages(c *gin.Context) {
if shouldTransparentRetryStreamWithBudgets(outcome, &generalRetries, &rateLimitRetries, maxRetries, attemptMaxRateLimitRetries, downstreamWrote, c.Request.Context().Err(), nil, continuousRetryPolicy) {
rememberContinuousRetryStreamFailure(c.Request.Context(), outcome, outcome.failurePayload)
_ = streamAttempt.Close()
+ retryLog := database.UsageLogInput{
+ AccountID: account.ID(), Endpoint: "/v1/messages", Model: model,
+ EffectiveModel: attemptEffectiveModel, StatusCode: outcome.logStatusCode,
+ DurationMs: totalDuration, FirstTokenMs: firstTokenMs, ReasoningEffort: reasoningEffort,
+ InboundEndpoint: "/v1/messages", UpstreamEndpoint: upstreamEndpoint,
+ Stream: isStream, ViaWebsocket: false, AttemptIndex: attempt + 1,
+ IsRetryAttempt: true, PromptPolicyIncidentID: promptPolicyIncidentID,
+ UpstreamErrorKind: outcome.failureKind,
+ ErrorMessage: usageLogFailureMessage(outcome.logStatusCode, outcome.failureMessage),
+ }
+ if usage != nil {
+ retryLog.PromptTokens, retryLog.CompletionTokens, retryLog.TotalTokens = usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens
+ retryLog.InputTokens, retryLog.OutputTokens = usage.InputTokens, usage.OutputTokens
+ retryLog.ReasoningTokens, retryLog.CachedTokens = usage.ReasoningTokens, usage.CachedTokens
+ }
+ h.logUsageForRequest(c, &retryLog)
h.reportStreamOutcomeFailure(account, outcome, time.Duration(totalDuration)*time.Millisecond)
h.store.Release(account)
h.store.UnbindSessionAffinity(affinityKey, account.ID())
@@ -679,6 +895,9 @@ func (h *Handler) Messages(c *gin.Context) {
return
}
copyGrokNativeResponseHeaders(c, resp.Header)
+ if account.IsClaudeOAuth() && isStream && continuousRetryBuffersAttempts(continuousRetryPolicy) {
+ copyClaudeNativeResponseHeaders(c, resp.Header)
+ }
if commitErr := h.commitStreamAttempt(c, streamAttempt); commitErr != nil {
if isContinuousRetryLocalFailure(commitErr) {
outcome = overlayContinuousRetryLocalFailure(outcome, commitErr)
@@ -704,6 +923,7 @@ func (h *Handler) Messages(c *gin.Context) {
DurationMs: totalDuration, FirstTokenMs: firstTokenMs, ReasoningEffort: reasoningEffort,
InboundEndpoint: "/v1/messages", UpstreamEndpoint: upstreamEndpoint,
Stream: isStream, ViaWebsocket: false, AttemptIndex: attempt + 1,
+ PromptPolicyIncidentID: promptPolicyIncidentID,
}
if usage != nil {
logInput.PromptTokens, logInput.CompletionTokens, logInput.TotalTokens = usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens
@@ -1032,7 +1252,7 @@ func (h *Handler) Messages(c *gin.Context) {
log.Printf("上游流在首包前断开,重试 (attempt %s, account %d, /v1/messages): %s",
retryAttemptProgress(attempt, maxRetries), account.ID(), outcome.failureMessage)
recyclePooledClient(account, proxyURL)
- SyncCodexUsageState(h.store, account, resp)
+ syncAnthropicUsageStateForAccount(h.store, account, resp)
if isFirstTokenTimeoutOutcome(outcome) {
retryExclusions.MarkSoftFirstTokenTimeout(account.ID())
} else {
@@ -1154,7 +1374,7 @@ func (h *Handler) Messages(c *gin.Context) {
h.logUsageForRequest(c, logInput)
resp.Body.Close()
- SyncCodexUsageState(h.store, account, resp)
+ syncAnthropicUsageStateForAccount(h.store, account, resp)
if outcome.penalize {
recyclePooledClient(account, proxyURL)
h.reportStreamOutcomeFailure(account, outcome, time.Duration(totalDuration)*time.Millisecond)
diff --git a/proxy/handler_anthropic_stream_failure_test.go b/proxy/handler_anthropic_stream_failure_test.go
index 9da1b4518..88720a8b2 100644
--- a/proxy/handler_anthropic_stream_failure_test.go
+++ b/proxy/handler_anthropic_stream_failure_test.go
@@ -66,6 +66,28 @@ func writeCodexSSE(w http.ResponseWriter, events ...string) {
}
}
+func TestSyncAnthropicUsageStateDispatchesByProvider(t *testing.T) {
+ store := auth.NewStore(nil, nil, nil)
+ claude := &auth.Account{DBID: 101, UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token"}
+ claudeResp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)}
+ claudeResp.Header.Set("anthropic-ratelimit-unified-5h-utilization", "0.42")
+ claudeResp.Header.Set("anthropic-ratelimit-unified-5h-reset", "4102444800")
+ syncAnthropicUsageStateForAccount(store, claude, claudeResp)
+ if got := claude.UsagePercent5h; got != 42 {
+ t.Fatalf("Claude usage = %v, want 42", got)
+ }
+
+ codex := &auth.Account{DBID: 102, UpstreamType: auth.UpstreamOpenAIResponses, AccessToken: "codex-token"}
+ codexResp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)}
+ codexResp.Header.Set("x-codex-primary-used-percent", "37")
+ codexResp.Header.Set("x-codex-primary-window-minutes", "300")
+ codexResp.Header.Set("x-codex-primary-reset-after-seconds", "3600")
+ syncAnthropicUsageStateForAccount(store, codex, codexResp)
+ if got := codex.UsagePercent5h; got != 37 {
+ t.Fatalf("Codex usage = %v, want 37", got)
+ }
+}
+
// TestMessagesStreamMidBreakEmitsErrorEventNotCleanStop 验证 issue #435 修复:
// 正文已开始后上游断流(未收到终止事件),下游必须收到 Anthropic 流内 error 事件,
// 而不是伪造 stop_reason=end_turn + message_stop 的"干净空收尾"(下游会把截断
@@ -97,6 +119,14 @@ func TestMessagesStreamMidBreakEmitsErrorEventNotCleanStop(t *testing.T) {
}
}
+func TestClaudeNativeFailureUsesProviderSpecificFallbackMessage(t *testing.T) {
+ account := &auth.Account{UpstreamType: auth.UpstreamClaude}
+ outcome := normalizeNativeFailureMessageForAccount(account, streamOutcome{failureMessage: "Grok upstream stream failed"})
+ if outcome.failureMessage != "Claude upstream stream failed" {
+ t.Fatalf("Claude native fallback message = %q", outcome.failureMessage)
+ }
+}
+
// TestMessagesStreamResponseFailedAfterContentEmitsErrorEvent 验证 issue #435 修复:
// 正文已下发后上游返回 response.failed,不能再走 handleFailed 翻译成 end_turn
// 干净收尾,必须发流内 error 事件让下游可感知。
diff --git a/proxy/internal_response_test.go b/proxy/internal_response_test.go
index 1f5395596..8a21434bb 100644
--- a/proxy/internal_response_test.go
+++ b/proxy/internal_response_test.go
@@ -28,6 +28,37 @@ func TestApplyUpstreamChannelFilterAntigravityFailsClosed(t *testing.T) {
}
}
+func TestApplyUpstreamChannelFilterClaudeIsolatesProvider(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Set(contextAPIKeyRow, &database.APIKeyRow{Limits: database.APIKeyLimits{UpstreamChannel: database.UpstreamChannelClaude}})
+ filter := (&Handler{}).applyUpstreamChannelFilter(c, "claude-sonnet-4-5", func(*auth.Account) bool { return true })
+ claude := &auth.Account{DBID: 1, UpstreamType: auth.UpstreamClaude, AccessToken: "claude", Models: []string{"claude-sonnet-4-5"}}
+ codex := &auth.Account{DBID: 2, AccessToken: "codex"}
+ if !filter(claude) {
+ t.Fatal("Claude channel rejected Claude account")
+ }
+ if filter(codex) {
+ t.Fatal("Claude channel admitted Codex account")
+ }
+}
+
+func TestResponsesFilterRejectsClaudeProtocol(t *testing.T) {
+ claude := &auth.Account{DBID: 3, UpstreamType: auth.UpstreamClaude, AccessToken: "claude", Models: []string{"claude-sonnet-4-5"}}
+ filter := excludeClaudeAccountsFilter(accountFilterForResponsesModel("claude-sonnet-4-5", true))
+ if filter(claude) {
+ t.Fatal("Responses protocol admitted Claude account")
+ }
+}
+
+func TestResponsesCompactFilterRejectsClaudeProtocol(t *testing.T) {
+ claude := &auth.Account{DBID: 6, UpstreamType: auth.UpstreamClaude, AccessToken: "claude", Models: []string{"claude-sonnet-4-5"}}
+ filter := accountFilterForCompactResponsesModelWithOriginal("claude-sonnet-4-5", "claude-sonnet-4-5", true)
+ if filter(claude) {
+ t.Fatal("Responses Compact admitted Claude native Messages account")
+ }
+}
+
func TestResponsesFilterAdmitsAntigravityInLazyMode(t *testing.T) {
account := &auth.Account{
DBID: 4, UpstreamType: auth.UpstreamAntigravity, AccessToken: "google-token",
diff --git a/proxy/model_registry.go b/proxy/model_registry.go
index 4679f571a..0e8b8f836 100644
--- a/proxy/model_registry.go
+++ b/proxy/model_registry.go
@@ -49,6 +49,7 @@ type ModelCatalog struct {
// 供前端在渠道选 grok 时切换模型下拉选项;注册表本身仍只管 Codex 模型。
GrokModels []string `json:"grok_models,omitempty"`
AntigravityModels []string `json:"antigravity_models,omitempty"`
+ ClaudeModels []string `json:"claude_models,omitempty"`
LastSyncedAt *time.Time `json:"last_synced_at,omitempty"`
SourceURL string `json:"source_url"`
Warning string `json:"warning,omitempty"`
diff --git a/proxy/scoped_models.go b/proxy/scoped_models.go
index 963fc4cdd..4a7601eaa 100644
--- a/proxy/scoped_models.go
+++ b/proxy/scoped_models.go
@@ -250,7 +250,8 @@ func (h *Handler) scopedModelRecords(ctx context.Context, row *database.APIKeyRo
// Antigravity-only keys intentionally expose exactly the native logical
// surface. Global/OpenAI aliases and synthesized effort aliases belong to
// other providers and would make Cockpit's catalog diverge again.
- if row.Limits.ResolveUpstreamChannel() != database.UpstreamChannelAntigravity {
+ channel := row.Limits.ResolveUpstreamChannel()
+ if channel != database.UpstreamChannelAntigravity && channel != database.UpstreamChannelClaude {
// Global exact aliases are visible only when their concrete target is
// routeable in this key's account snapshot. Wildcards are patterns, not
// model IDs, and therefore never appear in /v1/models.
diff --git a/proxy/scoped_models_test.go b/proxy/scoped_models_test.go
index 54b3da9a5..d185cfa89 100644
--- a/proxy/scoped_models_test.go
+++ b/proxy/scoped_models_test.go
@@ -229,6 +229,24 @@ func TestScopedModelsIncludeAntigravityAccounts(t *testing.T) {
}
}
+func TestScopedModelsClaudeOnlyKeyDoesNotExposeCodexAliases(t *testing.T) {
+ store := auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 2})
+ defer store.Stop()
+ store.SetModelMapping(`{"client-alias":"claude-sonnet-4-5"}`)
+ store.AddAccount(&auth.Account{
+ DBID: 100, UpstreamType: auth.UpstreamClaude, AccessToken: "claude-token", Status: auth.StatusReady,
+ Models: []string{"claude-sonnet-4-5"},
+ })
+ handler := NewHandler(store, nil, nil, nil)
+ models := listScopedModelsForTest(t, handler, &database.APIKeyRow{ID: 8, Limits: database.APIKeyLimits{UpstreamChannel: database.UpstreamChannelClaude}})
+ if _, _, ok := scopedModelByID(models, "claude-sonnet-4-5"); !ok {
+ t.Fatalf("Claude native model missing from Claude-only catalog: %+v", models)
+ }
+ if _, _, ok := scopedModelByID(models, "client-alias"); ok {
+ t.Fatalf("Codex/global alias leaked into Claude-only catalog: %+v", models)
+ }
+}
+
func TestScopedModelsDeclaredListCannotOverrideCatalogVisibility(t *testing.T) {
store := auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 1})
account := &auth.Account{DBID: 1, UpstreamType: auth.UpstreamGrok, APIKey: "xai", Models: []string{"declared-only", "hidden", "visible"}}
From 71ad2722580e8c7f85daa2a94fc3487e9bc64d81 Mon Sep 17 00:00:00 2001
From: hu <187184415@qq.com>
Date: Mon, 31 Aug 2026 20:37:22 +0800
Subject: [PATCH 5/9] feat(claude): add frontend account parity
---
frontend/src/api.ts | 14 +-
.../src/components/AccountDetailSheet.tsx | 26 +-
.../components/AccountGroupManagerModal.tsx | 270 ++
.../AccountQuotaDistributionChart.tsx | 40 +-
frontend/src/components/AccountUsageModal.tsx | 10 +-
frontend/src/components/ChannelFilter.tsx | 17 +-
frontend/src/components/ChannelLogo.tsx | 3 +-
frontend/src/components/ProxyField.tsx | 92 +
frontend/src/components/ProxyPoolSelect.tsx | 158 +-
frontend/src/lib/claudeParity.test.mjs | 122 +
.../src/lib/claudeProviderBoundary.test.mjs | 52 +
frontend/src/lib/poolRunway.test.mjs | 23 +
frontend/src/lib/poolRunway.ts | 13 +-
frontend/src/lib/usageFormat.test.mjs | 27 +
frontend/src/lib/usageFormat.ts | 23 +-
frontend/src/locales/en.json | 205 +-
frontend/src/locales/zh-TW.json | 210 +-
frontend/src/locales/zh.json | 205 +-
frontend/src/pages/APIKeys.tsx | 64 +-
frontend/src/pages/Accounts.tsx | 61 +-
frontend/src/pages/ApiReference.tsx | 525 ++-
frontend/src/pages/ClaudeAccounts.tsx | 3108 +++++++++++++++--
frontend/src/pages/Dashboard.tsx | 7 +-
frontend/src/pages/Docs.tsx | 44 +-
frontend/src/pages/Guide.tsx | 30 +-
frontend/src/pages/Proxies.tsx | 9 +-
frontend/src/pages/SchedulerBoard.tsx | 35 +-
frontend/src/pages/Settings.tsx | 94 +
frontend/src/pages/Usage.tsx | 21 +-
frontend/src/pages/docs/docsContent.ts | 19 +-
frontend/src/pages/docs/quickStartTools.ts | 2 +-
frontend/src/types.ts | 17 +
32 files changed, 4989 insertions(+), 557 deletions(-)
create mode 100644 frontend/src/components/AccountGroupManagerModal.tsx
create mode 100644 frontend/src/components/ProxyField.tsx
create mode 100644 frontend/src/lib/claudeParity.test.mjs
create mode 100644 frontend/src/lib/claudeProviderBoundary.test.mjs
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 44a9319dd..7d00c8031 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -131,6 +131,7 @@ import type {
CreateAccountGroupRequest,
UpdateAccountGroupRequest,
UpstreamChannel,
+ ClaudeGlobalConfig,
} from './types'
const BASE = '/api/admin'
@@ -598,7 +599,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(',') })
@@ -807,6 +808,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) }),
@@ -1184,6 +1187,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) =>
@@ -1463,7 +1473,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..7c1adedf3 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,12 @@ export default function AccountDetailSheet({
account.openai_responses_api ||
(isGrok && account.grok_auth_kind !== "oauth")),
);
- // auth.json / 额度券是 Codex 订阅路径专属,Grok 不展示。
- const showAuthJson = Boolean(account && !isGrok);
- const showResetCredits = Boolean(account && !isGrok);
+ // auth.json / 额度券是 Codex 订阅路径专属,Grok/Claude 不展示。
+ const showAuthJson = Boolean(account && !isGrok && !isClaude);
+ const showResetCredits = Boolean(account && !isGrok && !isClaude);
const authJsonDisabled = Boolean(
account &&
- (authJsonExporting || account.at_only || account.openai_responses_api),
+ (authJsonExporting || account.at_only || account.openai_responses_api || isClaude),
);
const resetCredits = account?.rate_limit_reset_credits ?? 0;
const healthLabel = (() => {
@@ -348,7 +349,9 @@ export default function AccountDetailSheet({
- {account.grok_api ? (
+ {account.claude_api ? (
+
+ ) : account.grok_api ? (
) : account.openai_responses_api ? (
@@ -757,6 +760,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 +786,14 @@ export default function AccountDetailSheet({
)}
+ {isClaude && (
+
+
+ {t("accounts.detailAuthType")}
+
+ {t("claude.authOAuth")}
+
+ )}
{isGrok && (
@@ -877,7 +889,9 @@ export default function AccountDetailSheet({
- {isGrok
+ {isClaude
+ ? t("claude.actionRefresh")
+ : isGrok
? t("grok.actionRefresh")
: t("accounts.actionRefreshAT")}
diff --git a/frontend/src/components/AccountGroupManagerModal.tsx b/frontend/src/components/AccountGroupManagerModal.tsx
new file mode 100644
index 000000000..7ec1e00d1
--- /dev/null
+++ b/frontend/src/components/AccountGroupManagerModal.tsx
@@ -0,0 +1,270 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Pencil, Trash2 } from "lucide-react";
+
+import { api } from "../api";
+import type { AccountGroup, UpstreamChannel } from "../types";
+import Modal from "./Modal";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { cn } from "@/lib/utils";
+import { useToast } from "../hooks/useToast";
+import { useConfirmDialog } from "../hooks/useConfirmDialog";
+import { getErrorMessage } from "../utils/error";
+
+// 分组管理器的调色板(与账号页一致,避免各页各造一套)。
+export const ACCOUNT_GROUP_COLORS = [
+ "#2563eb",
+ "#16a34a",
+ "#d97706",
+ "#dc2626",
+ "#7c3aed",
+ "#0891b2",
+ "#64748b",
+] as const;
+
+function normalizeGroupColor(color?: string): string {
+ const v = (color || "").trim();
+ return /^#[0-9a-fA-F]{6}$/.test(v) ? v : ACCOUNT_GROUP_COLORS[0];
+}
+
+type GroupDraft = {
+ id: number | null;
+ name: string;
+ description: string;
+ color: string;
+ baseConcurrency: string;
+ autoPause5h: string;
+ autoPause7d: string;
+ proxyUrls: string;
+};
+
+function emptyDraft(color: string): GroupDraft {
+ return { id: null, name: "", description: "", color, baseConcurrency: "", autoPause5h: "", autoPause7d: "", proxyUrls: "" };
+}
+
+// AccountGroupManagerModal 是各渠道通用的「管理分组」弹窗:创建/编辑/删除分组,
+// 字段与账号页的分组管理器一致(名称/描述/颜色/基础并发/自动暂停阈值/分组代理)。
+// channel 决定新建分组归属的渠道;groups 为该渠道已有分组。
+export function AccountGroupManagerModal({
+ channel,
+ groups,
+ title,
+ onClose,
+ onChanged,
+}: {
+ channel: UpstreamChannel;
+ groups: AccountGroup[];
+ title?: string;
+ onClose: () => void;
+ onChanged: () => void;
+}) {
+ const { t } = useTranslation();
+ const { showToast } = useToast();
+ const { confirm, confirmDialog } = useConfirmDialog();
+ const defaultColor = useMemo(
+ () => ACCOUNT_GROUP_COLORS[groups.length % ACCOUNT_GROUP_COLORS.length],
+ [groups.length],
+ );
+ const [draft, setDraft] = useState(() => emptyDraft(defaultColor));
+ const [busy, setBusy] = useState(false);
+
+ useEffect(() => {
+ if (draft.id === null) setDraft((d) => ({ ...d, color: d.color || defaultColor }));
+ }, [defaultColor, draft.id]);
+
+ const reset = useCallback(() => setDraft(emptyDraft(defaultColor)), [defaultColor]);
+
+ const parseNum = (v: string): number | null => {
+ const s = v.trim();
+ if (!s) return null;
+ const n = Number(s);
+ return Number.isFinite(n) ? n : null;
+ };
+
+ const startEdit = (g: AccountGroup) => {
+ setDraft({
+ id: g.id,
+ name: g.name,
+ description: g.description ?? "",
+ color: normalizeGroupColor(g.color),
+ baseConcurrency: g.base_concurrency_override != null ? String(g.base_concurrency_override) : "",
+ autoPause5h: g.auto_pause_5h_threshold ? String(g.auto_pause_5h_threshold) : "",
+ autoPause7d: g.auto_pause_7d_threshold ? String(g.auto_pause_7d_threshold) : "",
+ proxyUrls: (g.proxy_urls ?? []).join("\n"),
+ });
+ };
+
+ const save = useCallback(async () => {
+ const name = draft.name.trim();
+ if (!name) {
+ showToast(t("accountGroups.nameRequired"), "error");
+ return;
+ }
+ setBusy(true);
+ const payload = {
+ name,
+ description: draft.description.trim(),
+ color: normalizeGroupColor(draft.color),
+ base_concurrency_override: parseNum(draft.baseConcurrency),
+ auto_pause_5h_threshold: parseNum(draft.autoPause5h) ?? 0,
+ auto_pause_7d_threshold: parseNum(draft.autoPause7d) ?? 0,
+ proxy_urls: draft.proxyUrls
+ .split(/[\n,]/)
+ .map((s) => s.trim())
+ .filter(Boolean),
+ };
+ try {
+ if (draft.id === null) {
+ await api.createAccountGroup({ ...payload, channel });
+ } else {
+ await api.updateAccountGroup(draft.id, { ...payload, channel });
+ }
+ showToast(t("accountGroups.saved"), "success");
+ reset();
+ onChanged();
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ } finally {
+ setBusy(false);
+ }
+ }, [draft, channel, onChanged, reset, showToast, t]);
+
+ const remove = useCallback(
+ async (g: AccountGroup) => {
+ const ok = await confirm({ title: t("accountGroups.deleteConfirm"), description: g.name });
+ if (!ok) return;
+ try {
+ await api.deleteAccountGroup(g.id, true);
+ showToast(t("accountGroups.deleted"), "success");
+ if (draft.id === g.id) reset();
+ onChanged();
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ }
+ },
+ [confirm, draft.id, onChanged, reset, showToast, t],
+ );
+
+ const fieldLabel = "text-xs font-semibold text-muted-foreground";
+
+ return (
+
+
+ {t("common.close")}
+
+ void save()} disabled={busy || !draft.name.trim()}>
+ {draft.id === null ? t("accountGroups.create") : t("common.save")}
+
+
+ }
+ >
+
+ {/* 左:创建/编辑表单 */}
+
+
+ {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) => (
+ setDraft({ ...draft, color: c })}
+ className={cn(
+ "size-6 rounded-full ring-2 ring-offset-2 ring-offset-background transition-transform hover:scale-110",
+ normalizeGroupColor(draft.color) === c ? "ring-foreground" : "ring-transparent",
+ )}
+ style={{ backgroundColor: c }}
+ aria-label={c}
+ />
+ ))}
+
+
+
+ {t("accountGroups.description")}
+ setDraft({ ...draft, description: e.target.value })} placeholder={t("accountGroups.descriptionPlaceholder")} />
+
+
+
+ {t("accountGroups.proxyUrls")}
+
+ {draft.id !== null ? (
+
+ {t("accountGroups.cancelEdit")}
+
+ ) : null}
+
+
+ {/* 右:已有分组列表 */}
+
+
+ {t("accountGroups.existing", { count: groups.length })}
+
+ {groups.length === 0 ? (
+
{t("accountGroups.empty")}
+ ) : (
+
+ {groups.map((g) => (
+
+
+
+ {g.name}
+ ({g.member_count})
+
+
+ startEdit(g)} className="rounded p-1 text-muted-foreground hover:text-foreground" title={t("common.edit")}>
+
+
+ void remove(g)} className="rounded p-1 text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400" title={t("common.delete")}>
+
+
+
+
+ ))}
+
+ )}
+
+
+ {confirmDialog}
+
+ );
+}
diff --git a/frontend/src/components/AccountQuotaDistributionChart.tsx b/frontend/src/components/AccountQuotaDistributionChart.tsx
index 2cc44a880..43c491256 100644
--- a/frontend/src/components/AccountQuotaDistributionChart.tsx
+++ b/frontend/src/components/AccountQuotaDistributionChart.tsx
@@ -25,6 +25,11 @@ interface AccountQuotaDistributionChartProps {
onRefreshAnalysis?: () => Promise
| void
onProbeStarted?: () => void
onProbeError?: (message: string) => void
+ /** 描述/空态文案的 i18n key 覆写(带 {{sampled}}/{{total}} 插值);默认 Codex 文案。 */
+ descKey?: string
+ emptyKey?: string
+ /** 是否显示「立即采样」探针按钮(探针是 Codex 用量链路,其他渠道应隐藏)。 */
+ showProbe?: boolean
}
interface DistributionBucket {
@@ -71,6 +76,9 @@ export default function AccountQuotaDistributionChart({
onRefreshAnalysis,
onProbeStarted,
onProbeError,
+ descKey = 'accounts.quotaDistributionDesc',
+ emptyKey = 'accounts.quotaDistributionEmpty',
+ showProbe = true,
}: AccountQuotaDistributionChartProps) {
const { t } = useTranslation()
const [probing, setProbing] = useState(false)
@@ -182,24 +190,26 @@ export default function AccountQuotaDistributionChart({
{t('accounts.quotaDistributionTitle')}
- {t('accounts.quotaDistributionDesc', {
+ {t(descKey, {
sampled: distribution.sampled,
total: distribution.total,
})}
-