Skip to content

Commit 31b2fdd

Browse files
unraidclaude
andcommitted
feat: 添加 provider usage 统计与余额查询
- 新增 providerUsage 服务(anthropic/bedrock/openai 适配器) - 新增余额查询(deepseek/generic poller) - StatusLine 保留原有 rateLimits 接口不变 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1837df5 commit 31b2fdd

10 files changed

Lines changed: 693 additions & 0 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { describe, test, expect, beforeEach } from 'bun:test'
2+
import { anthropicAdapter } from '../adapters/anthropic.js'
3+
import { openaiAdapter } from '../adapters/openai.js'
4+
import { bedrockAdapter } from '../adapters/bedrock.js'
5+
import {
6+
getProviderUsage,
7+
resetProviderUsage,
8+
setProviderBalance,
9+
subscribeProviderUsage,
10+
updateProviderBuckets,
11+
} from '../store.js'
12+
13+
function headers(pairs: Record<string, string>): Headers {
14+
const h = new Headers()
15+
for (const [k, v] of Object.entries(pairs)) h.set(k, v)
16+
return h
17+
}
18+
19+
describe('anthropicAdapter', () => {
20+
test('parses both 5h and 7d buckets', () => {
21+
const h = headers({
22+
'anthropic-ratelimit-unified-5h-utilization': '0.42',
23+
'anthropic-ratelimit-unified-5h-reset': '1800000000',
24+
'anthropic-ratelimit-unified-7d-utilization': '0.1',
25+
'anthropic-ratelimit-unified-7d-reset': '1800100000',
26+
})
27+
const out = anthropicAdapter.parseHeaders(h)
28+
expect(out).toHaveLength(2)
29+
expect(out[0]).toMatchObject({
30+
kind: 'session',
31+
label: 'Session',
32+
utilization: 0.42,
33+
resetsAt: 1800000000,
34+
})
35+
expect(out[1]).toMatchObject({
36+
kind: 'weekly',
37+
label: 'Weekly',
38+
utilization: 0.1,
39+
resetsAt: 1800100000,
40+
})
41+
})
42+
43+
test('returns [] when headers absent (API key user)', () => {
44+
expect(anthropicAdapter.parseHeaders(new Headers())).toEqual([])
45+
})
46+
47+
test('drops bucket with non-numeric utilization', () => {
48+
const h = headers({
49+
'anthropic-ratelimit-unified-5h-utilization': 'xx',
50+
'anthropic-ratelimit-unified-5h-reset': '0',
51+
})
52+
expect(anthropicAdapter.parseHeaders(h)).toEqual([])
53+
})
54+
})
55+
56+
describe('openaiAdapter', () => {
57+
test('computes RPM and TPM utilization from limit+remaining', () => {
58+
const h = headers({
59+
'x-ratelimit-limit-requests': '1000',
60+
'x-ratelimit-remaining-requests': '250',
61+
'x-ratelimit-limit-tokens': '100000',
62+
'x-ratelimit-remaining-tokens': '25000',
63+
'x-ratelimit-reset-requests': '6m',
64+
})
65+
const out = openaiAdapter.parseHeaders(h)
66+
expect(out).toHaveLength(2)
67+
expect(out[0].kind).toBe('requests')
68+
expect(out[0].label).toBe('RPM')
69+
expect(out[0].utilization).toBeCloseTo(0.75, 5)
70+
expect(out[1].kind).toBe('tokens')
71+
expect(out[1].utilization).toBeCloseTo(0.75, 5)
72+
})
73+
74+
test('returns [] when no relevant headers', () => {
75+
expect(openaiAdapter.parseHeaders(new Headers())).toEqual([])
76+
})
77+
})
78+
79+
describe('bedrockAdapter', () => {
80+
test('inverts quota-remaining into utilization', () => {
81+
const h = headers({
82+
'x-amzn-bedrock-quota-remaining': '0.3',
83+
'x-amzn-bedrock-quota-reset': '1800000000',
84+
})
85+
const out = bedrockAdapter.parseHeaders(h)
86+
expect(out).toHaveLength(1)
87+
expect(out[0].kind).toBe('throttle')
88+
expect(out[0].utilization).toBeCloseTo(0.7, 5)
89+
expect(out[0].resetsAt).toBe(1800000000)
90+
})
91+
92+
test('returns [] without header', () => {
93+
expect(bedrockAdapter.parseHeaders(new Headers())).toEqual([])
94+
})
95+
})
96+
97+
describe('providerUsage store', () => {
98+
beforeEach(() => {
99+
resetProviderUsage()
100+
})
101+
102+
test('updateProviderBuckets replaces buckets and notifies', () => {
103+
const seen: string[] = []
104+
const unsub = subscribeProviderUsage(u => seen.push(u.providerId))
105+
updateProviderBuckets('openai', [
106+
{ kind: 'tokens', label: 'TPM', utilization: 0.5 },
107+
])
108+
expect(getProviderUsage().providerId).toBe('openai')
109+
expect(getProviderUsage().buckets).toHaveLength(1)
110+
expect(seen).toEqual(['openai'])
111+
unsub()
112+
})
113+
114+
test('setProviderBalance stores and clears', () => {
115+
setProviderBalance('deepseek', { currency: 'USD', remaining: 3.5 })
116+
expect(getProviderUsage().balance?.remaining).toBe(3.5)
117+
setProviderBalance('deepseek', null)
118+
expect(getProviderUsage().balance).toBeUndefined()
119+
})
120+
})
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { ProviderUsageAdapter, ProviderUsageBucket } from '../types.js'
2+
3+
export const anthropicAdapter: ProviderUsageAdapter = {
4+
providerId: 'anthropic',
5+
6+
/**
7+
* Parse Anthropic's unified rate-limit headers.
8+
*
9+
* anthropic-ratelimit-unified-5h-utilization (0..1)
10+
* anthropic-ratelimit-unified-5h-reset (unix seconds)
11+
* anthropic-ratelimit-unified-7d-utilization
12+
* anthropic-ratelimit-unified-7d-reset
13+
*
14+
* Only present for OAuth (Claude AI Pro/Max) subscribers. For raw API keys
15+
* these headers are absent and this adapter returns [].
16+
*/
17+
parseHeaders(headers): ProviderUsageBucket[] {
18+
const buckets: ProviderUsageBucket[] = []
19+
for (const [abbrev, kind, label] of [
20+
['5h', 'session', 'Session'],
21+
['7d', 'weekly', 'Weekly'],
22+
] as const) {
23+
const util = headers.get(
24+
`anthropic-ratelimit-unified-${abbrev}-utilization`,
25+
)
26+
const reset = headers.get(`anthropic-ratelimit-unified-${abbrev}-reset`)
27+
if (util === null || reset === null) continue
28+
const utilization = Number(util)
29+
const resetsAt = Number(reset)
30+
if (!Number.isFinite(utilization)) continue
31+
buckets.push({
32+
kind,
33+
label,
34+
utilization,
35+
...(Number.isFinite(resetsAt) && resetsAt > 0 ? { resetsAt } : {}),
36+
})
37+
}
38+
return buckets
39+
},
40+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { ProviderUsageAdapter, ProviderUsageBucket } from '../types.js'
2+
3+
/**
4+
* AWS Bedrock rate-limit / throttling headers.
5+
*
6+
* Bedrock does not expose a precise per-minute quota the way OpenAI or
7+
* Anthropic do — the only reliably-present signal is `x-amzn-bedrock-*`
8+
* metadata on the response. We surface *throttle pressure* as a bucket
9+
* only when we can derive a meaningful 0..1 signal; otherwise return [].
10+
*
11+
* x-amzn-bedrock-quota-remaining (0..1 fraction, when present on some models)
12+
* x-amzn-bedrock-quota-reset (unix seconds)
13+
* retry-after (seconds, present on 429)
14+
*/
15+
export const bedrockAdapter: ProviderUsageAdapter = {
16+
providerId: 'bedrock',
17+
parseHeaders(headers): ProviderUsageBucket[] {
18+
const buckets: ProviderUsageBucket[] = []
19+
20+
const remainingRaw = headers.get('x-amzn-bedrock-quota-remaining')
21+
const resetRaw = headers.get('x-amzn-bedrock-quota-reset')
22+
23+
if (remainingRaw !== null) {
24+
const remaining = Number(remainingRaw)
25+
if (Number.isFinite(remaining) && remaining >= 0 && remaining <= 1) {
26+
const resetsAt = resetRaw !== null ? Number(resetRaw) : 0
27+
buckets.push({
28+
kind: 'throttle',
29+
label: 'Throttle',
30+
utilization: 1 - remaining,
31+
...(Number.isFinite(resetsAt) && resetsAt > 0 ? { resetsAt } : {}),
32+
})
33+
}
34+
}
35+
36+
return buckets
37+
},
38+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { ProviderUsageAdapter, ProviderUsageBucket } from '../types.js'
2+
3+
/**
4+
* Parse a Retry-After-style duration string (e.g. "6m0s", "1h30m", "500ms")
5+
* into unix epoch seconds *from now*. Returns 0 if unparseable.
6+
*/
7+
function parseResetAt(value: string | null): number {
8+
if (!value) return 0
9+
let seconds = 0
10+
const re = /(\d+(?:\.\d+)?)(ms|s|m|h|d)/g
11+
let match: RegExpExecArray | null
12+
while ((match = re.exec(value)) !== null) {
13+
const n = Number(match[1])
14+
const unit = match[2]
15+
switch (unit) {
16+
case 'ms':
17+
seconds += n / 1000
18+
break
19+
case 's':
20+
seconds += n
21+
break
22+
case 'm':
23+
seconds += n * 60
24+
break
25+
case 'h':
26+
seconds += n * 3600
27+
break
28+
case 'd':
29+
seconds += n * 86400
30+
break
31+
}
32+
}
33+
if (seconds === 0) {
34+
const n = Number(value)
35+
if (Number.isFinite(n)) seconds = n
36+
}
37+
if (seconds <= 0) return 0
38+
return Math.floor(Date.now() / 1000) + seconds
39+
}
40+
41+
function computeUtilization(
42+
remaining: string | null,
43+
limit: string | null,
44+
): number | null {
45+
if (remaining === null || limit === null) return null
46+
const r = Number(remaining)
47+
const l = Number(limit)
48+
if (!Number.isFinite(r) || !Number.isFinite(l) || l <= 0) return null
49+
const used = Math.max(0, l - r)
50+
return Math.min(1, Math.max(0, used / l))
51+
}
52+
53+
/**
54+
* OpenAI-compatible rate-limit headers.
55+
*
56+
* x-ratelimit-limit-requests / x-ratelimit-remaining-requests / x-ratelimit-reset-requests
57+
* x-ratelimit-limit-tokens / x-ratelimit-remaining-tokens / x-ratelimit-reset-tokens
58+
*
59+
* Works for OpenAI, DeepSeek, Moonshot, Grok (xAI) and many self-hosted
60+
* OpenAI-compatible gateways.
61+
*/
62+
export const openaiAdapter: ProviderUsageAdapter = {
63+
providerId: 'openai',
64+
parseHeaders(headers): ProviderUsageBucket[] {
65+
const buckets: ProviderUsageBucket[] = []
66+
67+
const reqUtil = computeUtilization(
68+
headers.get('x-ratelimit-remaining-requests'),
69+
headers.get('x-ratelimit-limit-requests'),
70+
)
71+
if (reqUtil !== null) {
72+
buckets.push({
73+
kind: 'requests',
74+
label: 'RPM',
75+
utilization: reqUtil,
76+
resetsAt:
77+
parseResetAt(headers.get('x-ratelimit-reset-requests')) || undefined,
78+
})
79+
}
80+
81+
const tokUtil = computeUtilization(
82+
headers.get('x-ratelimit-remaining-tokens'),
83+
headers.get('x-ratelimit-limit-tokens'),
84+
)
85+
if (tokUtil !== null) {
86+
buckets.push({
87+
kind: 'tokens',
88+
label: 'TPM',
89+
utilization: tokUtil,
90+
resetsAt:
91+
parseResetAt(headers.get('x-ratelimit-reset-tokens')) || undefined,
92+
})
93+
}
94+
95+
return buckets
96+
},
97+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { ProviderBalance } from '../types.js'
2+
import type { BalanceProvider } from './types.js'
3+
4+
/**
5+
* DeepSeek exposes balance at `GET /user/balance`.
6+
*
7+
* Enabled when:
8+
* - OPENAI_BASE_URL points at api.deepseek.com, OR
9+
* - DEEPSEEK_API_KEY is set (explicit opt-in).
10+
*
11+
* Response shape:
12+
* { is_available: true, balance_infos: [{ currency:"USD", total_balance:"5.00", ... }, ...] }
13+
*/
14+
15+
function getBaseUrl(): string | null {
16+
const url = process.env.OPENAI_BASE_URL
17+
if (url && /\bapi\.deepseek\.com\b/i.test(url)) return url.replace(/\/+$/, '')
18+
if (process.env.DEEPSEEK_API_KEY) return 'https://api.deepseek.com'
19+
return null
20+
}
21+
22+
function getApiKey(): string | null {
23+
return process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY || null
24+
}
25+
26+
export const deepseekBalanceProvider: BalanceProvider = {
27+
providerId: 'deepseek',
28+
29+
isEnabled(): boolean {
30+
return getBaseUrl() !== null && getApiKey() !== null
31+
},
32+
33+
async fetchBalance(signal?: AbortSignal): Promise<ProviderBalance | null> {
34+
const base = getBaseUrl()
35+
const key = getApiKey()
36+
if (!base || !key) return null
37+
38+
let res: Response
39+
try {
40+
res = await fetch(`${base}/user/balance`, {
41+
method: 'GET',
42+
headers: {
43+
Authorization: `Bearer ${key}`,
44+
Accept: 'application/json',
45+
},
46+
signal,
47+
})
48+
} catch {
49+
return null
50+
}
51+
if (!res.ok) return null
52+
53+
let data: unknown
54+
try {
55+
data = await res.json()
56+
} catch {
57+
return null
58+
}
59+
60+
const infos = (data as { balance_infos?: unknown })?.balance_infos
61+
if (!Array.isArray(infos)) return null
62+
63+
// Prefer USD; fall back to the first entry.
64+
const usd = infos.find(
65+
(e: unknown) =>
66+
typeof e === 'object' &&
67+
e !== null &&
68+
(e as { currency?: unknown }).currency === 'USD',
69+
) as Record<string, unknown> | undefined
70+
const pick = usd ?? (infos[0] as Record<string, unknown>) ?? null
71+
if (!pick) return null
72+
73+
const currency = typeof pick.currency === 'string' ? pick.currency : 'USD'
74+
const remainingRaw = pick.total_balance
75+
const remaining =
76+
typeof remainingRaw === 'number' ? remainingRaw : Number(remainingRaw)
77+
if (!Number.isFinite(remaining)) return null
78+
79+
return {
80+
currency,
81+
remaining,
82+
updatedAt: Math.floor(Date.now() / 1000),
83+
}
84+
},
85+
}

0 commit comments

Comments
 (0)