@cortexkit/anthropic-auth-core 1.16.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accounts.d.ts +34 -1
- package/dist/accounts.js +419 -43
- package/dist/commands/account.d.ts +1 -0
- package/dist/commands/account.js +7 -1
- package/dist/constants.js +2 -1
- package/dist/fast.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/models.d.ts +14 -0
- package/dist/models.js +15 -0
- package/dist/oauth-profile.d.ts +11 -0
- package/dist/oauth-profile.js +48 -0
- package/dist/quota-headers.d.ts +4 -0
- package/dist/quota-headers.js +66 -0
- package/dist/quota-manager.d.ts +3 -6
- package/dist/quota-manager.js +57 -17
- package/dist/quotas.d.ts +5 -2
- package/dist/quotas.js +35 -5
- package/dist/relay.d.ts +7 -0
- package/dist/relay.js +37 -8
- package/dist/token-fingerprint.d.ts +5 -0
- package/dist/token-fingerprint.js +8 -0
- package/package.json +1 -1
package/dist/commands/account.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatOAuthAccountTier } from "../oauth-profile.js";
|
|
1
2
|
export const CLAUDE_ACCOUNT_COMMAND_NAME = 'claude-account';
|
|
2
3
|
export function parseAccountCommandAction(argumentsText) {
|
|
3
4
|
const parts = argumentsText.trim().split(/\s+/).filter(Boolean);
|
|
@@ -89,6 +90,7 @@ export function buildAccountList(storage) {
|
|
|
89
90
|
role: 'main',
|
|
90
91
|
enabled: true,
|
|
91
92
|
quotaPercent: fiveHour?.usedPercent ?? null,
|
|
93
|
+
tierLabel: formatOAuthAccountTier(storage.main?.profile),
|
|
92
94
|
});
|
|
93
95
|
for (const account of storage.accounts) {
|
|
94
96
|
const fiveHourQuota = account.quota?.five_hour;
|
|
@@ -98,6 +100,9 @@ export function buildAccountList(storage) {
|
|
|
98
100
|
role: 'fallback',
|
|
99
101
|
enabled: account.enabled !== false,
|
|
100
102
|
quotaPercent: fiveHourQuota?.usedPercent ?? null,
|
|
103
|
+
tierLabel: account.type === 'oauth'
|
|
104
|
+
? formatOAuthAccountTier(account.profile)
|
|
105
|
+
: undefined,
|
|
101
106
|
});
|
|
102
107
|
}
|
|
103
108
|
return list;
|
|
@@ -124,7 +129,8 @@ export function executeAccountCommand(input) {
|
|
|
124
129
|
for (const a of list) {
|
|
125
130
|
const pct = a.quotaPercent != null ? ` ${Math.round(a.quotaPercent)}%` : '';
|
|
126
131
|
const status = !a.enabled ? ' (disabled)' : '';
|
|
127
|
-
|
|
132
|
+
const tier = a.tierLabel ? ` · ${a.tierLabel}` : '';
|
|
133
|
+
lines.push(`- **${a.label}** [${a.role}]${tier}${status}${pct}`);
|
|
128
134
|
}
|
|
129
135
|
lines.push('', USAGE_TEXT);
|
|
130
136
|
return { text: lines.join('\n') };
|
package/dist/constants.js
CHANGED
|
@@ -32,7 +32,8 @@ export function isFastModeSupportedModel(model) {
|
|
|
32
32
|
return (typeof model === 'string' &&
|
|
33
33
|
(model.startsWith('claude-opus-4-6') ||
|
|
34
34
|
model.startsWith('claude-opus-4-7') ||
|
|
35
|
-
model.startsWith('claude-opus-4-8')
|
|
35
|
+
model.startsWith('claude-opus-4-8') ||
|
|
36
|
+
model.startsWith('claude-opus-5')));
|
|
36
37
|
}
|
|
37
38
|
export const OPENCODE_IDENTITY_PREFIX = 'You are OpenCode';
|
|
38
39
|
export const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
|
package/dist/fast.js
CHANGED
|
@@ -32,7 +32,7 @@ export function buildFastModeStatusSummary(input) {
|
|
|
32
32
|
`- Enabled: ${enabled ? 'enabled' : 'disabled'}`,
|
|
33
33
|
'- Persisted: ~/.config/opencode/anthropic-auth.json',
|
|
34
34
|
'- Scope: adds Anthropic fast mode to supported Opus requests',
|
|
35
|
-
'- Supported models: claude-opus-4-6, claude-opus-4-7,
|
|
35
|
+
'- Supported models: claude-opus-4-6, claude-opus-4-7, claude-opus-4-8, and claude-opus-5',
|
|
36
36
|
'- Request changes: adds `speed: "fast"` and the `fast-mode-2026-02-01` beta header',
|
|
37
37
|
'- Note: fast and standard speeds do not share prompt-cache prefixes',
|
|
38
38
|
].join('\n');
|
package/dist/index.d.ts
CHANGED
|
@@ -13,8 +13,10 @@ export * from './killswitch.ts';
|
|
|
13
13
|
export * from './logger.ts';
|
|
14
14
|
export * from './logging.ts';
|
|
15
15
|
export * from './models.ts';
|
|
16
|
+
export * from './oauth-profile.ts';
|
|
16
17
|
export * from './pkce.ts';
|
|
17
18
|
export * from './provider.ts';
|
|
19
|
+
export * from './quota-headers.ts';
|
|
18
20
|
export * from './quota-manager.ts';
|
|
19
21
|
export * from './quotas.ts';
|
|
20
22
|
export * from './relay.ts';
|
package/dist/index.js
CHANGED
|
@@ -13,8 +13,10 @@ export * from "./killswitch.js";
|
|
|
13
13
|
export * from "./logger.js";
|
|
14
14
|
export * from "./logging.js";
|
|
15
15
|
export * from "./models.js";
|
|
16
|
+
export * from "./oauth-profile.js";
|
|
16
17
|
export * from "./pkce.js";
|
|
17
18
|
export * from "./provider.js";
|
|
19
|
+
export * from "./quota-headers.js";
|
|
18
20
|
export * from "./quota-manager.js";
|
|
19
21
|
export * from "./quotas.js";
|
|
20
22
|
export * from "./relay.js";
|
package/dist/models.d.ts
CHANGED
|
@@ -34,4 +34,18 @@ export declare const CLAUDE_SONNET_5_ADAPTIVE_THINKING: {
|
|
|
34
34
|
readonly display: "summarized";
|
|
35
35
|
};
|
|
36
36
|
export declare function isClaudeSonnet5Model(model: unknown): boolean;
|
|
37
|
+
export declare const CLAUDE_OPUS_5_MODEL_ID = "claude-opus-5";
|
|
38
|
+
/**
|
|
39
|
+
* Opus 5 has the same adaptive-thinking-by-default + `display: "omitted"`
|
|
40
|
+
* shape as Sonnet 5, so the injected thinking must also be summarized to be
|
|
41
|
+
* visible. Kept as its own constant per the PR #100 lesson — hook callers
|
|
42
|
+
* should reference the per-family name (`CLAUDE_OPUS_5_ADAPTIVE_THINKING`,
|
|
43
|
+
* not the Fable/Mythos alias) so a future divergence between the families
|
|
44
|
+
* only changes this re-export, not every call site.
|
|
45
|
+
*/
|
|
46
|
+
export declare const CLAUDE_OPUS_5_ADAPTIVE_THINKING: {
|
|
47
|
+
readonly type: "adaptive";
|
|
48
|
+
readonly display: "summarized";
|
|
49
|
+
};
|
|
50
|
+
export declare function isClaudeOpus5Model(model: unknown): boolean;
|
|
37
51
|
export declare function isOpenAIReasoningSignature(value: unknown): boolean;
|
package/dist/models.js
CHANGED
|
@@ -46,6 +46,21 @@ export function isClaudeSonnet5Model(model) {
|
|
|
46
46
|
(model === CLAUDE_SONNET_5_MODEL_ID ||
|
|
47
47
|
model.startsWith(`${CLAUDE_SONNET_5_MODEL_ID}-`)));
|
|
48
48
|
}
|
|
49
|
+
export const CLAUDE_OPUS_5_MODEL_ID = 'claude-opus-5';
|
|
50
|
+
/**
|
|
51
|
+
* Opus 5 has the same adaptive-thinking-by-default + `display: "omitted"`
|
|
52
|
+
* shape as Sonnet 5, so the injected thinking must also be summarized to be
|
|
53
|
+
* visible. Kept as its own constant per the PR #100 lesson — hook callers
|
|
54
|
+
* should reference the per-family name (`CLAUDE_OPUS_5_ADAPTIVE_THINKING`,
|
|
55
|
+
* not the Fable/Mythos alias) so a future divergence between the families
|
|
56
|
+
* only changes this re-export, not every call site.
|
|
57
|
+
*/
|
|
58
|
+
export const CLAUDE_OPUS_5_ADAPTIVE_THINKING = CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING;
|
|
59
|
+
export function isClaudeOpus5Model(model) {
|
|
60
|
+
return (typeof model === 'string' &&
|
|
61
|
+
(model === CLAUDE_OPUS_5_MODEL_ID ||
|
|
62
|
+
model.startsWith(`${CLAUDE_OPUS_5_MODEL_ID}-`)));
|
|
63
|
+
}
|
|
49
64
|
export function isOpenAIReasoningSignature(value) {
|
|
50
65
|
if (typeof value !== 'string')
|
|
51
66
|
return false;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { OAuthAccountProfile } from './accounts.ts';
|
|
2
|
+
export declare const PROFILE_TTL_MS: number;
|
|
3
|
+
export declare function fetchOAuthAccountProfile(input: {
|
|
4
|
+
accessToken: string;
|
|
5
|
+
fetchImpl?: typeof fetch;
|
|
6
|
+
now?: () => number;
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
}): Promise<OAuthAccountProfile>;
|
|
9
|
+
export declare function oauthProfileMatchesToken(profile: OAuthAccountProfile | undefined, accessToken: string): boolean;
|
|
10
|
+
export declare function oauthProfileIsFresh(profile: OAuthAccountProfile | undefined, now?: number): boolean;
|
|
11
|
+
export declare function formatOAuthAccountTier(profile: OAuthAccountProfile | undefined): string | undefined;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { tokenFingerprint } from "./quota-manager.js";
|
|
2
|
+
const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
|
|
3
|
+
export const PROFILE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
4
|
+
export async function fetchOAuthAccountProfile(input) {
|
|
5
|
+
const response = await (input.fetchImpl ?? fetch)(PROFILE_URL, {
|
|
6
|
+
method: 'GET',
|
|
7
|
+
signal: input.signal,
|
|
8
|
+
headers: {
|
|
9
|
+
Authorization: `Bearer ${input.accessToken}`,
|
|
10
|
+
Accept: 'application/json',
|
|
11
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
if (!response.ok) {
|
|
15
|
+
throw new Error(`Claude profile check failed: ${response.status}`);
|
|
16
|
+
}
|
|
17
|
+
const value = (await response.json());
|
|
18
|
+
const tier = value.organization?.rate_limit_tier;
|
|
19
|
+
const orgType = value.organization?.organization_type;
|
|
20
|
+
if (typeof tier !== 'string' ||
|
|
21
|
+
tier.trim() === '' ||
|
|
22
|
+
typeof orgType !== 'string' ||
|
|
23
|
+
orgType.trim() === '') {
|
|
24
|
+
throw new Error('Claude profile response is missing account metadata');
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
tier,
|
|
28
|
+
orgType,
|
|
29
|
+
checkedAt: input.now?.() ?? Date.now(),
|
|
30
|
+
tokenFingerprint: tokenFingerprint(input.accessToken),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function oauthProfileMatchesToken(profile, accessToken) {
|
|
34
|
+
return profile?.tokenFingerprint === tokenFingerprint(accessToken);
|
|
35
|
+
}
|
|
36
|
+
export function oauthProfileIsFresh(profile, now = Date.now()) {
|
|
37
|
+
return Boolean(profile &&
|
|
38
|
+
Number.isFinite(profile.checkedAt) &&
|
|
39
|
+
now >= profile.checkedAt &&
|
|
40
|
+
now - profile.checkedAt < PROFILE_TTL_MS);
|
|
41
|
+
}
|
|
42
|
+
export function formatOAuthAccountTier(profile) {
|
|
43
|
+
const match = profile?.tier.match(/^default_claude_max_(\d+)x$/);
|
|
44
|
+
if (!match)
|
|
45
|
+
return undefined;
|
|
46
|
+
const label = `Max ${match[1]}x`;
|
|
47
|
+
return profile?.orgType === 'claude_team' ? `Team · ${label}` : label;
|
|
48
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { OAuthQuotaSnapshot } from './accounts.ts';
|
|
2
|
+
export declare function isQuotaBearingHeaderFrame(headers: Headers): boolean;
|
|
3
|
+
export declare function normalizeQuotaHeaders(headers: Headers, now?: number): OAuthQuotaSnapshot;
|
|
4
|
+
export declare function mergeHeaderQuotaSnapshot(existing: OAuthQuotaSnapshot | undefined, incoming: OAuthQuotaSnapshot): OAuthQuotaSnapshot;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const PREFIX = 'anthropic-ratelimit-unified-';
|
|
2
|
+
const WINDOW_KEYS = {
|
|
3
|
+
'5h': 'five_hour',
|
|
4
|
+
'7d': 'seven_day',
|
|
5
|
+
};
|
|
6
|
+
export function isQuotaBearingHeaderFrame(headers) {
|
|
7
|
+
return Object.keys(WINDOW_KEYS).some((suffix) => Number.isFinite(finiteHeaderNumber(headers, `${PREFIX}${suffix}-utilization`)));
|
|
8
|
+
}
|
|
9
|
+
function finiteHeaderNumber(headers, name) {
|
|
10
|
+
const value = headers.get(name);
|
|
11
|
+
if (value == null || value.trim() === '')
|
|
12
|
+
return undefined;
|
|
13
|
+
const parsed = Number(value);
|
|
14
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
15
|
+
}
|
|
16
|
+
function normalizeWindow(headers, suffix, checkedAt) {
|
|
17
|
+
const utilization = finiteHeaderNumber(headers, `${PREFIX}${suffix}-utilization`);
|
|
18
|
+
if (utilization == null)
|
|
19
|
+
return undefined;
|
|
20
|
+
const usedPercent = Math.min(100, Math.max(0, Math.round(utilization * 100)));
|
|
21
|
+
const resetSeconds = finiteHeaderNumber(headers, `${PREFIX}${suffix}-reset`);
|
|
22
|
+
const resetDate = resetSeconds == null ? undefined : new Date(resetSeconds * 1000);
|
|
23
|
+
const resetsAt = resetDate && Number.isFinite(resetDate.getTime())
|
|
24
|
+
? resetDate.toISOString()
|
|
25
|
+
: undefined;
|
|
26
|
+
return {
|
|
27
|
+
usedPercent,
|
|
28
|
+
remainingPercent: 100 - usedPercent,
|
|
29
|
+
...(resetsAt && { resetsAt }),
|
|
30
|
+
checkedAt,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function normalizeQuotaHeaders(headers, now = Date.now()) {
|
|
34
|
+
const snapshot = {
|
|
35
|
+
fallbackAdvised: headers.get(`${PREFIX}fallback`) === 'available',
|
|
36
|
+
source: 'headers',
|
|
37
|
+
checkedAt: now,
|
|
38
|
+
};
|
|
39
|
+
for (const [suffix, key] of Object.entries(WINDOW_KEYS)) {
|
|
40
|
+
const window = normalizeWindow(headers, suffix, now);
|
|
41
|
+
if (window)
|
|
42
|
+
snapshot[key] = window;
|
|
43
|
+
}
|
|
44
|
+
const representativeClaim = headers.get(`${PREFIX}representative-claim`);
|
|
45
|
+
if (representativeClaim) {
|
|
46
|
+
snapshot.bindingWindow = representativeClaim;
|
|
47
|
+
snapshot.bindingWindowSource = 'headers';
|
|
48
|
+
}
|
|
49
|
+
return snapshot;
|
|
50
|
+
}
|
|
51
|
+
export function mergeHeaderQuotaSnapshot(existing, incoming) {
|
|
52
|
+
return {
|
|
53
|
+
...existing,
|
|
54
|
+
...incoming,
|
|
55
|
+
scoped: existing && 'scoped' in existing ? existing.scoped : undefined,
|
|
56
|
+
extraUsage: existing?.extraUsage,
|
|
57
|
+
bindingWindow: existing?.bindingWindowSource === 'poll'
|
|
58
|
+
? existing.bindingWindow
|
|
59
|
+
: (incoming.bindingWindow ?? existing?.bindingWindow),
|
|
60
|
+
bindingWindowSource: existing?.bindingWindowSource === 'poll'
|
|
61
|
+
? 'poll'
|
|
62
|
+
: (incoming.bindingWindowSource ?? existing?.bindingWindowSource),
|
|
63
|
+
// Source tracks 5h/7d freshness; preserved scoped and credit fields remain poll-owned.
|
|
64
|
+
source: 'headers',
|
|
65
|
+
};
|
|
66
|
+
}
|
package/dist/quota-manager.d.ts
CHANGED
|
@@ -6,12 +6,7 @@
|
|
|
6
6
|
* Handles deduplication, rate-limiting (429 backoff), and staleness.
|
|
7
7
|
*/
|
|
8
8
|
import type { AccountOperationError, AccountStorage, OAuthAccount, OAuthQuotaSnapshot } from './accounts.ts';
|
|
9
|
-
|
|
10
|
-
* Stable, non-reversible fingerprint of an access token. Used to detect a
|
|
11
|
-
* main-account switch so a different account's persisted/cached quota is never
|
|
12
|
-
* reused. Not a secret — a truncated SHA-256, safe to persist alongside quota.
|
|
13
|
-
*/
|
|
14
|
-
export declare function tokenFingerprint(token: string): string;
|
|
9
|
+
export { tokenFingerprint } from './token-fingerprint.ts';
|
|
15
10
|
export type QuotaEntry = {
|
|
16
11
|
quota: OAuthQuotaSnapshot;
|
|
17
12
|
refreshAfter: number;
|
|
@@ -60,6 +55,8 @@ export declare class QuotaManager {
|
|
|
60
55
|
getAllFallbacks(): Map<string, QuotaEntry>;
|
|
61
56
|
setMain(accessToken: string, entry: QuotaEntry): void;
|
|
62
57
|
setFallback(accountId: string, entry: QuotaEntry, accessToken?: string): void;
|
|
58
|
+
pushMainFromHeaders(accessToken: string, incoming: OAuthQuotaSnapshot): QuotaEntry;
|
|
59
|
+
pushFallbackFromHeaders(accountId: string, accessToken: string, incoming: OAuthQuotaSnapshot): QuotaEntry;
|
|
63
60
|
refreshMain(accessToken: string): Promise<OAuthQuotaSnapshot>;
|
|
64
61
|
refreshFallback(accountId: string, accessToken: string): Promise<OAuthQuotaSnapshot>;
|
|
65
62
|
refreshAllFallbacks(accounts: OAuthAccount[]): Promise<void>;
|
package/dist/quota-manager.js
CHANGED
|
@@ -5,17 +5,32 @@
|
|
|
5
5
|
* share one QuotaManager instance so they see the same in-memory cache.
|
|
6
6
|
* Handles deduplication, rate-limiting (429 backoff), and staleness.
|
|
7
7
|
*/
|
|
8
|
-
import { createHash } from 'node:crypto';
|
|
9
8
|
import { acquireRefreshFileLock, buildQuotaOperationError, fetchOAuthQuotaSnapshot, getPersistedMainQuota, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getQuotaRefreshEveryNRequests, getScopedQuotaWindowForModel, isQuotaPolicyAuthError, quotaBackoffActive, } from "./accounts.js";
|
|
9
|
+
import { mergeHeaderQuotaSnapshot } from "./quota-headers.js";
|
|
10
|
+
export { tokenFingerprint } from "./token-fingerprint.js";
|
|
11
|
+
import { tokenFingerprint } from "./token-fingerprint.js";
|
|
10
12
|
// Capture real setTimeout before tests can mock globalThis.setTimeout
|
|
11
13
|
const nativeSetTimeout = globalThis.setTimeout;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
function mergePollCompletionWithNewerHeaders(current, polled) {
|
|
15
|
+
if (current?.source !== 'headers')
|
|
16
|
+
return polled;
|
|
17
|
+
const fiveHourIsNewer = Boolean(current.five_hour &&
|
|
18
|
+
current.five_hour.checkedAt > (polled.five_hour?.checkedAt ?? 0));
|
|
19
|
+
const sevenDayIsNewer = Boolean(current.seven_day &&
|
|
20
|
+
current.seven_day.checkedAt > (polled.seven_day?.checkedAt ?? 0));
|
|
21
|
+
if (!fiveHourIsNewer && !sevenDayIsNewer)
|
|
22
|
+
return polled;
|
|
23
|
+
return mergeHeaderQuotaSnapshot(polled, {
|
|
24
|
+
...(fiveHourIsNewer && { five_hour: current.five_hour }),
|
|
25
|
+
...(sevenDayIsNewer && { seven_day: current.seven_day }),
|
|
26
|
+
fallbackAdvised: current.fallbackAdvised,
|
|
27
|
+
...(current.bindingWindowSource === 'headers' && {
|
|
28
|
+
bindingWindow: current.bindingWindow,
|
|
29
|
+
bindingWindowSource: current.bindingWindowSource,
|
|
30
|
+
}),
|
|
31
|
+
source: 'headers',
|
|
32
|
+
checkedAt: Math.max(fiveHourIsNewer ? (current.five_hour?.checkedAt ?? 0) : 0, sevenDayIsNewer ? (current.seven_day?.checkedAt ?? 0) : 0),
|
|
33
|
+
});
|
|
19
34
|
}
|
|
20
35
|
// ---------------------------------------------------------------------------
|
|
21
36
|
// Class
|
|
@@ -116,6 +131,29 @@ export class QuotaManager {
|
|
|
116
131
|
this.fallbackTokenFps.delete(accountId);
|
|
117
132
|
}
|
|
118
133
|
}
|
|
134
|
+
pushMainFromHeaders(accessToken, incoming) {
|
|
135
|
+
const checkedAt = incoming.checkedAt ?? this.now();
|
|
136
|
+
const accessTokenFp = tokenFingerprint(accessToken);
|
|
137
|
+
const quota = mergeHeaderQuotaSnapshot(this.mainTokenFp === accessTokenFp ? this.main?.quota : undefined, incoming);
|
|
138
|
+
const entry = {
|
|
139
|
+
quota,
|
|
140
|
+
checkedAt,
|
|
141
|
+
refreshAfter: getQuotaNextRefreshAt(quota, this.storage, checkedAt),
|
|
142
|
+
};
|
|
143
|
+
this.setMain(accessToken, entry);
|
|
144
|
+
return entry;
|
|
145
|
+
}
|
|
146
|
+
pushFallbackFromHeaders(accountId, accessToken, incoming) {
|
|
147
|
+
const checkedAt = incoming.checkedAt ?? this.now();
|
|
148
|
+
const quota = mergeHeaderQuotaSnapshot(this.getFallback(accountId, accessToken)?.quota, incoming);
|
|
149
|
+
const entry = {
|
|
150
|
+
quota,
|
|
151
|
+
checkedAt,
|
|
152
|
+
refreshAfter: getQuotaNextRefreshAt(quota, this.storage, checkedAt),
|
|
153
|
+
};
|
|
154
|
+
this.setFallback(accountId, entry, accessToken);
|
|
155
|
+
return entry;
|
|
156
|
+
}
|
|
119
157
|
// =========================================================================
|
|
120
158
|
// Refresh (async, deduplicated, rate-limited)
|
|
121
159
|
// =========================================================================
|
|
@@ -380,15 +418,16 @@ export class QuotaManager {
|
|
|
380
418
|
now: this.now,
|
|
381
419
|
});
|
|
382
420
|
const now = this.now();
|
|
421
|
+
const completedQuota = mergePollCompletionWithNewerHeaders(this.mainTokenFp === thisFetchFp ? this.main?.quota : undefined, quota);
|
|
383
422
|
this.mainTokenFp = tokenFingerprint(accessToken);
|
|
384
423
|
this.main = {
|
|
385
|
-
quota,
|
|
386
|
-
refreshAfter: getQuotaNextRefreshAt(
|
|
387
|
-
checkedAt: now,
|
|
424
|
+
quota: completedQuota,
|
|
425
|
+
refreshAfter: getQuotaNextRefreshAt(completedQuota, this.storage, now),
|
|
426
|
+
checkedAt: completedQuota.checkedAt ?? now,
|
|
388
427
|
};
|
|
389
428
|
this.mainLastApiError = undefined;
|
|
390
|
-
this.onMainQuotaFetched?.(
|
|
391
|
-
return
|
|
429
|
+
this.onMainQuotaFetched?.(completedQuota, now, this.mainTokenFp, fetchStartedAt);
|
|
430
|
+
return completedQuota;
|
|
392
431
|
}
|
|
393
432
|
catch (error) {
|
|
394
433
|
this._handleMainFetchError(error);
|
|
@@ -433,14 +472,15 @@ export class QuotaManager {
|
|
|
433
472
|
now: this.now,
|
|
434
473
|
});
|
|
435
474
|
const now = this.now();
|
|
475
|
+
const completedQuota = mergePollCompletionWithNewerHeaders(this.getFallback(accountId, accessToken)?.quota, quota);
|
|
436
476
|
this.setFallback(accountId, {
|
|
437
|
-
quota,
|
|
438
|
-
refreshAfter:
|
|
439
|
-
checkedAt: now,
|
|
477
|
+
quota: completedQuota,
|
|
478
|
+
refreshAfter: getQuotaNextRefreshAt(completedQuota, this.storage, now),
|
|
479
|
+
checkedAt: completedQuota.checkedAt ?? now,
|
|
440
480
|
}, accessToken);
|
|
441
481
|
this.fallbackApiErrors.delete(accountId);
|
|
442
482
|
this.fallbackErrorTokenFps.delete(accountId);
|
|
443
|
-
return
|
|
483
|
+
return completedQuota;
|
|
444
484
|
}
|
|
445
485
|
finally {
|
|
446
486
|
await fileLock.release();
|
package/dist/quotas.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AccountStorage, OAuthQuotaSnapshot } from './accounts.ts';
|
|
1
|
+
import type { AccountStorage, OAuthQuotaSnapshot, QuotaMoney } from './accounts.ts';
|
|
2
2
|
export declare const CLAUDE_QUOTAS_COMMAND_NAME = "claude-quota";
|
|
3
3
|
export type QuotaAccountSummary = {
|
|
4
4
|
name: string;
|
|
@@ -7,14 +7,17 @@ export type QuotaAccountSummary = {
|
|
|
7
7
|
quota?: OAuthQuotaSnapshot;
|
|
8
8
|
lastRefreshedAt?: number;
|
|
9
9
|
error?: string;
|
|
10
|
+
tierLabel?: string;
|
|
10
11
|
};
|
|
12
|
+
export declare function formatQuotaMoney(money: QuotaMoney): string;
|
|
11
13
|
export declare function buildFallbackQuotaSummaries(storage: AccountStorage | null, errors?: ReadonlyMap<string, string>): {
|
|
12
14
|
error?: string | undefined;
|
|
15
|
+
lastRefreshedAt: number | undefined;
|
|
16
|
+
tierLabel?: string | undefined;
|
|
13
17
|
name: string;
|
|
14
18
|
role: "fallback";
|
|
15
19
|
enabled: boolean;
|
|
16
20
|
quota: OAuthQuotaSnapshot | undefined;
|
|
17
|
-
lastRefreshedAt: number | undefined;
|
|
18
21
|
}[];
|
|
19
22
|
export declare function buildClaudeQuotaSummary(input: {
|
|
20
23
|
accounts: QuotaAccountSummary[];
|
package/dist/quotas.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isOAuthAccount } from "./accounts.js";
|
|
2
|
+
import { formatOAuthAccountTier } from "./oauth-profile.js";
|
|
2
3
|
export const CLAUDE_QUOTAS_COMMAND_NAME = 'claude-quota';
|
|
3
4
|
const WINDOW_LABELS = {
|
|
4
5
|
five_hour: '5h',
|
|
@@ -43,14 +44,28 @@ function formatReset(resetsAt, now) {
|
|
|
43
44
|
return '';
|
|
44
45
|
return `, resets ${formatResetDuration(resetsAt, now)}`;
|
|
45
46
|
}
|
|
46
|
-
function formatWindow(key, window, now) {
|
|
47
|
+
function formatWindow(key, window, now, bindingWindow) {
|
|
47
48
|
if (!window)
|
|
48
49
|
return ` - ${WINDOW_LABELS[key]}: unknown`;
|
|
49
|
-
|
|
50
|
+
const line = [
|
|
50
51
|
` - ${WINDOW_LABELS[key]}: ${formatPercent(window.remainingPercent)} remaining`,
|
|
51
52
|
` (${formatPercent(window.usedPercent)} used`,
|
|
52
53
|
`${formatReset(window.resetsAt, now)}, checked ${formatAge(window.checkedAt, now)})`,
|
|
53
54
|
].join('');
|
|
55
|
+
return bindingWindow === key ? `${line} •` : line;
|
|
56
|
+
}
|
|
57
|
+
export function formatQuotaMoney(money) {
|
|
58
|
+
try {
|
|
59
|
+
return new Intl.NumberFormat('en-US', {
|
|
60
|
+
style: 'currency',
|
|
61
|
+
currency: money.currency,
|
|
62
|
+
minimumFractionDigits: money.exponent,
|
|
63
|
+
maximumFractionDigits: money.exponent,
|
|
64
|
+
}).format(money.amountMinor / 10 ** money.exponent);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return `${money.amountMinor} ${money.currency}`;
|
|
68
|
+
}
|
|
54
69
|
}
|
|
55
70
|
function formatScopedWindow(window, now) {
|
|
56
71
|
return [
|
|
@@ -75,6 +90,9 @@ export function buildFallbackQuotaSummaries(storage, errors = new Map()) {
|
|
|
75
90
|
role: 'fallback',
|
|
76
91
|
enabled: account.enabled !== false,
|
|
77
92
|
quota: account.quota,
|
|
93
|
+
...(formatOAuthAccountTier(account.profile) && {
|
|
94
|
+
tierLabel: formatOAuthAccountTier(account.profile),
|
|
95
|
+
}),
|
|
78
96
|
lastRefreshedAt: account.lastRefreshedAt,
|
|
79
97
|
...(error && { error }),
|
|
80
98
|
};
|
|
@@ -94,16 +112,28 @@ export function buildClaudeQuotaSummary(input) {
|
|
|
94
112
|
const role = account.role === 'main' ? 'main' : 'fallback';
|
|
95
113
|
const disabled = account.enabled === false ? ' disabled' : '';
|
|
96
114
|
lines.push(`### ${account.name} (${role}${disabled})`);
|
|
115
|
+
if (account.tierLabel)
|
|
116
|
+
lines.push(` - Tier: ${account.tierLabel}`);
|
|
97
117
|
if (account.lastRefreshedAt) {
|
|
98
118
|
lines.push(` - Last token refresh: ${formatAge(account.lastRefreshedAt, now)}`);
|
|
99
119
|
}
|
|
100
120
|
if (account.error) {
|
|
101
121
|
lines.push(` - Error: ${account.error}`);
|
|
102
122
|
}
|
|
103
|
-
lines.push(formatWindow('five_hour', account.quota?.five_hour, now));
|
|
104
|
-
lines.push(formatWindow('seven_day', account.quota?.seven_day, now));
|
|
123
|
+
lines.push(formatWindow('five_hour', account.quota?.five_hour, now, account.quota?.bindingWindow));
|
|
124
|
+
lines.push(formatWindow('seven_day', account.quota?.seven_day, now, account.quota?.bindingWindow));
|
|
105
125
|
for (const window of account.quota?.scoped ?? []) {
|
|
106
|
-
|
|
126
|
+
const line = formatScopedWindow(window, now);
|
|
127
|
+
lines.push(account.quota?.bindingWindow === window.id ? `${line} •` : line);
|
|
128
|
+
}
|
|
129
|
+
const extraUsage = account.quota?.extraUsage;
|
|
130
|
+
if (extraUsage) {
|
|
131
|
+
const used = formatQuotaMoney(extraUsage.used);
|
|
132
|
+
const limit = formatQuotaMoney(extraUsage.limit);
|
|
133
|
+
lines.push(` - credits ${used}/${limit}${extraUsage.exhausted ? ' · exhausted' : ''}`);
|
|
134
|
+
}
|
|
135
|
+
if (account.quota?.fallbackAdvised === true) {
|
|
136
|
+
lines.push(' - → fallback advised');
|
|
107
137
|
}
|
|
108
138
|
lines.push('');
|
|
109
139
|
}
|
package/dist/relay.d.ts
CHANGED
|
@@ -24,5 +24,12 @@ export declare function sendViaRelay(options: {
|
|
|
24
24
|
fallback: () => Promise<Response>;
|
|
25
25
|
affinity?: string | null;
|
|
26
26
|
optimisticResponse?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Observes genuine upstream headers once per delivered response. WebSocket
|
|
29
|
+
* retries before downstream bytes replace staged headers, so the callback
|
|
30
|
+
* receives only the final accepted response; duplicate response_start frames
|
|
31
|
+
* within an attempt are ignored.
|
|
32
|
+
*/
|
|
33
|
+
onResponseHeaders?: (headers: Headers) => void;
|
|
27
34
|
}): Promise<Response>;
|
|
28
35
|
export declare const WORKER_SCRIPT = "\nasync function hashBody(body) {\n const bytes = new TextEncoder().encode(body)\n const digest = await crypto.subtle.digest('SHA-256', bytes)\n const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')\n return 'sha256:' + hex\n}\n\nfunction applyPatch(base, patch) {\n return base.slice(0, patch.start) + patch.insert + base.slice(patch.start + patch.deleteCount)\n}\n\nfunction applyPatchSet(base, patch) {\n if (!Array.isArray(patch)) return applyPatch(base, patch)\n if (patch.length === 0) return base\n\n let result = ''\n let cursor = 0\n for (const item of patch) {\n if (item.start < cursor) return { error: 'overlapping patch', status: 400 }\n result += base.slice(cursor, item.start)\n result += item.insert\n cursor = item.start + item.deleteCount\n }\n return result + base.slice(cursor)\n}\n\nasync function readState(env, affinity) {\n const raw = await env.RELAY_STATE.get('session:' + affinity)\n return raw ? JSON.parse(raw) : null\n}\n\nasync function writeState(env, affinity, state) {\n await env.RELAY_STATE.put('session:' + affinity, JSON.stringify(state), { expirationTtl: 86400 })\n}\n\nasync function resolveBody(env, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n const state = await readState(env, payload.affinity)\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nasync function prepareUpstream(env, payload) {\n if ((payload.protocol !== 1 && payload.protocol !== 2) || payload.type !== 'request' || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = await resolveBody(env, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n\n if (typeof body !== 'string' || (await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const stateWrite = writeState(env, payload.affinity, { body, hash: payload.next_hash, revision: payload.revision }).catch(() => {})\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'relay',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, stateWrite }\n}\n\nfunction resolveBodyFromState(state, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nfunction deferWorkerTask(task) {\n return new Promise((resolve) => setTimeout(resolve, 0)).then(task)\n}\n\nconst WEBSOCKET_STREAM_FLUSH_BYTES = 1024\nconst WEBSOCKET_STREAM_FLUSH_MS = 20\n\nfunction createWebSocketStreamCoalescer(socket, options = {}) {\n const flushBytes = options.flushBytes || WEBSOCKET_STREAM_FLUSH_BYTES\n const flushMs = options.flushMs ?? WEBSOCKET_STREAM_FLUSH_MS\n let chunks = []\n let byteLength = 0\n let flushTimer = null\n const stats = {\n upstreamChunks: 0,\n upstreamBytes: 0,\n frames: 0,\n frameBytes: 0,\n }\n\n const clearFlushTimer = () => {\n if (flushTimer === null) return\n clearTimeout(flushTimer)\n flushTimer = null\n }\n\n const mergeChunks = () => {\n if (chunks.length === 1) return chunks[0]\n const frame = new Uint8Array(byteLength)\n let offset = 0\n for (const chunk of chunks) {\n frame.set(chunk, offset)\n offset += chunk.byteLength\n }\n return frame\n }\n\n const flush = () => {\n clearFlushTimer()\n if (byteLength === 0) return\n const frame = mergeChunks()\n chunks = []\n byteLength = 0\n socket.send(frame)\n stats.frames += 1\n stats.frameBytes += frame.byteLength\n }\n\n const scheduleFlush = () => {\n if (flushTimer !== null) return\n flushTimer = setTimeout(flush, flushMs)\n }\n\n return {\n push(value) {\n const chunk = value instanceof Uint8Array ? value : new Uint8Array(value)\n if (chunk.byteLength === 0) return\n stats.upstreamChunks += 1\n stats.upstreamBytes += chunk.byteLength\n chunks.push(chunk)\n byteLength += chunk.byteLength\n if (byteLength >= flushBytes) flush()\n else scheduleFlush()\n },\n flush,\n stats() {\n return { ...stats, pendingBytes: byteLength }\n },\n }\n}\n\nfunction checkpointWebSocketState(env, payload, body, nextState) {\n return deferWorkerTask(async () => {\n try {\n if ((await hashBody(body)) !== payload.next_hash) {\n throw new Error('hash mismatch')\n }\n await writeState(env, payload.affinity, nextState)\n } catch (error) {\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n event: 'checkpoint_write_failed',\n affinity: String(payload.affinity).slice(0, 12),\n message: error instanceof Error ? error.message : String(error),\n }))\n }\n })\n}\n\nasync function prepareWebSocketUpstream(env, state, payload) {\n if (payload.protocol !== 2 || payload.type !== 'request' || !payload.id || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = resolveBodyFromState(state, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n if (typeof body !== 'string') return { error: 'invalid body', status: 400 }\n if ((await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const nextState = { body, hash: payload.next_hash, revision: payload.revision }\n const checkpoint = checkpointWebSocketState(env, payload, body, nextState)\n const logAccepted = () => console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, state: nextState, checkpoint, logAccepted }\n}\n\nfunction headersToObject(headers) {\n const result = {}\n for (const [key, value] of headers.entries()) result[key] = value\n return result\n}\n\nasync function handleRelayPayload(env, payload) {\n const prepared = await prepareUpstream(env, payload)\n if (prepared.error) return prepared\n const upstream = await fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: prepared.body,\n })\n return { upstream, stateWrite: prepared.stateWrite }\n}\n\nasync function handleWebSocket(socket, env, ctx, payload, getState, setState) {\n const heartbeat = setInterval(() => {\n try {\n socket.send(JSON.stringify({ protocol: 2, type: 'keepalive' }))\n } catch {\n clearInterval(heartbeat)\n }\n }, 15000)\n let streamCoalescer = null\n try {\n const result = await prepareWebSocketUpstream(env, getState(), payload)\n if (result.error) {\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: result.status, message: result.error }))\n return\n }\n\n setState(result.state)\n socket.send(JSON.stringify({ protocol: 2, type: 'accepted', id: payload.id, hash: result.state.hash, revision: result.state.revision }))\n ctx?.waitUntil?.(deferWorkerTask(result.logAccepted))\n\n const upstreamPromise = fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: result.body,\n })\n ctx?.waitUntil?.(result.checkpoint)\n const upstream = await upstreamPromise\n socket.send(JSON.stringify({\n protocol: 2,\n type: 'response_start',\n id: payload.id,\n status: upstream.status,\n statusText: upstream.statusText,\n headers: headersToObject(upstream.headers),\n }))\n\n const reader = upstream.body?.getReader()\n if (reader) {\n streamCoalescer = createWebSocketStreamCoalescer(socket)\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n streamCoalescer.push(value)\n }\n streamCoalescer.flush()\n }\n const stats = streamCoalescer?.stats?.() || {}\n socket.send(JSON.stringify({ protocol: 2, type: 'done', id: payload.id, ...stats }))\n } catch (error) {\n try {\n streamCoalescer?.flush?.()\n } catch {}\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: 500, message: error instanceof Error ? error.message : String(error) }))\n } finally {\n clearInterval(heartbeat)\n }\n}\n\nexport default {\n async fetch(request, env, ctx) {\n if (request.headers.get('Upgrade') === 'websocket') {\n const url = new URL(request.url)\n const token = url.searchParams.get('token')\n const affinity = url.searchParams.get('affinity')\n if (token !== env.RELAY_TOKEN) return new Response('unauthorized', { status: 401 })\n if (!affinity) return new Response('missing affinity', { status: 400 })\n const pair = new WebSocketPair()\n const client = pair[0]\n const server = pair[1]\n server.binaryType = 'arraybuffer'\n let state = null\n let ready = false\n server.accept()\n const loadState = readState(env, affinity).then((loadedState) => {\n state = loadedState\n ready = true\n server.send(JSON.stringify({\n protocol: 2,\n type: 'ready',\n state: state ? { hash: state.hash, revision: state.revision } : null,\n }))\n }).catch((error) => {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 500, message: error instanceof Error ? error.message : String(error) }))\n server.close(1011, 'state load failed')\n })\n ctx?.waitUntil?.(loadState)\n if (!ctx?.waitUntil) void loadState\n let busy = false\n server.addEventListener('message', (event) => {\n if (!ready) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 425, message: 'relay state is not ready' }))\n return\n }\n if (busy) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 429, message: 'request already in flight' }))\n return\n }\n let payload\n try {\n payload = JSON.parse(event.data)\n } catch {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 400, message: 'invalid JSON payload' }))\n return\n }\n payload.affinity = affinity\n busy = true\n const run = handleWebSocket(server, env, ctx, payload, () => state, (nextState) => { state = nextState }).finally(() => { busy = false })\n ctx?.waitUntil?.(run)\n if (!ctx?.waitUntil) void run\n })\n return new Response(null, { status: 101, webSocket: client })\n }\n\n if (request.method === 'GET') {\n return Response.json({ status: 'ok', transports: ['http', 'websocket'] })\n }\n if (request.method !== 'POST') return new Response('method not allowed', { status: 405 })\n if (request.headers.get('x-relay-token') !== env.RELAY_TOKEN) {\n return new Response('unauthorized', { status: 401 })\n }\n\n try {\n const payload = await request.json()\n const result = await handleRelayPayload(env, payload)\n if (result.error) return Response.json({ error: result.error }, { status: result.status })\n\n if (result.stateWrite) ctx.waitUntil(result.stateWrite)\n\n const upstream = result.upstream\n return new Response(upstream.body, {\n status: upstream.status,\n statusText: upstream.statusText,\n headers: upstream.headers,\n })\n } catch (error) {\n return Response.json(\n { error: error instanceof Error ? error.message : 'internal relay error' },\n { status: 502 },\n )\n }\n },\n}\n";
|