feat(anthropic): treat 7d_oi (Fable) window 429 as model-level rate limit

Anthropic OAuth/setup-token accounts now expose a Fable-only 7d window in
429 responses (anthropic-ratelimit-unified-7d_oi-*). Previously, a 429
triggered solely by this window fell through to the legacy path and marked
the whole account rate limited (picking the sooner 5h/7d reset) even though
5h/7d were still "allowed".

Backend:
- HandleUpstreamError: when only 7d_oi is rejected/exceeded, set a
  model-level rate limit (scope "claude-fable-5") and skip account-level
  SetRateLimited / session-window rewrite / temp-unsched entirely.
  Real 5h/7d exhaustion keeps the exact legacy account-level behavior.
- modelRateLimitKeysForRequest: append the Fable family scope for any
  mapped Anthropic model containing "fable", so claude-fable-5[1m] and
  other variants are excluded from scheduling together.
- Passive sampling: collect 7d_oi utilization/reset into Extra
  (passive_usage_7d_oi_*), including from the 429 response itself so the
  usage bar doesn't freeze at a stale value during the limit window;
  cleared on 5h window reset like the existing 7d keys.
- Usage API: new seven_day_fable window in UsageInfo. GetPassiveUsage
  builds it from passive samples; active queries parse the upstream
  seven_day_overage_included field (claim name from the
  representative-claim header) and fall back to passive data when the
  upstream usage API doesn't return it, so the active-query button keeps
  the bar populated. seven_day_sonnet handling is preserved unchanged.

Frontend:
- /admin/accounts usage cell renders a "7d F" (amber) progress bar next to
  the existing 5h / 7d / 7d S bars for Anthropic OAuth/setup-token accounts.
This commit is contained in:
shaw
2026-07-04 08:28:30 +08:00
parent 2649573b99
commit b3f796972e
11 changed files with 835 additions and 48 deletions
@@ -68,6 +68,15 @@
color="purple"
/>
<!-- 7d Fable Window (7d_oi) -->
<UsageProgressBar
v-if="usageInfo.seven_day_fable"
label="7d F"
:utilization="usageInfo.seven_day_fable.utilization"
:resets-at="usageInfo.seven_day_fable.resets_at"
color="amber"
/>
<!-- Passive sampling label + active query button -->
<div class="flex items-center gap-1.5 mt-0.5">
<span
@@ -655,4 +655,102 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).toContain('A $0.00')
expect(wrapper.text()).toContain('U $0.00')
})
it('Anthropic OAuth 会渲染 7d F (Fable) 进度条,且 7d S 逻辑保留', async () => {
getUsage.mockResolvedValue({
source: 'passive',
five_hour: {
utilization: 41,
resets_at: '2026-07-03T10:00:00Z',
remaining_seconds: 3600
},
seven_day: {
utilization: 56,
resets_at: '2026-07-06T22:00:00Z',
remaining_seconds: 300000
},
seven_day_sonnet: {
utilization: 30,
resets_at: '2026-07-06T22:00:00Z',
remaining_seconds: 300000
},
seven_day_fable: {
utilization: 100,
resets_at: '2026-07-06T22:00:00Z',
remaining_seconds: 300000
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 3001,
platform: 'anthropic',
type: 'oauth',
extra: {}
})
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('5h|41')
expect(wrapper.text()).toContain('7d|56')
expect(wrapper.text()).toContain('7d S|30')
expect(wrapper.text()).toContain('7d F|100')
})
it('Anthropic OAuth 无 Fable 数据时不渲染 7d F 进度条', async () => {
getUsage.mockResolvedValue({
source: 'passive',
five_hour: {
utilization: 41,
resets_at: '2026-07-03T10:00:00Z',
remaining_seconds: 3600
},
seven_day: {
utilization: 56,
resets_at: '2026-07-06T22:00:00Z',
remaining_seconds: 300000
}
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 3002,
platform: 'anthropic',
type: 'oauth',
extra: {}
})
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('5h|41')
expect(wrapper.text()).toContain('7d|56')
expect(wrapper.text()).not.toContain('7d S')
expect(wrapper.text()).not.toContain('7d F')
})
})
+1
View File
@@ -984,6 +984,7 @@ export interface AccountUsageInfo {
five_hour: UsageProgress | null
seven_day: UsageProgress | null
seven_day_sonnet: UsageProgress | null
seven_day_fable?: UsageProgress | null
gemini_shared_daily?: UsageProgress | null
gemini_pro_daily?: UsageProgress | null
gemini_flash_daily?: UsageProgress | null