@teamvibe/poller 0.8.2-rc.2 → 0.8.3-rc.2
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/classify-failed-iteration.d.ts +7 -0
- package/dist/classify-failed-iteration.js +8 -0
- package/dist/claude-quota.d.ts +15 -0
- package/dist/claude-quota.js +188 -0
- package/dist/claude-rate-limit.d.ts +19 -0
- package/dist/claude-rate-limit.js +44 -0
- package/dist/claude-spawner.d.ts +15 -0
- package/dist/claude-spawner.js +306 -25
- package/dist/config-client.d.ts +2 -0
- package/dist/config-client.js +3 -0
- package/dist/inbox-manager.d.ts +22 -1
- package/dist/inbox-manager.js +57 -3
- package/dist/inbox-watcher.d.ts +1 -1
- package/dist/inbox-watcher.js +42 -20
- package/dist/poller.js +262 -51
- package/dist/pool-defer.d.ts +13 -0
- package/dist/pool-defer.js +81 -0
- package/dist/pool-telemetry.d.ts +44 -0
- package/dist/pool-telemetry.js +107 -0
- package/dist/quota-pause.d.ts +16 -0
- package/dist/quota-pause.js +96 -0
- package/dist/session-store.d.ts +2 -2
- package/dist/session-store.js +6 -3
- package/dist/slack-client.d.ts +4 -1
- package/dist/slack-client.js +19 -1
- package/dist/subscription-pool-client.d.ts +32 -0
- package/dist/subscription-pool-client.js +139 -0
- package/dist/subscription-pool.d.ts +18 -0
- package/dist/subscription-pool.js +24 -0
- package/dist/subscription-runtime.d.ts +53 -0
- package/dist/subscription-runtime.js +74 -0
- package/dist/subscription-selection.d.ts +75 -0
- package/dist/subscription-selection.js +142 -0
- package/dist/subscription-usage-probe.d.ts +16 -0
- package/dist/subscription-usage-probe.js +106 -0
- package/dist/subscription-usage-store.d.ts +47 -0
- package/dist/subscription-usage-store.js +206 -0
- package/dist/types.d.ts +25 -9
- package/dist/worktree-manager.js +4 -0
- package/npm-shrinkwrap.json +78 -77
- package/package.json +1 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type SubscriptionAccount } from './subscription-pool-client.js';
|
|
2
|
+
import { type ResolvedPoolModel } from './subscription-pool.js';
|
|
3
|
+
import { FileSubscriptionUsageStore, type SubscriptionUsageStore } from './subscription-usage-store.js';
|
|
4
|
+
import { type PoolFetchResult } from './subscription-selection.js';
|
|
5
|
+
import type { SubscriptionPin, TeamVibeQueueMessage, SessionMetrics } from './types.js';
|
|
6
|
+
import type { ClaudeRateLimitInfo } from './claude-rate-limit.js';
|
|
7
|
+
import type { ClaudeQuotaFailure } from './claude-quota.js';
|
|
8
|
+
import type { KilledByPoller } from './classify-failed-iteration.js';
|
|
9
|
+
export interface PoolAttempt {
|
|
10
|
+
workspaceId: string;
|
|
11
|
+
accountId: string;
|
|
12
|
+
accountLabel?: string;
|
|
13
|
+
observedAt?: number;
|
|
14
|
+
rateLimitInfo?: ClaudeRateLimitInfo;
|
|
15
|
+
credentialVersion: string;
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
model?: string;
|
|
18
|
+
success: boolean;
|
|
19
|
+
exitCode: number | null;
|
|
20
|
+
killedByPoller?: KilledByPoller;
|
|
21
|
+
quota?: ClaudeQuotaFailure;
|
|
22
|
+
metrics?: SessionMetrics;
|
|
23
|
+
}
|
|
24
|
+
export declare class PoolDeferredError extends Error {
|
|
25
|
+
readonly eligibleAt: number;
|
|
26
|
+
readonly resetAt?: string | undefined;
|
|
27
|
+
attempts?: readonly PoolAttempt[];
|
|
28
|
+
constructor(eligibleAt?: number, resetAt?: string | undefined);
|
|
29
|
+
}
|
|
30
|
+
export interface PoolRuntimeOptions {
|
|
31
|
+
pin?: SubscriptionPin;
|
|
32
|
+
pool?: PoolFetchResult<SubscriptionAccount>;
|
|
33
|
+
attempts?: readonly PoolAttempt[];
|
|
34
|
+
quotaDeferEpisodes?: number;
|
|
35
|
+
persist: (sessionId: string, pin: SubscriptionPin, attempts?: readonly PoolAttempt[]) => Promise<void>;
|
|
36
|
+
complete: (sessionId: string, pin: SubscriptionPin, attempts: readonly PoolAttempt[], rateLimitInfo?: ClaudeRateLimitInfo) => Promise<void>;
|
|
37
|
+
shouldStop: () => boolean;
|
|
38
|
+
}
|
|
39
|
+
export declare function subscriptionUsageStore(): FileSubscriptionUsageStore;
|
|
40
|
+
export declare function fetchRuntimePool(msg: TeamVibeQueueMessage): Promise<PoolFetchResult<SubscriptionAccount>>;
|
|
41
|
+
export declare function preparePool(msg: TeamVibeQueueMessage, resolved: ResolvedPoolModel, pin?: SubscriptionPin, fetched?: PoolFetchResult<SubscriptionAccount>): Promise<{
|
|
42
|
+
account: SubscriptionAccount;
|
|
43
|
+
store: FileSubscriptionUsageStore;
|
|
44
|
+
pool: PoolFetchResult<SubscriptionAccount>;
|
|
45
|
+
} | {
|
|
46
|
+
account: undefined;
|
|
47
|
+
store: FileSubscriptionUsageStore;
|
|
48
|
+
pool: PoolFetchResult<SubscriptionAccount>;
|
|
49
|
+
}>;
|
|
50
|
+
export declare function accountPin(account: SubscriptionAccount, resolved: ResolvedPoolModel): SubscriptionPin;
|
|
51
|
+
export declare function validateAccount(account: SubscriptionAccount, resolved: ResolvedPoolModel): Promise<void>;
|
|
52
|
+
/** A snapshot TTL/recheck is not a provider reset. Report a reset only when reactive evidence establishes it. */
|
|
53
|
+
export declare function poolDeferredError(pool: PoolFetchResult<SubscriptionAccount>, resolved: ResolvedPoolModel, store: SubscriptionUsageStore, eligibleAt?: number): Promise<PoolDeferredError>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { emitCandidateUsage } from './pool-telemetry.js';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { config } from './config.js';
|
|
4
|
+
import { getPollerId } from './auth-provider.js';
|
|
5
|
+
import { fetchSubscriptionPool } from './subscription-pool-client.js';
|
|
6
|
+
import { selectPoolForResolvedModel } from './subscription-pool.js';
|
|
7
|
+
import { FileSubscriptionUsageStore } from './subscription-usage-store.js';
|
|
8
|
+
import { POOL_RECHECK_MS } from './subscription-selection.js';
|
|
9
|
+
export class PoolDeferredError extends Error {
|
|
10
|
+
eligibleAt;
|
|
11
|
+
resetAt;
|
|
12
|
+
attempts;
|
|
13
|
+
constructor(eligibleAt = Date.now() + POOL_RECHECK_MS, resetAt) {
|
|
14
|
+
super('Subscription pool unavailable; work retained');
|
|
15
|
+
this.eligibleAt = eligibleAt;
|
|
16
|
+
this.resetAt = resetAt;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function subscriptionUsageStore() {
|
|
20
|
+
return new FileSubscriptionUsageStore(join(config.TEAMVIBE_DATA_DIR, 'subscription-usage'));
|
|
21
|
+
}
|
|
22
|
+
export async function fetchRuntimePool(msg) {
|
|
23
|
+
return fetchSubscriptionPool({ pollerId: getPollerId() || '', workspaceId: msg.teamvibe.workspaceId, channelId: msg.teamvibe.channelId }, { apiUrl: config.TEAMVIBE_API_URL, pollerToken: config.TEAMVIBE_POLLER_TOKEN || '' });
|
|
24
|
+
}
|
|
25
|
+
export async function preparePool(msg, resolved, pin, fetched) {
|
|
26
|
+
const pool = fetched ?? await fetchRuntimePool(msg);
|
|
27
|
+
const store = subscriptionUsageStore();
|
|
28
|
+
if (pool.kind === 'ready')
|
|
29
|
+
await emitCandidateUsage(msg, pool.accounts, store);
|
|
30
|
+
if (pool.kind === 'ready' && pin?.workspaceId === msg.teamvibe.workspaceId) {
|
|
31
|
+
const account = pool.accounts.find(a => a.accountId === pin.accountId);
|
|
32
|
+
if (account) {
|
|
33
|
+
const selected = await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool: { kind: 'ready', accounts: [account] }, store, now: Date.now() });
|
|
34
|
+
if (selected.kind === 'selected')
|
|
35
|
+
return { account, store, pool };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const selected = await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool, store, now: Date.now() });
|
|
39
|
+
if (selected.kind === 'selected')
|
|
40
|
+
return { account: selected.account, store, pool };
|
|
41
|
+
if (selected.kind === 'no_accounts_configured')
|
|
42
|
+
return { account: undefined, store, pool };
|
|
43
|
+
throw await poolDeferredError(pool, resolved, store, selected.kind === 'deferred' ? selected.eligibleAt : undefined);
|
|
44
|
+
}
|
|
45
|
+
export function accountPin(account, resolved) {
|
|
46
|
+
return { workspaceId: account.workspaceId, accountId: account.accountId, credentialVersion: account.credentialVersion, ...resolved };
|
|
47
|
+
}
|
|
48
|
+
export async function validateAccount(account, resolved) {
|
|
49
|
+
const selected = await selectPoolForResolvedModel(resolved, { workspaceId: account.workspaceId, pool: { kind: 'ready', accounts: [account] }, store: subscriptionUsageStore(), now: Date.now() });
|
|
50
|
+
if (selected.kind !== 'selected')
|
|
51
|
+
throw new PoolDeferredError(selected.kind === 'deferred' ? selected.eligibleAt : undefined);
|
|
52
|
+
}
|
|
53
|
+
/** A snapshot TTL/recheck is not a provider reset. Report a reset only when reactive evidence establishes it. */
|
|
54
|
+
export async function poolDeferredError(pool, resolved, store, eligibleAt) {
|
|
55
|
+
if (eligibleAt === undefined) {
|
|
56
|
+
const selected = await selectPoolForResolvedModel(resolved, { workspaceId: pool.kind === 'ready' ? pool.accounts[0]?.workspaceId ?? '' : '', pool, store, now: Date.now() });
|
|
57
|
+
eligibleAt = selected.kind === 'deferred' ? selected.eligibleAt : Date.now() + POOL_RECHECK_MS;
|
|
58
|
+
}
|
|
59
|
+
if (pool.kind === 'ready') {
|
|
60
|
+
try {
|
|
61
|
+
for (const account of pool.accounts) {
|
|
62
|
+
const selected = await selectPoolForResolvedModel(resolved, { workspaceId: account.workspaceId, pool: { kind: 'ready', accounts: [account] }, store, now: Date.now() });
|
|
63
|
+
if (selected.kind !== 'deferred' || selected.eligibleAt !== eligibleAt)
|
|
64
|
+
continue;
|
|
65
|
+
const state = await store.read(account);
|
|
66
|
+
const live = state.reactive.filter(r => !r.quota.resetAt || Date.parse(r.quota.resetAt) > Date.now());
|
|
67
|
+
if (live.length && live.every(r => r.quota.resetAt && Number.isFinite(Date.parse(r.quota.resetAt))) && live.some(r => Date.parse(r.quota.resetAt) === eligibleAt))
|
|
68
|
+
return new PoolDeferredError(eligibleAt, new Date(eligibleAt).toISOString());
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch { }
|
|
72
|
+
}
|
|
73
|
+
return new PoolDeferredError(eligibleAt);
|
|
74
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ClaudeQuotaFailure } from './claude-quota.js';
|
|
2
|
+
export declare const USAGE_THRESHOLD_PERCENT = 90;
|
|
3
|
+
/** Passive snapshots are useful for five minutes, then become unknown. */
|
|
4
|
+
export declare const PASSIVE_USAGE_TTL_MS: number;
|
|
5
|
+
export declare const PROBE_USAGE_TTL_MS = 60000;
|
|
6
|
+
export declare const POOL_RECHECK_MS: number;
|
|
7
|
+
export interface AccountIdentity {
|
|
8
|
+
workspaceId: string;
|
|
9
|
+
accountId: string;
|
|
10
|
+
credentialVersion: string;
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
}
|
|
13
|
+
export type PoolFetchResult<A extends AccountIdentity = AccountIdentity> = {
|
|
14
|
+
kind: 'ready';
|
|
15
|
+
accounts: readonly A[];
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'no_accounts_configured';
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'unknown_pool_state';
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'fetch_unauthorized';
|
|
22
|
+
status: 401 | 403;
|
|
23
|
+
} | {
|
|
24
|
+
kind: 'fetch_unavailable';
|
|
25
|
+
reason: 'configuration' | 'http' | 'network' | 'invalid_response' | 'cancelled' | 'no_usable_accounts';
|
|
26
|
+
status?: number;
|
|
27
|
+
};
|
|
28
|
+
export interface UsageWindow {
|
|
29
|
+
window: 'five_hour' | 'seven_day';
|
|
30
|
+
scope: 'global' | 'sonnet' | 'opus' | 'haiku' | 'uncertain';
|
|
31
|
+
percent?: number;
|
|
32
|
+
resetAt?: number;
|
|
33
|
+
}
|
|
34
|
+
export interface UsageObservation {
|
|
35
|
+
source: 'passive' | 'probe';
|
|
36
|
+
observedAt: number;
|
|
37
|
+
windows: readonly UsageWindow[];
|
|
38
|
+
}
|
|
39
|
+
export interface ReactiveExhaustion {
|
|
40
|
+
observedAt: number;
|
|
41
|
+
quota: ClaudeQuotaFailure;
|
|
42
|
+
}
|
|
43
|
+
export interface AccountUsageState {
|
|
44
|
+
usage?: UsageObservation;
|
|
45
|
+
reactive?: readonly ReactiveExhaustion[];
|
|
46
|
+
}
|
|
47
|
+
export type SelectionOutcome<A extends AccountIdentity = AccountIdentity> = {
|
|
48
|
+
kind: 'selected';
|
|
49
|
+
account: A;
|
|
50
|
+
provenance: 'known';
|
|
51
|
+
score: number;
|
|
52
|
+
source: 'passive' | 'probe';
|
|
53
|
+
} | {
|
|
54
|
+
kind: 'selected';
|
|
55
|
+
account: A;
|
|
56
|
+
provenance: 'unknown';
|
|
57
|
+
uncertainApplicability: boolean;
|
|
58
|
+
} | {
|
|
59
|
+
kind: 'deferred';
|
|
60
|
+
eligibleAt: number;
|
|
61
|
+
} | Exclude<PoolFetchResult<A>, {
|
|
62
|
+
kind: 'ready';
|
|
63
|
+
}>;
|
|
64
|
+
export declare function record(value: unknown): Record<string, unknown>;
|
|
65
|
+
export declare function normalizePassiveUsage(value: unknown, observedAt: number): UsageObservation;
|
|
66
|
+
export declare function normalizeProbeUsage(value: unknown, observedAt: number): UsageObservation;
|
|
67
|
+
export declare function reactiveEligibleAt(reactive: ReactiveExhaustion): number;
|
|
68
|
+
/** Pure policy. Unknown is eligible after every known/reactive blocker is ruled out. */
|
|
69
|
+
export declare function selectSubscriptionAccount<A extends AccountIdentity>(input: {
|
|
70
|
+
pool: PoolFetchResult<A>;
|
|
71
|
+
/** Stable account ID within the pool's single workspace. */
|
|
72
|
+
states: Readonly<Record<string, AccountUsageState | undefined>>;
|
|
73
|
+
model: string | undefined;
|
|
74
|
+
now: number;
|
|
75
|
+
}): SelectionOutcome<A>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
export const USAGE_THRESHOLD_PERCENT = 90;
|
|
2
|
+
/** Passive snapshots are useful for five minutes, then become unknown. */
|
|
3
|
+
export const PASSIVE_USAGE_TTL_MS = 5 * 60_000;
|
|
4
|
+
export const PROBE_USAGE_TTL_MS = 60_000;
|
|
5
|
+
export const POOL_RECHECK_MS = 15 * 60_000;
|
|
6
|
+
export function record(value) {
|
|
7
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
8
|
+
}
|
|
9
|
+
function percent(value, scale) {
|
|
10
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= scale ? value * (100 / scale) : undefined;
|
|
11
|
+
}
|
|
12
|
+
function epoch(value) {
|
|
13
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 253402300799 ? value * 1000 : undefined;
|
|
14
|
+
}
|
|
15
|
+
function date(value) {
|
|
16
|
+
if (typeof value !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?(?:Z|[+-]\d\d:\d\d)$/.test(value))
|
|
17
|
+
return undefined;
|
|
18
|
+
const [year, month, day] = value.slice(0, 10).split('-').map(Number);
|
|
19
|
+
const calendar = new Date(Date.UTC(year, month - 1, day));
|
|
20
|
+
if (calendar.getUTCFullYear() !== year || calendar.getUTCMonth() + 1 !== month || calendar.getUTCDate() !== day)
|
|
21
|
+
return undefined;
|
|
22
|
+
const ms = Date.parse(value);
|
|
23
|
+
return Number.isFinite(ms) && ms > 0 ? ms : undefined;
|
|
24
|
+
}
|
|
25
|
+
export function normalizePassiveUsage(value, observedAt) {
|
|
26
|
+
const info = record(value);
|
|
27
|
+
const windows = [];
|
|
28
|
+
const unified = record(info['unifiedWindows']);
|
|
29
|
+
for (const window of ['five_hour', 'seven_day']) {
|
|
30
|
+
const raw = unified[window];
|
|
31
|
+
if (raw == null)
|
|
32
|
+
continue;
|
|
33
|
+
const item = record(raw);
|
|
34
|
+
windows.push({ window, scope: 'global', percent: percent(item['utilization'], 1), resetAt: epoch(item['resetsAt']) });
|
|
35
|
+
}
|
|
36
|
+
const type = info['rateLimitType'];
|
|
37
|
+
if (typeof type === 'string') {
|
|
38
|
+
const legacy = type.match(/^(five_hour|seven_day)(?:_(sonnet|opus|haiku))?$/);
|
|
39
|
+
windows.push({
|
|
40
|
+
window: legacy?.[1] === 'five_hour' ? 'five_hour' : 'seven_day',
|
|
41
|
+
scope: legacy ? (legacy[2] ?? 'global') : 'uncertain',
|
|
42
|
+
percent: percent(info['utilization'], 1), resetAt: epoch(info['resetsAt']),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return { source: 'passive', observedAt, windows };
|
|
46
|
+
}
|
|
47
|
+
export function normalizeProbeUsage(value, observedAt) {
|
|
48
|
+
const info = record(value);
|
|
49
|
+
const windows = [];
|
|
50
|
+
for (const [key, window, scope] of [
|
|
51
|
+
['five_hour', 'five_hour', 'global'], ['seven_day', 'seven_day', 'global'],
|
|
52
|
+
['seven_day_sonnet', 'seven_day', 'sonnet'], ['seven_day_opus', 'seven_day', 'opus'],
|
|
53
|
+
]) {
|
|
54
|
+
const raw = info[key];
|
|
55
|
+
if (raw == null)
|
|
56
|
+
continue;
|
|
57
|
+
const item = record(raw);
|
|
58
|
+
windows.push({ window, scope, percent: percent(item['utilization'], 100), resetAt: date(item['resets_at']) });
|
|
59
|
+
}
|
|
60
|
+
// The optional endpoint is undocumented. Accept only the explicit window /
|
|
61
|
+
// model form below; arbitrary internal codenames never map to a family.
|
|
62
|
+
if (Array.isArray(info['limits'])) {
|
|
63
|
+
for (const raw of info['limits'].slice(0, 128)) {
|
|
64
|
+
const item = record(raw);
|
|
65
|
+
const window = item['window'];
|
|
66
|
+
if (window !== 'five_hour' && window !== 'seven_day') {
|
|
67
|
+
windows.push({ window: 'seven_day', scope: 'uncertain' });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const model = item['model'];
|
|
71
|
+
const scope = model === 'global' ? 'global' : typeof model === 'string' ? family(model) ?? 'uncertain' : 'uncertain';
|
|
72
|
+
windows.push({ window, scope, percent: percent(item['utilization'], 100), resetAt: date(item['resets_at']) });
|
|
73
|
+
}
|
|
74
|
+
if (info['limits'].length > 128)
|
|
75
|
+
windows.push({ window: 'seven_day', scope: 'uncertain' });
|
|
76
|
+
}
|
|
77
|
+
return { source: 'probe', observedAt, windows };
|
|
78
|
+
}
|
|
79
|
+
function family(model) {
|
|
80
|
+
return model?.toLowerCase().match(/^(?:claude-)?(sonnet|opus|haiku)(?:-\d[\w.-]*)?(?:\[1m\])?$/)?.[1];
|
|
81
|
+
}
|
|
82
|
+
function applicable(scope, model) {
|
|
83
|
+
return scope === 'global' || scope === 'uncertain' || family(model) === undefined || scope === family(model);
|
|
84
|
+
}
|
|
85
|
+
export function reactiveEligibleAt(reactive) {
|
|
86
|
+
const parsed = reactive.quota.resetAt ? Date.parse(reactive.quota.resetAt) : NaN;
|
|
87
|
+
return Number.isFinite(parsed) && parsed > reactive.observedAt ? parsed : reactive.observedAt + POOL_RECHECK_MS;
|
|
88
|
+
}
|
|
89
|
+
/** Pure policy. Unknown is eligible after every known/reactive blocker is ruled out. */
|
|
90
|
+
export function selectSubscriptionAccount(input) {
|
|
91
|
+
if (input.pool.kind !== 'ready')
|
|
92
|
+
return input.pool;
|
|
93
|
+
const accounts = input.pool.accounts;
|
|
94
|
+
if (!accounts.length || accounts.some(a => !a.enabled || a.workspaceId !== accounts[0].workspaceId))
|
|
95
|
+
return { kind: 'fetch_unavailable', reason: 'invalid_response' };
|
|
96
|
+
const known = [];
|
|
97
|
+
const unknown = [];
|
|
98
|
+
const deadlines = [];
|
|
99
|
+
for (const account of input.pool.accounts) {
|
|
100
|
+
if (!account.enabled)
|
|
101
|
+
continue;
|
|
102
|
+
const state = input.states[account.accountId];
|
|
103
|
+
const blockers = [];
|
|
104
|
+
for (const reactive of state?.reactive ?? []) {
|
|
105
|
+
// Like durable pause createdAt, future observations expire on clock rollback.
|
|
106
|
+
if (input.now < reactive.observedAt)
|
|
107
|
+
continue;
|
|
108
|
+
const scope = reactive.quota.modelScope;
|
|
109
|
+
if (scope && family(scope) && family(input.model) && family(scope) !== family(input.model))
|
|
110
|
+
continue;
|
|
111
|
+
const until = reactiveEligibleAt(reactive);
|
|
112
|
+
if (input.now < until)
|
|
113
|
+
blockers.push(until);
|
|
114
|
+
}
|
|
115
|
+
const usage = state?.usage;
|
|
116
|
+
const expiry = usage ? usage.observedAt + (usage.source === 'passive' ? PASSIVE_USAGE_TTL_MS : PROBE_USAGE_TTL_MS) : 0;
|
|
117
|
+
const fresh = usage && usage.observedAt <= input.now && input.now < expiry;
|
|
118
|
+
const windows = fresh ? usage.windows.filter(w => applicable(w.scope, input.model) && (w.resetAt === undefined || w.resetAt > input.now)) : [];
|
|
119
|
+
const uncertain = windows.some(w => w.scope === 'uncertain' || (w.scope !== 'global' && !family(input.model)));
|
|
120
|
+
for (const w of windows) {
|
|
121
|
+
if (w.percent !== undefined && w.percent >= USAGE_THRESHOLD_PERCENT)
|
|
122
|
+
blockers.push(Math.min(w.resetAt ?? expiry, expiry));
|
|
123
|
+
}
|
|
124
|
+
if (blockers.length) {
|
|
125
|
+
deadlines.push(Math.max(...blockers));
|
|
126
|
+
}
|
|
127
|
+
else if (windows.length && !uncertain && windows.every(w => w.percent !== undefined && w.resetAt !== undefined)) {
|
|
128
|
+
known.push({ account, score: Math.max(...windows.map(w => w.percent)), source: usage.source });
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
unknown.push({ account, uncertainApplicability: uncertain });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const compare = (a, b) => a.accountId < b.accountId ? -1 : a.accountId > b.accountId ? 1 : 0;
|
|
135
|
+
known.sort((a, b) => a.score - b.score || compare(a.account, b.account));
|
|
136
|
+
unknown.sort((a, b) => compare(a.account, b.account));
|
|
137
|
+
if (known[0])
|
|
138
|
+
return { kind: 'selected', provenance: 'known', ...known[0] };
|
|
139
|
+
if (unknown[0])
|
|
140
|
+
return { kind: 'selected', provenance: 'unknown', ...unknown[0] };
|
|
141
|
+
return { kind: 'deferred', eligibleAt: Math.min(...deadlines) };
|
|
142
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type SubscriptionAccount } from './subscription-pool-client.js';
|
|
2
|
+
import type { ProbeStatus, SubscriptionUsageStore } from './subscription-usage-store.js';
|
|
3
|
+
interface ProbeOptions {
|
|
4
|
+
fetch?: typeof fetch;
|
|
5
|
+
now?: () => number;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
concurrency?: number;
|
|
8
|
+
}
|
|
9
|
+
export type ProbeResultStatus = ProbeStatus | 'store_unavailable';
|
|
10
|
+
/** Optional background source. Selection NEVER calls/awaits this class. */
|
|
11
|
+
export declare class OptionalSubscriptionUsageProbe {
|
|
12
|
+
#private;
|
|
13
|
+
constructor(store: SubscriptionUsageStore, options?: ProbeOptions);
|
|
14
|
+
probeAccounts(workspaceId: string, accounts: readonly SubscriptionAccount[], signal?: AbortSignal): Promise<Record<string, ProbeResultStatus>>;
|
|
15
|
+
}
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { boundedRequest, readSubscriptionJson } from './subscription-pool-client.js';
|
|
2
|
+
import { normalizeProbeUsage, record } from './subscription-selection.js';
|
|
3
|
+
/** Optional background source. Selection NEVER calls/awaits this class. */
|
|
4
|
+
export class OptionalSubscriptionUsageProbe {
|
|
5
|
+
#store;
|
|
6
|
+
#options;
|
|
7
|
+
#concurrency;
|
|
8
|
+
#active = 0;
|
|
9
|
+
#waiting = [];
|
|
10
|
+
constructor(store, options = {}) {
|
|
11
|
+
this.#store = store;
|
|
12
|
+
this.#options = options;
|
|
13
|
+
this.#concurrency = Math.max(1, Math.min(8, Math.floor(options.concurrency ?? 2) || 2));
|
|
14
|
+
}
|
|
15
|
+
async #slot(signal) {
|
|
16
|
+
if (signal?.aborted)
|
|
17
|
+
return false;
|
|
18
|
+
if (this.#active < this.#concurrency) {
|
|
19
|
+
this.#active++;
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
return new Promise(resolve => {
|
|
23
|
+
const start = () => { signal?.removeEventListener('abort', cancel); resolve(true); };
|
|
24
|
+
const cancel = () => {
|
|
25
|
+
const index = this.#waiting.indexOf(start);
|
|
26
|
+
if (index >= 0)
|
|
27
|
+
this.#waiting.splice(index, 1);
|
|
28
|
+
resolve(false);
|
|
29
|
+
};
|
|
30
|
+
this.#waiting.push(start);
|
|
31
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
#release() {
|
|
35
|
+
const next = this.#waiting.shift();
|
|
36
|
+
if (next)
|
|
37
|
+
next();
|
|
38
|
+
else
|
|
39
|
+
this.#active--;
|
|
40
|
+
}
|
|
41
|
+
async probeAccounts(workspaceId, accounts, signal) {
|
|
42
|
+
const results = Object.create(null);
|
|
43
|
+
await Promise.all(accounts.filter(a => a.enabled && a.workspaceId === workspaceId).map(async (account) => {
|
|
44
|
+
if (!await this.#slot(signal)) {
|
|
45
|
+
results[account.accountId] = 'cancelled';
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
results[account.accountId] = await this.#probe(account, signal);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
this.#release();
|
|
53
|
+
}
|
|
54
|
+
}));
|
|
55
|
+
return results;
|
|
56
|
+
}
|
|
57
|
+
async #probe(account, signal) {
|
|
58
|
+
const now = () => (this.#options.now ?? Date.now)();
|
|
59
|
+
if (signal?.aborted)
|
|
60
|
+
return 'cancelled';
|
|
61
|
+
try {
|
|
62
|
+
if (!await this.#store.beginProbe(account, now()))
|
|
63
|
+
return (await this.#store.read(account)).probe?.status ?? 'unavailable';
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return 'store_unavailable';
|
|
67
|
+
}
|
|
68
|
+
const outcome = { status: 'unavailable' };
|
|
69
|
+
try {
|
|
70
|
+
await boundedRequest(async (signal) => {
|
|
71
|
+
const response = await (this.#options.fetch ?? fetch)('https://api.anthropic.com/api/oauth/usage', {
|
|
72
|
+
method: 'GET', signal, redirect: 'error',
|
|
73
|
+
headers: { Authorization: `Bearer ${account.credential.unwrap()}`, 'anthropic-beta': 'oauth-2025-04-20', 'Cache-Control': 'no-store' },
|
|
74
|
+
});
|
|
75
|
+
if (response.status === 401) {
|
|
76
|
+
outcome.status = 'unauthorized';
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (response.status === 403) {
|
|
80
|
+
outcome.status = 'unauthorized';
|
|
81
|
+
const body = record(await readSubscriptionJson(response));
|
|
82
|
+
if (record(body['error'])['error_code'] === 'oauth_scope_insufficient' || body['error_code'] === 'oauth_scope_insufficient')
|
|
83
|
+
outcome.status = 'unsupported';
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (!response.ok)
|
|
87
|
+
return;
|
|
88
|
+
const body = await readSubscriptionJson(response);
|
|
89
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
90
|
+
return;
|
|
91
|
+
outcome.usage = normalizeProbeUsage(body, now());
|
|
92
|
+
outcome.status = 'ok';
|
|
93
|
+
}, signal, this.#options.timeoutMs);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
outcome.status = signal?.aborted ? 'cancelled' : 'unavailable';
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
await this.#store.finishProbe(account, { status: outcome.status, observedAt: now(), ...(outcome.usage && outcome.status === 'ok' ? { usage: outcome.usage } : {}) });
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return 'store_unavailable';
|
|
103
|
+
}
|
|
104
|
+
return outcome.status;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type ClaudeRateLimitInfo } from './claude-rate-limit.js';
|
|
2
|
+
import type { ClaudeQuotaFailure } from './claude-quota.js';
|
|
3
|
+
import { type AccountIdentity, type AccountUsageState, type ReactiveExhaustion, type UsageObservation } from './subscription-selection.js';
|
|
4
|
+
export type UsageKey = Pick<AccountIdentity, 'workspaceId' | 'accountId' | 'credentialVersion'>;
|
|
5
|
+
export type ProbeStatus = 'attempted' | 'ok' | 'unsupported' | 'unauthorized' | 'unavailable' | 'cancelled';
|
|
6
|
+
export interface ProbeObservation {
|
|
7
|
+
status: ProbeStatus;
|
|
8
|
+
observedAt: number;
|
|
9
|
+
usage?: UsageObservation;
|
|
10
|
+
}
|
|
11
|
+
export interface StoredAccountUsage {
|
|
12
|
+
passive?: {
|
|
13
|
+
rateLimitInfo: ClaudeRateLimitInfo;
|
|
14
|
+
observedAt: number;
|
|
15
|
+
};
|
|
16
|
+
probe?: ProbeObservation;
|
|
17
|
+
/** Account-level evidence intentionally survives credential rotations. */
|
|
18
|
+
reactive: readonly ReactiveExhaustion[];
|
|
19
|
+
}
|
|
20
|
+
/** #405 writes after each spawn; callers must handle store errors without falling back to ambient credentials. */
|
|
21
|
+
export interface UsageWriteOptions {
|
|
22
|
+
expireFutureObservations?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface SubscriptionUsageStore {
|
|
25
|
+
read(key: UsageKey): Promise<StoredAccountUsage>;
|
|
26
|
+
recordPassive(key: UsageKey, rateLimitInfo: ClaudeRateLimitInfo, observedAt: number, options?: UsageWriteOptions): Promise<void>;
|
|
27
|
+
recordQuota(key: UsageKey, quota: ClaudeQuotaFailure, observedAt: number, options?: UsageWriteOptions): Promise<void>;
|
|
28
|
+
/** Persist BEFORE network I/O; one attempt per credential version, including restart. */
|
|
29
|
+
beginProbe(key: UsageKey, observedAt: number): Promise<boolean>;
|
|
30
|
+
finishProbe(key: UsageKey, observation: ProbeObservation): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export declare function usageStateForSelection(state: StoredAccountUsage, now: number): AccountUsageState;
|
|
33
|
+
export declare class SubscriptionUsageStoreError extends Error {
|
|
34
|
+
constructor();
|
|
35
|
+
}
|
|
36
|
+
export declare function safeRateLimitInfo(info: unknown): ClaudeRateLimitInfo;
|
|
37
|
+
export declare function safeQuota(quota: ClaudeQuotaFailure): ClaudeQuotaFailure;
|
|
38
|
+
/** One file per version + one reactive file per workspace/account. No credentials or raw provider bodies. */
|
|
39
|
+
export declare class FileSubscriptionUsageStore implements SubscriptionUsageStore {
|
|
40
|
+
#private;
|
|
41
|
+
constructor(directory: string);
|
|
42
|
+
read(key: UsageKey): Promise<StoredAccountUsage>;
|
|
43
|
+
recordPassive(key: UsageKey, rateLimitInfo: ClaudeRateLimitInfo, observedAt: number, options?: UsageWriteOptions): Promise<void>;
|
|
44
|
+
recordQuota(key: UsageKey, quota: ClaudeQuotaFailure, observedAt: number, options?: UsageWriteOptions): Promise<void>;
|
|
45
|
+
beginProbe(key: UsageKey, observedAt: number): Promise<boolean>;
|
|
46
|
+
finishProbe(key: UsageKey, observation: ProbeObservation): Promise<void>;
|
|
47
|
+
}
|