@centerforagenticai/pi-multi-account 0.1.1
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/LICENSE +21 -0
- package/NOTICE +29 -0
- package/README.md +999 -0
- package/config/models/pi-multi-account.v1.json +32 -0
- package/config/subscription-plans.v1.json +122 -0
- package/package.json +76 -0
- package/packages/pi-anthropic-oauth/LICENSE +21 -0
- package/packages/pi-anthropic-oauth/package.json +54 -0
- package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
- package/packages/pi-anthropic-oauth/src/context.ts +116 -0
- package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
- package/packages/pi-anthropic-oauth/src/index.ts +37 -0
- package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
- package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
- package/packages/pi-antigravity/LICENSE +21 -0
- package/packages/pi-antigravity/package.json +77 -0
- package/packages/pi-antigravity/src/auth/index.ts +14 -0
- package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
- package/packages/pi-antigravity/src/client/client.ts +561 -0
- package/packages/pi-antigravity/src/client/index.ts +1 -0
- package/packages/pi-antigravity/src/context.ts +110 -0
- package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
- package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
- package/packages/pi-antigravity/src/image/image.ts +336 -0
- package/packages/pi-antigravity/src/image/index.ts +1 -0
- package/packages/pi-antigravity/src/index.ts +280 -0
- package/packages/pi-antigravity/src/models/discovery.ts +154 -0
- package/packages/pi-antigravity/src/models/grouping.ts +424 -0
- package/packages/pi-antigravity/src/models/index.ts +3 -0
- package/packages/pi-antigravity/src/models/models.ts +500 -0
- package/packages/pi-antigravity/src/stream/index.ts +1 -0
- package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
- package/packages/pi-antigravity/src/types/enums.ts +42 -0
- package/packages/pi-antigravity/src/types/index.ts +2 -0
- package/packages/pi-antigravity/src/types/types.ts +292 -0
- package/packages/pi-antigravity/src/usage/index.ts +1 -0
- package/packages/pi-antigravity/src/usage/usage.ts +416 -0
- package/packages/pi-antigravity/src/utils/http.ts +91 -0
- package/packages/pi-antigravity/src/utils/index.ts +3 -0
- package/packages/pi-antigravity/src/utils/security.ts +73 -0
- package/packages/pi-antigravity/src/utils/util.ts +132 -0
- package/scripts/multi-account.mjs +44 -0
- package/src/account-labels.ts +223 -0
- package/src/account-plan-assignment.ts +340 -0
- package/src/account-rate-history.ts +372 -0
- package/src/anthropic-adaptive-stream.ts +531 -0
- package/src/anthropic-alias-stream.ts +140 -0
- package/src/anthropic-context-compat.ts +80 -0
- package/src/api-pricing.ts +579 -0
- package/src/bounded-file-lines.ts +97 -0
- package/src/catalog-rebinding.ts +177 -0
- package/src/catalog-registration-probe.ts +111 -0
- package/src/codex-adapter.ts +345 -0
- package/src/codex-model-defaults.ts +785 -0
- package/src/command-completions.ts +404 -0
- package/src/commands.ts +2000 -0
- package/src/compaction.ts +14 -0
- package/src/config.ts +1317 -0
- package/src/continuation.ts +569 -0
- package/src/cooldowns.ts +110 -0
- package/src/cost-digest-store.ts +332 -0
- package/src/cost-digest.ts +1044 -0
- package/src/cost-history.ts +251 -0
- package/src/cost-period-closer.ts +160 -0
- package/src/cost-report-json.ts +318 -0
- package/src/cost-report-reader.ts +368 -0
- package/src/cost-report-render.ts +207 -0
- package/src/cost-report.ts +1104 -0
- package/src/coverage-attestation.ts +397 -0
- package/src/credential-lifecycle.ts +169 -0
- package/src/credential-refresh.ts +248 -0
- package/src/declaration-notice-marker.ts +238 -0
- package/src/diagnostic-store.ts +276 -0
- package/src/diagnostics.ts +309 -0
- package/src/discovery.ts +471 -0
- package/src/duration.ts +13 -0
- package/src/error-classification.ts +256 -0
- package/src/fuzzy.ts +15 -0
- package/src/group-policy.ts +81 -0
- package/src/history-store.ts +897 -0
- package/src/index.ts +5572 -0
- package/src/lifecycle.ts +378 -0
- package/src/logical-dispatch.ts +279 -0
- package/src/logical-model-selector.ts +254 -0
- package/src/logical-model-switcher.ts +430 -0
- package/src/logical-provider-attribution.ts +544 -0
- package/src/logical-provider.ts +1237 -0
- package/src/logical-route-indicator.ts +215 -0
- package/src/machine-lease.ts +445 -0
- package/src/model-support.ts +66 -0
- package/src/models-declaration.ts +1091 -0
- package/src/openai-adapter.ts +117 -0
- package/src/openrouter-budget.ts +304 -0
- package/src/openrouter-fallback.ts +146 -0
- package/src/period-boundaries.ts +376 -0
- package/src/pi-anthropic-oauth.d.ts +6 -0
- package/src/preflight.ts +253 -0
- package/src/pricing-cache.ts +235 -0
- package/src/project-identity.ts +100 -0
- package/src/provider-registration.ts +942 -0
- package/src/rate-formula.ts +163 -0
- package/src/recovery-engine.ts +853 -0
- package/src/recovery-output.ts +837 -0
- package/src/recovery-plan.ts +239 -0
- package/src/report-range.ts +203 -0
- package/src/route-resolver.ts +789 -0
- package/src/routing-config-transaction.ts +232 -0
- package/src/routing.ts +1163 -0
- package/src/runtime-state.ts +630 -0
- package/src/session-account-groups.ts +284 -0
- package/src/session-restore.ts +287 -0
- package/src/shared-usage.ts +1392 -0
- package/src/standalone-cli.ts +720 -0
- package/src/status-view.ts +578 -0
- package/src/subscription-plan-catalog.ts +346 -0
- package/src/tier-model-resolver.ts +46 -0
- package/src/upstream-anthropic.ts +315 -0
- package/src/upstream-antigravity.ts +327 -0
- package/src/usage-fetch.ts +1634 -0
- package/src/usage.ts +1026 -0
- package/src/vendor.ts +87 -0
- package/src/warmer.ts +231 -0
- package/src/watchdog.ts +219 -0
- package/src/window-history.ts +270 -0
package/src/usage.ts
ADDED
|
@@ -0,0 +1,1026 @@
|
|
|
1
|
+
import type { AllowedFamily } from "./config.js";
|
|
2
|
+
import { formatDuration } from "./duration.js";
|
|
3
|
+
import {
|
|
4
|
+
EXHAUSTION_HOLD_MS,
|
|
5
|
+
SHARED_USAGE_TTL_MS,
|
|
6
|
+
type SharedUsageStore,
|
|
7
|
+
type SharedUsageRateLimit,
|
|
8
|
+
type SharedUsageSnapshot,
|
|
9
|
+
} from "./shared-usage.js";
|
|
10
|
+
import { isCanonicalManagedProviderId } from "./runtime-state.js";
|
|
11
|
+
// The one exhaustion rule, imported rather than restated. A second definition
|
|
12
|
+
// here would be a second notion of exhaustion, free to drift from the one
|
|
13
|
+
// selection enforces -- the drift this defect class has already produced twice.
|
|
14
|
+
// `routing.ts` does not import this module, so this adds no cycle.
|
|
15
|
+
import { snapshotIndicatesExhaustion } from "./routing.js";
|
|
16
|
+
import {
|
|
17
|
+
iterateHistory,
|
|
18
|
+
type HistoryRecordEnvelope,
|
|
19
|
+
} from "./history-store.js";
|
|
20
|
+
import type { CostRecordPayload } from "./cost-history.js";
|
|
21
|
+
import {
|
|
22
|
+
readLatestWindowHistory,
|
|
23
|
+
readWindowHistory,
|
|
24
|
+
type WindowSample,
|
|
25
|
+
} from "./window-history.js";
|
|
26
|
+
import {
|
|
27
|
+
computeCoverageState,
|
|
28
|
+
type CoverageState,
|
|
29
|
+
} from "./coverage-attestation.js";
|
|
30
|
+
|
|
31
|
+
export interface TokenUsageObservation {
|
|
32
|
+
readonly inputTokens?: number;
|
|
33
|
+
readonly outputTokens?: number;
|
|
34
|
+
readonly cacheCreationInputTokens?: number;
|
|
35
|
+
readonly cacheReadInputTokens?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RateLimitObservation extends SharedUsageRateLimit {}
|
|
39
|
+
|
|
40
|
+
export interface UsageObservation {
|
|
41
|
+
readonly providerId: string;
|
|
42
|
+
readonly family: AllowedFamily;
|
|
43
|
+
readonly observedAtMs: number;
|
|
44
|
+
readonly tokens?: TokenUsageObservation;
|
|
45
|
+
readonly rateLimit?: RateLimitObservation;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface UsageSnapshot {
|
|
49
|
+
readonly providerId: string;
|
|
50
|
+
readonly family: AllowedFamily;
|
|
51
|
+
readonly snapshotAtMs: number;
|
|
52
|
+
/** Present only for a peer-only snapshot read from the machine store. */
|
|
53
|
+
readonly observationScope?: "session" | "fleet";
|
|
54
|
+
readonly observerId?: string;
|
|
55
|
+
readonly ageMs?: number;
|
|
56
|
+
/**
|
|
57
|
+
* True when this snapshot is older than the shared store's freshness window
|
|
58
|
+
* and was returned only so an operator surface can show a retained reading
|
|
59
|
+
* instead of claiming no data exists. Routing must not treat it as current.
|
|
60
|
+
*/
|
|
61
|
+
readonly stale?: boolean;
|
|
62
|
+
/** The latest fresh peer observation, retained alongside this session's data. */
|
|
63
|
+
readonly fleetObserved?: FleetUsageSnapshot;
|
|
64
|
+
readonly inputTokens: number;
|
|
65
|
+
readonly outputTokens: number;
|
|
66
|
+
readonly cacheCreationInputTokens: number;
|
|
67
|
+
readonly cacheReadInputTokens: number;
|
|
68
|
+
readonly remainingRequests?: number;
|
|
69
|
+
readonly remainingTokens?: number;
|
|
70
|
+
readonly recoveryAtMs?: number;
|
|
71
|
+
/**
|
|
72
|
+
* Largest `remainingRequests` seen for this account. Providers report what is
|
|
73
|
+
* LEFT but never the ceiling, so the peak is the only available proxy for a
|
|
74
|
+
* budget denominator. Monotonic within a window; a reset that reports a higher
|
|
75
|
+
* remaining count raises it.
|
|
76
|
+
*/
|
|
77
|
+
readonly observedPeakRequests?: number;
|
|
78
|
+
/** Largest `remainingTokens` seen for this account. See observedPeakRequests. */
|
|
79
|
+
readonly observedPeakTokens?: number;
|
|
80
|
+
readonly utilization?: number;
|
|
81
|
+
/** When utilization itself was observed; token-only updates do not advance it. */
|
|
82
|
+
readonly utilizationObservedAtMs?: number;
|
|
83
|
+
/** When the cohesive rate-limit observation was recorded. */
|
|
84
|
+
readonly quotaObservedAtMs?: number;
|
|
85
|
+
readonly utilizationSource?: RateLimitObservation["utilizationSource"];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface FleetUsageSnapshot {
|
|
89
|
+
readonly providerId: string;
|
|
90
|
+
readonly family: AllowedFamily;
|
|
91
|
+
readonly snapshotAtMs: number;
|
|
92
|
+
readonly ageMs: number;
|
|
93
|
+
readonly observerId: string;
|
|
94
|
+
readonly remainingRequests?: number;
|
|
95
|
+
readonly remainingTokens?: number;
|
|
96
|
+
readonly recoveryAtMs?: number;
|
|
97
|
+
readonly utilization?: number;
|
|
98
|
+
readonly utilizationSource?: RateLimitObservation["utilizationSource"];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type UsageQuotaProjection =
|
|
102
|
+
| Readonly<{
|
|
103
|
+
status: "fresh";
|
|
104
|
+
utilization?: number;
|
|
105
|
+
remainingRequests?: number;
|
|
106
|
+
remainingTokens?: number;
|
|
107
|
+
recoveryAtMs?: number;
|
|
108
|
+
}>
|
|
109
|
+
| Readonly<{ status: "stale" }>
|
|
110
|
+
| Readonly<{ status: "missing" }>;
|
|
111
|
+
|
|
112
|
+
type CohesiveQuotaObservation = Readonly<{
|
|
113
|
+
observedAtMs: number;
|
|
114
|
+
remainingRequests?: number;
|
|
115
|
+
remainingTokens?: number;
|
|
116
|
+
recoveryAtMs?: number;
|
|
117
|
+
utilization?: number;
|
|
118
|
+
utilizationSource?: RateLimitObservation["utilizationSource"];
|
|
119
|
+
}>;
|
|
120
|
+
|
|
121
|
+
type QuotaSelection = Readonly<{
|
|
122
|
+
projection: UsageQuotaProjection;
|
|
123
|
+
candidate?: CohesiveQuotaObservation;
|
|
124
|
+
}>;
|
|
125
|
+
|
|
126
|
+
const TOKEN_FIELDS = [
|
|
127
|
+
"inputTokens",
|
|
128
|
+
"outputTokens",
|
|
129
|
+
"cacheCreationInputTokens",
|
|
130
|
+
"cacheReadInputTokens",
|
|
131
|
+
] as const;
|
|
132
|
+
|
|
133
|
+
type TokenField = (typeof TOKEN_FIELDS)[number];
|
|
134
|
+
|
|
135
|
+
function count(value: number | undefined, name: string): number {
|
|
136
|
+
if (value === undefined) return 0;
|
|
137
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
138
|
+
throw new TypeError(`${name} must be a non-negative safe integer.`);
|
|
139
|
+
}
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function optionalCount(
|
|
144
|
+
value: number | undefined,
|
|
145
|
+
name: string,
|
|
146
|
+
): number | undefined {
|
|
147
|
+
if (value === undefined) return undefined;
|
|
148
|
+
return count(value, name);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function optionalTimestamp(value: number | undefined): number | undefined {
|
|
152
|
+
if (value === undefined) return undefined;
|
|
153
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
154
|
+
throw new TypeError(
|
|
155
|
+
"recoveryAtMs must be a finite non-negative timestamp.",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Retains the largest observed value, ignoring absent readings. */
|
|
162
|
+
function highWaterMark(
|
|
163
|
+
next: number | undefined,
|
|
164
|
+
previous: number | undefined,
|
|
165
|
+
): number | undefined {
|
|
166
|
+
if (next === undefined) return previous;
|
|
167
|
+
if (previous === undefined) return next;
|
|
168
|
+
return Math.max(next, previous);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function validDisplayUtilization(value: number | undefined): value is number {
|
|
172
|
+
return (
|
|
173
|
+
typeof value === "number" &&
|
|
174
|
+
Number.isFinite(value) &&
|
|
175
|
+
value >= 0 &&
|
|
176
|
+
value <= 1
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function validDisplayCount(value: number | undefined): value is number {
|
|
181
|
+
return Number.isSafeInteger(value) && (value as number) >= 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function validDisplayRecovery(value: number | undefined): value is number {
|
|
185
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function hasQuotaEvidence(candidate: CohesiveQuotaObservation): boolean {
|
|
189
|
+
return (
|
|
190
|
+
candidate.remainingRequests !== undefined ||
|
|
191
|
+
candidate.remainingTokens !== undefined ||
|
|
192
|
+
candidate.recoveryAtMs !== undefined ||
|
|
193
|
+
candidate.utilization !== undefined ||
|
|
194
|
+
candidate.utilizationSource !== undefined
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function compareOptionalCount(
|
|
199
|
+
left: number | undefined,
|
|
200
|
+
right: number | undefined,
|
|
201
|
+
): number {
|
|
202
|
+
const leftRanked = validDisplayCount(left);
|
|
203
|
+
const rightRanked = validDisplayCount(right);
|
|
204
|
+
if (leftRanked && !rightRanked) return -1;
|
|
205
|
+
if (!leftRanked && rightRanked) return 1;
|
|
206
|
+
if (!leftRanked || !rightRanked) return 0;
|
|
207
|
+
return left - right;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function selectDisplayCandidate(
|
|
211
|
+
candidates: readonly CohesiveQuotaObservation[],
|
|
212
|
+
): CohesiveQuotaObservation | undefined {
|
|
213
|
+
const utilizationCandidates = candidates.filter((candidate) =>
|
|
214
|
+
validDisplayUtilization(candidate.utilization),
|
|
215
|
+
);
|
|
216
|
+
if (utilizationCandidates.length > 0) {
|
|
217
|
+
return utilizationCandidates.reduce((selected, candidate) => {
|
|
218
|
+
if ((candidate.utilization ?? 0) > (selected.utilization ?? 0)) return candidate;
|
|
219
|
+
if (
|
|
220
|
+
candidate.utilization === selected.utilization &&
|
|
221
|
+
candidate.observedAtMs > selected.observedAtMs
|
|
222
|
+
) {
|
|
223
|
+
return candidate;
|
|
224
|
+
}
|
|
225
|
+
return selected;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const countCandidates = candidates.filter(
|
|
229
|
+
(candidate) =>
|
|
230
|
+
validDisplayCount(candidate.remainingRequests) ||
|
|
231
|
+
validDisplayCount(candidate.remainingTokens),
|
|
232
|
+
);
|
|
233
|
+
if (countCandidates.length === 0) return undefined;
|
|
234
|
+
return countCandidates.reduce((selected, candidate) => {
|
|
235
|
+
const requestOrder = compareOptionalCount(
|
|
236
|
+
candidate.remainingRequests,
|
|
237
|
+
selected.remainingRequests,
|
|
238
|
+
);
|
|
239
|
+
if (requestOrder < 0) return candidate;
|
|
240
|
+
if (requestOrder > 0) return selected;
|
|
241
|
+
const tokenOrder = compareOptionalCount(
|
|
242
|
+
candidate.remainingTokens,
|
|
243
|
+
selected.remainingTokens,
|
|
244
|
+
);
|
|
245
|
+
if (tokenOrder < 0) return candidate;
|
|
246
|
+
if (tokenOrder > 0) return selected;
|
|
247
|
+
return candidate.observedAtMs > selected.observedAtMs ? candidate : selected;
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function projectionFromCandidate(
|
|
252
|
+
candidate: CohesiveQuotaObservation,
|
|
253
|
+
): UsageQuotaProjection {
|
|
254
|
+
return {
|
|
255
|
+
status: "fresh",
|
|
256
|
+
...(validDisplayUtilization(candidate.utilization)
|
|
257
|
+
? { utilization: candidate.utilization }
|
|
258
|
+
: {}),
|
|
259
|
+
...(validDisplayCount(candidate.remainingRequests)
|
|
260
|
+
? { remainingRequests: candidate.remainingRequests }
|
|
261
|
+
: {}),
|
|
262
|
+
...(validDisplayCount(candidate.remainingTokens)
|
|
263
|
+
? { remainingTokens: candidate.remainingTokens }
|
|
264
|
+
: {}),
|
|
265
|
+
...(validDisplayRecovery(candidate.recoveryAtMs)
|
|
266
|
+
? { recoveryAtMs: candidate.recoveryAtMs }
|
|
267
|
+
: {}),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function selectQuotaProjection(
|
|
272
|
+
candidates: readonly CohesiveQuotaObservation[],
|
|
273
|
+
nowMs: number,
|
|
274
|
+
): QuotaSelection {
|
|
275
|
+
const quotaCandidates = candidates.filter(hasQuotaEvidence);
|
|
276
|
+
if (quotaCandidates.length === 0) return { projection: { status: "missing" } };
|
|
277
|
+
const freshCandidates = quotaCandidates.filter(
|
|
278
|
+
(candidate) => nowMs - candidate.observedAtMs <= SHARED_USAGE_TTL_MS,
|
|
279
|
+
);
|
|
280
|
+
const selected = selectDisplayCandidate(freshCandidates);
|
|
281
|
+
if (selected !== undefined) {
|
|
282
|
+
return { projection: projectionFromCandidate(selected), candidate: selected };
|
|
283
|
+
}
|
|
284
|
+
const retained = selectDisplayCandidate(quotaCandidates) ??
|
|
285
|
+
quotaCandidates.reduce((newest, candidate) =>
|
|
286
|
+
candidate.observedAtMs > newest.observedAtMs ? candidate : newest,
|
|
287
|
+
);
|
|
288
|
+
return { projection: { status: "stale" }, candidate: retained };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function quotaFromShared(
|
|
292
|
+
snapshot: SharedUsageSnapshot | undefined,
|
|
293
|
+
): CohesiveQuotaObservation | undefined {
|
|
294
|
+
if (snapshot === undefined) return undefined;
|
|
295
|
+
return {
|
|
296
|
+
observedAtMs: snapshot.snapshotAtMs,
|
|
297
|
+
...(snapshot.remainingRequests === undefined
|
|
298
|
+
? {}
|
|
299
|
+
: { remainingRequests: snapshot.remainingRequests }),
|
|
300
|
+
...(snapshot.remainingTokens === undefined
|
|
301
|
+
? {}
|
|
302
|
+
: { remainingTokens: snapshot.remainingTokens }),
|
|
303
|
+
...(snapshot.recoveryAtMs === undefined
|
|
304
|
+
? {}
|
|
305
|
+
: { recoveryAtMs: snapshot.recoveryAtMs }),
|
|
306
|
+
...(snapshot.utilization === undefined
|
|
307
|
+
? {}
|
|
308
|
+
: { utilization: snapshot.utilization }),
|
|
309
|
+
...(snapshot.utilizationSource === undefined
|
|
310
|
+
? {}
|
|
311
|
+
: { utilizationSource: snapshot.utilizationSource }),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function checkedTotal(
|
|
316
|
+
previous: number,
|
|
317
|
+
increment: number,
|
|
318
|
+
name: string,
|
|
319
|
+
): number {
|
|
320
|
+
const total = previous + increment;
|
|
321
|
+
if (!Number.isSafeInteger(total) || total < 0) {
|
|
322
|
+
throw new RangeError(
|
|
323
|
+
`${name} cumulative total exceeds safe integer range.`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
return total;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Process-local aggregate ledger; no per-turn or content-bearing records. */
|
|
330
|
+
export class UsageLedger {
|
|
331
|
+
readonly #snapshots = new Map<string, UsageSnapshot>();
|
|
332
|
+
readonly #quotaObservations = new Map<string, CohesiveQuotaObservation>();
|
|
333
|
+
/**
|
|
334
|
+
* Accounts whose exhaustion holds this process ignores, and from when.
|
|
335
|
+
*
|
|
336
|
+
* The operator's `clear` must reach every automatic path, and a durable
|
|
337
|
+
* exhaustion hold is one. But a hold is machine-global: deleting the record
|
|
338
|
+
* would revoke it for every other session on the machine, and those peers
|
|
339
|
+
* may still be refusing work on that account. So the override is
|
|
340
|
+
* PROCESS-LOCAL -- this session stops honouring holds installed up to the
|
|
341
|
+
* moment of the clear, and peers keep theirs.
|
|
342
|
+
*
|
|
343
|
+
* Stored as the clear time rather than a boolean so a LATER failure still
|
|
344
|
+
* installs a hold this session honours. A boolean would make one `clear`
|
|
345
|
+
* exempt the account from exhaustion holds for the life of the process,
|
|
346
|
+
* which is a permanent effect from a transient instruction.
|
|
347
|
+
*/
|
|
348
|
+
readonly #holdOverrides = new Map<string, number>();
|
|
349
|
+
readonly #sharedStore: SharedUsageStore | undefined;
|
|
350
|
+
readonly #now: () => number;
|
|
351
|
+
|
|
352
|
+
constructor(
|
|
353
|
+
options: {
|
|
354
|
+
readonly sharedStore?: SharedUsageStore;
|
|
355
|
+
readonly now?: () => number;
|
|
356
|
+
} = {},
|
|
357
|
+
) {
|
|
358
|
+
this.#sharedStore = options.sharedStore;
|
|
359
|
+
this.#now = options.now ?? Date.now;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
record(observation: UsageObservation): UsageSnapshot {
|
|
363
|
+
if (
|
|
364
|
+
!isCanonicalManagedProviderId(observation.providerId, observation.family)
|
|
365
|
+
) {
|
|
366
|
+
throw new TypeError(
|
|
367
|
+
"usage providerId must be canonical for its managed family.",
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
if (
|
|
371
|
+
!Number.isFinite(observation.observedAtMs) ||
|
|
372
|
+
observation.observedAtMs < 0
|
|
373
|
+
) {
|
|
374
|
+
throw new TypeError(
|
|
375
|
+
"observedAtMs must be a finite non-negative timestamp.",
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
const previous = this.#snapshots.get(observation.providerId);
|
|
379
|
+
if (previous && previous.family !== observation.family) {
|
|
380
|
+
throw new TypeError(
|
|
381
|
+
"provider family cannot change within a usage ledger.",
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const increments = Object.fromEntries(
|
|
386
|
+
TOKEN_FIELDS.map((field) => [
|
|
387
|
+
field,
|
|
388
|
+
count(observation.tokens?.[field], field),
|
|
389
|
+
]),
|
|
390
|
+
) as Record<TokenField, number>;
|
|
391
|
+
const remainingRequests = optionalCount(
|
|
392
|
+
observation.rateLimit?.remainingRequests,
|
|
393
|
+
"remainingRequests",
|
|
394
|
+
);
|
|
395
|
+
const remainingTokens = optionalCount(
|
|
396
|
+
observation.rateLimit?.remainingTokens,
|
|
397
|
+
"remainingTokens",
|
|
398
|
+
);
|
|
399
|
+
const recoveryAtMs = optionalTimestamp(observation.rateLimit?.recoveryAtMs);
|
|
400
|
+
const previousQuota = this.#quotaObservations.get(observation.providerId);
|
|
401
|
+
const acceptsQuota =
|
|
402
|
+
observation.rateLimit !== undefined &&
|
|
403
|
+
(previousQuota === undefined || observation.observedAtMs >= previousQuota.observedAtMs);
|
|
404
|
+
const nextQuota: CohesiveQuotaObservation | undefined = acceptsQuota
|
|
405
|
+
? Object.freeze({
|
|
406
|
+
observedAtMs: observation.observedAtMs,
|
|
407
|
+
...(remainingRequests === undefined ? {} : { remainingRequests }),
|
|
408
|
+
...(remainingTokens === undefined ? {} : { remainingTokens }),
|
|
409
|
+
...(recoveryAtMs === undefined ? {} : { recoveryAtMs }),
|
|
410
|
+
...(observation.rateLimit?.utilization === undefined
|
|
411
|
+
? {}
|
|
412
|
+
: { utilization: observation.rateLimit.utilization }),
|
|
413
|
+
...(observation.rateLimit?.utilizationSource === undefined
|
|
414
|
+
? {}
|
|
415
|
+
: { utilizationSource: observation.rateLimit.utilizationSource }),
|
|
416
|
+
})
|
|
417
|
+
: previousQuota;
|
|
418
|
+
const latestRemainingRequests = nextQuota?.remainingRequests;
|
|
419
|
+
const latestRemainingTokens = nextQuota?.remainingTokens;
|
|
420
|
+
const latestRecoveryAtMs = nextQuota?.recoveryAtMs;
|
|
421
|
+
const latestUtilization = nextQuota?.utilization;
|
|
422
|
+
const latestUtilizationSource = nextQuota?.utilizationSource;
|
|
423
|
+
const latestUtilizationObservedAtMs =
|
|
424
|
+
latestUtilization === undefined ? undefined : nextQuota?.observedAtMs;
|
|
425
|
+
const snapshotAtMs = Math.max(
|
|
426
|
+
observation.observedAtMs,
|
|
427
|
+
previous?.snapshotAtMs ?? observation.observedAtMs,
|
|
428
|
+
);
|
|
429
|
+
const peakRequests = highWaterMark(
|
|
430
|
+
latestRemainingRequests,
|
|
431
|
+
previous?.observedPeakRequests,
|
|
432
|
+
);
|
|
433
|
+
const peakTokens = highWaterMark(
|
|
434
|
+
latestRemainingTokens,
|
|
435
|
+
previous?.observedPeakTokens,
|
|
436
|
+
);
|
|
437
|
+
const totals = Object.fromEntries(
|
|
438
|
+
TOKEN_FIELDS.map((field) => [
|
|
439
|
+
field,
|
|
440
|
+
checkedTotal(previous?.[field] ?? 0, increments[field], field),
|
|
441
|
+
]),
|
|
442
|
+
) as Record<TokenField, number>;
|
|
443
|
+
|
|
444
|
+
const snapshot: UsageSnapshot = Object.freeze({
|
|
445
|
+
providerId: observation.providerId,
|
|
446
|
+
family: observation.family,
|
|
447
|
+
snapshotAtMs,
|
|
448
|
+
inputTokens: totals.inputTokens,
|
|
449
|
+
outputTokens: totals.outputTokens,
|
|
450
|
+
cacheCreationInputTokens: totals.cacheCreationInputTokens,
|
|
451
|
+
cacheReadInputTokens: totals.cacheReadInputTokens,
|
|
452
|
+
...(latestRemainingRequests === undefined
|
|
453
|
+
? {}
|
|
454
|
+
: { remainingRequests: latestRemainingRequests }),
|
|
455
|
+
...(latestRemainingTokens === undefined
|
|
456
|
+
? {}
|
|
457
|
+
: { remainingTokens: latestRemainingTokens }),
|
|
458
|
+
...(latestRecoveryAtMs === undefined
|
|
459
|
+
? {}
|
|
460
|
+
: { recoveryAtMs: latestRecoveryAtMs }),
|
|
461
|
+
...(peakRequests === undefined
|
|
462
|
+
? {}
|
|
463
|
+
: { observedPeakRequests: peakRequests }),
|
|
464
|
+
...(peakTokens === undefined ? {} : { observedPeakTokens: peakTokens }),
|
|
465
|
+
...(latestUtilization === undefined
|
|
466
|
+
? {}
|
|
467
|
+
: { utilization: latestUtilization }),
|
|
468
|
+
...(latestUtilizationObservedAtMs === undefined
|
|
469
|
+
? {}
|
|
470
|
+
: { utilizationObservedAtMs: latestUtilizationObservedAtMs }),
|
|
471
|
+
...(nextQuota === undefined
|
|
472
|
+
? {}
|
|
473
|
+
: { quotaObservedAtMs: nextQuota.observedAtMs }),
|
|
474
|
+
...(latestUtilizationSource === undefined
|
|
475
|
+
? {}
|
|
476
|
+
: { utilizationSource: latestUtilizationSource }),
|
|
477
|
+
});
|
|
478
|
+
this.#snapshots.set(observation.providerId, snapshot);
|
|
479
|
+
if (nextQuota !== undefined) {
|
|
480
|
+
this.#quotaObservations.set(observation.providerId, nextQuota);
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
this.#sharedStore?.append({
|
|
484
|
+
providerId: observation.providerId,
|
|
485
|
+
family: observation.family,
|
|
486
|
+
observedAtMs: observation.observedAtMs,
|
|
487
|
+
observerId: this.#sharedStore.observerId,
|
|
488
|
+
...(observation.tokens === undefined
|
|
489
|
+
? {}
|
|
490
|
+
: { tokens: observation.tokens }),
|
|
491
|
+
...(observation.rateLimit === undefined
|
|
492
|
+
? {}
|
|
493
|
+
: { rateLimit: observation.rateLimit }),
|
|
494
|
+
});
|
|
495
|
+
} catch {
|
|
496
|
+
// Shared state is an optimization; a failed append never fails a turn.
|
|
497
|
+
}
|
|
498
|
+
return snapshot;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
#quotaSelection(
|
|
502
|
+
providerId: string,
|
|
503
|
+
family: AllowedFamily,
|
|
504
|
+
nowMs: number,
|
|
505
|
+
): QuotaSelection {
|
|
506
|
+
const aggregate = this.#sharedStore?.aggregate(providerId, family, nowMs);
|
|
507
|
+
const candidates = [
|
|
508
|
+
this.#quotaObservations.get(providerId),
|
|
509
|
+
quotaFromShared(aggregate?.session),
|
|
510
|
+
quotaFromShared(aggregate?.fleet),
|
|
511
|
+
quotaFromShared(aggregate?.stale),
|
|
512
|
+
].filter((candidate): candidate is CohesiveQuotaObservation => candidate !== undefined);
|
|
513
|
+
return selectQuotaProjection(candidates, nowMs);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
quotaDisplay(providerId: string, nowMs = this.#now()): UsageQuotaProjection {
|
|
517
|
+
if (!Number.isFinite(nowMs) || nowMs < 0) return { status: "missing" };
|
|
518
|
+
const family = this.#snapshots.get(providerId)?.family ?? this.#familyForProvider(providerId);
|
|
519
|
+
if (family === undefined) return { status: "missing" };
|
|
520
|
+
return this.#quotaSelection(providerId, family, nowMs).projection;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
hasFreshHeaderObservation(
|
|
524
|
+
providerId: string,
|
|
525
|
+
family: AllowedFamily,
|
|
526
|
+
nowMs = this.#now(),
|
|
527
|
+
): boolean {
|
|
528
|
+
if (!Number.isFinite(nowMs) || nowMs < 0) return false;
|
|
529
|
+
const aggregate = this.#sharedStore?.aggregate(providerId, family, nowMs);
|
|
530
|
+
const candidates = [
|
|
531
|
+
this.#quotaObservations.get(providerId),
|
|
532
|
+
quotaFromShared(aggregate?.session),
|
|
533
|
+
quotaFromShared(aggregate?.fleet),
|
|
534
|
+
].filter((candidate): candidate is CohesiveQuotaObservation => candidate !== undefined);
|
|
535
|
+
return candidates.some(
|
|
536
|
+
(candidate) =>
|
|
537
|
+
candidate.utilizationSource === "rate-limit-header" &&
|
|
538
|
+
nowMs - candidate.observedAtMs <= SHARED_USAGE_TTL_MS,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
get(providerId: string): UsageSnapshot | undefined {
|
|
543
|
+
const local = this.#snapshots.get(providerId);
|
|
544
|
+
const family = local?.family ?? this.#familyForProvider(providerId);
|
|
545
|
+
if (family === undefined) return local;
|
|
546
|
+
if (this.#sharedStore === undefined && !this.#quotaObservations.has(providerId)) {
|
|
547
|
+
return local;
|
|
548
|
+
}
|
|
549
|
+
const nowMs = this.#now();
|
|
550
|
+
const aggregate = this.#sharedStore?.aggregate(providerId, family, nowMs);
|
|
551
|
+
const sharedBase = aggregate?.session ?? aggregate?.fleet ?? aggregate?.stale;
|
|
552
|
+
if (local === undefined && sharedBase === undefined) return undefined;
|
|
553
|
+
|
|
554
|
+
const source: UsageSnapshot = local ?? {
|
|
555
|
+
providerId: sharedBase!.providerId,
|
|
556
|
+
family: sharedBase!.family,
|
|
557
|
+
snapshotAtMs: sharedBase!.snapshotAtMs,
|
|
558
|
+
observationScope: aggregate?.session === sharedBase ? "session" : "fleet",
|
|
559
|
+
observerId: sharedBase!.observerId,
|
|
560
|
+
ageMs: sharedBase!.ageMs,
|
|
561
|
+
inputTokens: sharedBase!.inputTokens,
|
|
562
|
+
outputTokens: sharedBase!.outputTokens,
|
|
563
|
+
cacheCreationInputTokens: sharedBase!.cacheCreationInputTokens,
|
|
564
|
+
cacheReadInputTokens: sharedBase!.cacheReadInputTokens,
|
|
565
|
+
};
|
|
566
|
+
const {
|
|
567
|
+
remainingRequests: _remainingRequests,
|
|
568
|
+
remainingTokens: _remainingTokens,
|
|
569
|
+
recoveryAtMs: _recoveryAtMs,
|
|
570
|
+
utilization: _utilization,
|
|
571
|
+
utilizationObservedAtMs: _utilizationObservedAtMs,
|
|
572
|
+
quotaObservedAtMs: _quotaObservedAtMs,
|
|
573
|
+
utilizationSource: _utilizationSource,
|
|
574
|
+
stale: _stale,
|
|
575
|
+
fleetObserved: _fleetObserved,
|
|
576
|
+
...base
|
|
577
|
+
} = source;
|
|
578
|
+
const selection = this.#quotaSelection(providerId, family, nowMs);
|
|
579
|
+
const candidate = selection.candidate;
|
|
580
|
+
const fleet = aggregate?.fleet;
|
|
581
|
+
return {
|
|
582
|
+
...base,
|
|
583
|
+
...(selection.projection.status === "stale" ? { stale: true } : {}),
|
|
584
|
+
...(candidate === undefined
|
|
585
|
+
? {}
|
|
586
|
+
: {
|
|
587
|
+
quotaObservedAtMs: candidate.observedAtMs,
|
|
588
|
+
...(validDisplayCount(candidate.remainingRequests)
|
|
589
|
+
? { remainingRequests: candidate.remainingRequests }
|
|
590
|
+
: {}),
|
|
591
|
+
...(validDisplayCount(candidate.remainingTokens)
|
|
592
|
+
? { remainingTokens: candidate.remainingTokens }
|
|
593
|
+
: {}),
|
|
594
|
+
...(validDisplayRecovery(candidate.recoveryAtMs)
|
|
595
|
+
? { recoveryAtMs: candidate.recoveryAtMs }
|
|
596
|
+
: {}),
|
|
597
|
+
...(validDisplayUtilization(candidate.utilization)
|
|
598
|
+
? {
|
|
599
|
+
utilization: candidate.utilization,
|
|
600
|
+
utilizationObservedAtMs: candidate.observedAtMs,
|
|
601
|
+
}
|
|
602
|
+
: {}),
|
|
603
|
+
...(candidate.utilizationSource === undefined
|
|
604
|
+
? {}
|
|
605
|
+
: { utilizationSource: candidate.utilizationSource }),
|
|
606
|
+
}),
|
|
607
|
+
...(local === undefined || fleet === undefined
|
|
608
|
+
? {}
|
|
609
|
+
: {
|
|
610
|
+
fleetObserved: {
|
|
611
|
+
providerId: fleet.providerId,
|
|
612
|
+
family: fleet.family,
|
|
613
|
+
snapshotAtMs: fleet.snapshotAtMs,
|
|
614
|
+
ageMs: fleet.ageMs,
|
|
615
|
+
observerId: fleet.observerId,
|
|
616
|
+
...(fleet.remainingRequests === undefined
|
|
617
|
+
? {}
|
|
618
|
+
: { remainingRequests: fleet.remainingRequests }),
|
|
619
|
+
...(fleet.remainingTokens === undefined
|
|
620
|
+
? {}
|
|
621
|
+
: { remainingTokens: fleet.remainingTokens }),
|
|
622
|
+
...(fleet.recoveryAtMs === undefined
|
|
623
|
+
? {}
|
|
624
|
+
: { recoveryAtMs: fleet.recoveryAtMs }),
|
|
625
|
+
...(fleet.utilization === undefined
|
|
626
|
+
? {}
|
|
627
|
+
: { utilization: fleet.utilization }),
|
|
628
|
+
...(fleet.utilizationSource === undefined
|
|
629
|
+
? {}
|
|
630
|
+
: { utilizationSource: fleet.utilizationSource }),
|
|
631
|
+
},
|
|
632
|
+
}),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* The usage reading ROUTING must consult before it spends a turn.
|
|
638
|
+
*
|
|
639
|
+
* Routing used to read a `fleetUsage` method that returned
|
|
640
|
+
* `aggregate().fleet`, which is peer-only by definition, so a session could
|
|
641
|
+
* not avoid the account it had itself just measured as spent: it polled the
|
|
642
|
+
* usage endpoint, recorded utilization 1 with a recovery three days out, and
|
|
643
|
+
* routed the next turn straight back to that account because its own reading
|
|
644
|
+
* was invisible to selection. Observed live on `openai-codex`, twice in
|
|
645
|
+
* ninety seconds, while the operator surface showed the truth at the same
|
|
646
|
+
* instant (#97). That method is gone; this is its replacement.
|
|
647
|
+
*
|
|
648
|
+
* NEITHER OBSERVER WINS BY POSITION. `session ?? fleet` would close that
|
|
649
|
+
* defect and open its mirror image: a healthy self-reading would hide a
|
|
650
|
+
* fresh peer reading that says the account is spent, which is a route the
|
|
651
|
+
* old peer-only read would have refused. `fleet ?? session` restores the
|
|
652
|
+
* original defect. There is no safe positional order, so the two fresh readings are
|
|
653
|
+
* arbitrated by WHAT THEY SAY: whichever reports exhaustion is believed.
|
|
654
|
+
*
|
|
655
|
+
* That follows the same instinct as {@link selectDisplayCandidate}, which
|
|
656
|
+
* prefers the most exhausted fresh reading rather than the closest one --
|
|
657
|
+
* added after a live account showed `0% used` from a local response while
|
|
658
|
+
* its fresh endpoint record said `0% left`. Routing deserves at least the
|
|
659
|
+
* caution the display already has.
|
|
660
|
+
*
|
|
661
|
+
* NOT the same rule, though, and the difference is deliberate.
|
|
662
|
+
* `selectDisplayCandidate` ranks by utilization and ignores count-only
|
|
663
|
+
* candidates whenever any utilization candidate exists, because it is
|
|
664
|
+
* choosing one number to show. This asks a yes-or-no question through
|
|
665
|
+
* `snapshotIndicatesExhaustion`, for which a remaining count of zero is
|
|
666
|
+
* exhaustion just as much as a utilization of 1. A reading the display would
|
|
667
|
+
* pass over can still decide a route.
|
|
668
|
+
*
|
|
669
|
+
* `durableExhaustion` outranks both: it is the only element that survives
|
|
670
|
+
* the TTL, and a fresher token-only record must not displace a known future
|
|
671
|
+
* recovery.
|
|
672
|
+
*
|
|
673
|
+
* `stale` is deliberately excluded. Beyond a durable recovery time a
|
|
674
|
+
* retained reading is presentational; letting an hour-old token count gate
|
|
675
|
+
* routing would be a new defect rather than a fix.
|
|
676
|
+
*/
|
|
677
|
+
routingUsage(
|
|
678
|
+
providerId: string,
|
|
679
|
+
family: AllowedFamily,
|
|
680
|
+
nowMs = this.#now(),
|
|
681
|
+
): SharedUsageSnapshot | undefined {
|
|
682
|
+
const aggregate = this.#sharedStore?.aggregate(providerId, family, nowMs);
|
|
683
|
+
if (aggregate?.durableExhaustion !== undefined) {
|
|
684
|
+
return aggregate.durableExhaustion;
|
|
685
|
+
}
|
|
686
|
+
const session = aggregate?.session;
|
|
687
|
+
const fleet = aggregate?.fleet;
|
|
688
|
+
if (session === undefined) return fleet;
|
|
689
|
+
if (fleet === undefined) return session;
|
|
690
|
+
// Exhaustion is the claim worth acting on, so a reading that reports it
|
|
691
|
+
// is believed over one that does not, whoever observed it. With both or
|
|
692
|
+
// neither exhausted, our own reading is the more direct evidence.
|
|
693
|
+
if (
|
|
694
|
+
snapshotIndicatesExhaustion(fleet, nowMs, "all-observed") &&
|
|
695
|
+
!snapshotIndicatesExhaustion(session, nowMs, "all-observed")
|
|
696
|
+
) {
|
|
697
|
+
return fleet;
|
|
698
|
+
}
|
|
699
|
+
return session;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* End of an active exhaustion hold for this account, or undefined.
|
|
704
|
+
*
|
|
705
|
+
* Deliberately separate from `routingUsage`. A hold is not a usage reading
|
|
706
|
+
* and must not compete with one for selection: it says "requests were being
|
|
707
|
+
* refused recently" while a snapshot says "this is what the window looked
|
|
708
|
+
* like". Routing takes the later of this and any authoritative recovery
|
|
709
|
+
* time, so returning it alongside the snapshot keeps both facts intact
|
|
710
|
+
* rather than having one silently win.
|
|
711
|
+
*/
|
|
712
|
+
activeExhaustionHoldUntilMs(
|
|
713
|
+
providerId: string,
|
|
714
|
+
family: AllowedFamily,
|
|
715
|
+
nowMs = this.#now(),
|
|
716
|
+
): number | undefined {
|
|
717
|
+
const holdUntilMs = this.#sharedStore?.activeExhaustionHoldUntilMs(
|
|
718
|
+
providerId,
|
|
719
|
+
family,
|
|
720
|
+
nowMs,
|
|
721
|
+
);
|
|
722
|
+
if (holdUntilMs === undefined) return undefined;
|
|
723
|
+
// An operator `clear` in THIS process stops us honouring holds that were
|
|
724
|
+
// already installed when it ran. A hold from a LATER failure is honoured
|
|
725
|
+
// again: the override is a point in time, not a permanent exemption.
|
|
726
|
+
// Peers keep honouring the record either way, because it is not deleted.
|
|
727
|
+
//
|
|
728
|
+
// `holdUntilMs - EXHAUSTION_HOLD_MS` recovers the failure time from the
|
|
729
|
+
// reported deadline without a second store read. The writer always sets
|
|
730
|
+
// exactly that span (asserted by the hold-duration test), and the
|
|
731
|
+
// store's own read bound refuses any record claiming more.
|
|
732
|
+
const clearedAtMs = this.#holdOverrides.get(providerId);
|
|
733
|
+
if (
|
|
734
|
+
clearedAtMs !== undefined &&
|
|
735
|
+
holdUntilMs - EXHAUSTION_HOLD_MS <= clearedAtMs
|
|
736
|
+
) {
|
|
737
|
+
return undefined;
|
|
738
|
+
}
|
|
739
|
+
return holdUntilMs;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Installs a bounded hold after the provider refused a request for quota or
|
|
744
|
+
* rate-limit reasons.
|
|
745
|
+
*
|
|
746
|
+
* Called at the accepted terminal classification rather than at settlement,
|
|
747
|
+
* so the hold is visible to peers as soon as the failure is known.
|
|
748
|
+
*
|
|
749
|
+
* ONLY for a refusal that carries no recovery time. When the provider says
|
|
750
|
+
* when it will recover -- `retry-after`, or a reset timestamp -- that is
|
|
751
|
+
* authoritative and a 60-minute guess must not override it. A `retry-after: 5`
|
|
752
|
+
* response means five seconds, and holding that account for an hour would
|
|
753
|
+
* shrink the usable fleet on a refusal the provider already told us was
|
|
754
|
+
* momentary. The hold exists for the silent case, where nothing says when to
|
|
755
|
+
* come back.
|
|
756
|
+
*
|
|
757
|
+
* Fail-soft like every other shared-store write: a hold that cannot be
|
|
758
|
+
* persisted degrades to today's behaviour, and must never fail the turn that
|
|
759
|
+
* discovered the exhaustion.
|
|
760
|
+
*/
|
|
761
|
+
recordExhaustionHold(
|
|
762
|
+
providerId: string,
|
|
763
|
+
family: AllowedFamily,
|
|
764
|
+
failedAtMs: number,
|
|
765
|
+
): void {
|
|
766
|
+
try {
|
|
767
|
+
this.#sharedStore?.append({
|
|
768
|
+
recordType: "usage-exhaustion-hold",
|
|
769
|
+
tokens: null,
|
|
770
|
+
providerId,
|
|
771
|
+
family,
|
|
772
|
+
observedAtMs: failedAtMs,
|
|
773
|
+
observerId: this.#sharedStore.observerId,
|
|
774
|
+
failedAtMs,
|
|
775
|
+
holdUntilMs: failedAtMs + EXHAUSTION_HOLD_MS,
|
|
776
|
+
});
|
|
777
|
+
} catch {
|
|
778
|
+
// Shared state is an optimization; a failed append never fails a turn.
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
#familyForProvider(providerId: string): AllowedFamily | undefined {
|
|
783
|
+
if (isCanonicalManagedProviderId(providerId, "anthropic"))
|
|
784
|
+
return "anthropic";
|
|
785
|
+
if (isCanonicalManagedProviderId(providerId, "openai-codex"))
|
|
786
|
+
return "openai-codex";
|
|
787
|
+
// Without this, `get()` on an account this process never itself recorded
|
|
788
|
+
// (a peer process's antigravity slot, or a cold read before this process's
|
|
789
|
+
// first poll) fell through to `return local` at line ~545 while `local` was
|
|
790
|
+
// still undefined -- silently hiding real shared/fleet quota data rather
|
|
791
|
+
// than reading it, for this family alone.
|
|
792
|
+
if (isCanonicalManagedProviderId(providerId, "google-antigravity"))
|
|
793
|
+
return "google-antigravity";
|
|
794
|
+
return undefined;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
snapshots(): readonly UsageSnapshot[] {
|
|
798
|
+
return [...this.#snapshots.values()];
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Clears process-local usage state for an account, or every account.
|
|
803
|
+
*
|
|
804
|
+
* Also stops this process honouring exhaustion holds already installed for
|
|
805
|
+
* those accounts. The shared records are deliberately NOT deleted: they are
|
|
806
|
+
* machine-global, and peers may still be taking refusals on that account.
|
|
807
|
+
* An operator clearing state in one session should not silently revoke a
|
|
808
|
+
* hold another session is relying on.
|
|
809
|
+
*/
|
|
810
|
+
clear(providerId?: string, nowMs = this.#now()): void {
|
|
811
|
+
if (providerId === undefined) {
|
|
812
|
+
this.#snapshots.clear();
|
|
813
|
+
this.#quotaObservations.clear();
|
|
814
|
+
for (const id of this.#sharedStore
|
|
815
|
+
?.readExhaustionHolds()
|
|
816
|
+
.map((hold) => hold.providerId) ?? []) {
|
|
817
|
+
this.#holdOverrides.set(id, nowMs);
|
|
818
|
+
}
|
|
819
|
+
} else {
|
|
820
|
+
this.#snapshots.delete(providerId);
|
|
821
|
+
this.#quotaObservations.delete(providerId);
|
|
822
|
+
this.#holdOverrides.set(providerId, nowMs);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export function projectUsageQuota(
|
|
828
|
+
snapshot: UsageSnapshot | undefined,
|
|
829
|
+
nowMs: number,
|
|
830
|
+
): UsageQuotaProjection {
|
|
831
|
+
if (snapshot === undefined || !Number.isFinite(nowMs) || nowMs < 0) {
|
|
832
|
+
return { status: "missing" };
|
|
833
|
+
}
|
|
834
|
+
const hasEvidence =
|
|
835
|
+
snapshot.remainingRequests !== undefined ||
|
|
836
|
+
snapshot.remainingTokens !== undefined ||
|
|
837
|
+
snapshot.recoveryAtMs !== undefined ||
|
|
838
|
+
snapshot.utilization !== undefined ||
|
|
839
|
+
snapshot.utilizationSource !== undefined;
|
|
840
|
+
if (!hasEvidence) return { status: "missing" };
|
|
841
|
+
const observedAtMs =
|
|
842
|
+
snapshot.quotaObservedAtMs ??
|
|
843
|
+
snapshot.utilizationObservedAtMs ??
|
|
844
|
+
snapshot.snapshotAtMs;
|
|
845
|
+
if (snapshot.stale === true || nowMs - observedAtMs > SHARED_USAGE_TTL_MS) {
|
|
846
|
+
return { status: "stale" };
|
|
847
|
+
}
|
|
848
|
+
const candidate: CohesiveQuotaObservation = {
|
|
849
|
+
observedAtMs,
|
|
850
|
+
...(snapshot.remainingRequests === undefined
|
|
851
|
+
? {}
|
|
852
|
+
: { remainingRequests: snapshot.remainingRequests }),
|
|
853
|
+
...(snapshot.remainingTokens === undefined
|
|
854
|
+
? {}
|
|
855
|
+
: { remainingTokens: snapshot.remainingTokens }),
|
|
856
|
+
...(snapshot.recoveryAtMs === undefined
|
|
857
|
+
? {}
|
|
858
|
+
: { recoveryAtMs: snapshot.recoveryAtMs }),
|
|
859
|
+
...(snapshot.utilization === undefined
|
|
860
|
+
? {}
|
|
861
|
+
: { utilization: snapshot.utilization }),
|
|
862
|
+
...(snapshot.utilizationSource === undefined
|
|
863
|
+
? {}
|
|
864
|
+
: { utilizationSource: snapshot.utilizationSource }),
|
|
865
|
+
};
|
|
866
|
+
return selectDisplayCandidate([candidate]) === undefined
|
|
867
|
+
? { status: "stale" }
|
|
868
|
+
: projectionFromCandidate(candidate);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
export function formatUsageSnapshot(
|
|
872
|
+
snapshot: UsageSnapshot,
|
|
873
|
+
nowMs: number,
|
|
874
|
+
): string {
|
|
875
|
+
const parts = [
|
|
876
|
+
`${snapshot.providerId} — ${snapshot.inputTokens} input, ${snapshot.outputTokens} output`,
|
|
877
|
+
`${snapshot.cacheCreationInputTokens} cache-create, ${snapshot.cacheReadInputTokens} cache-read`,
|
|
878
|
+
];
|
|
879
|
+
if (snapshot.remainingRequests !== undefined) {
|
|
880
|
+
parts.push(`${snapshot.remainingRequests} requests remaining`);
|
|
881
|
+
}
|
|
882
|
+
if (snapshot.remainingTokens !== undefined) {
|
|
883
|
+
parts.push(`${snapshot.remainingTokens} tokens remaining`);
|
|
884
|
+
}
|
|
885
|
+
if (snapshot.recoveryAtMs !== undefined) {
|
|
886
|
+
parts.push(`recovers in ${formatDuration(snapshot.recoveryAtMs - nowMs)}`);
|
|
887
|
+
}
|
|
888
|
+
const hasQuota =
|
|
889
|
+
snapshot.remainingRequests !== undefined ||
|
|
890
|
+
snapshot.remainingTokens !== undefined ||
|
|
891
|
+
snapshot.recoveryAtMs !== undefined ||
|
|
892
|
+
snapshot.utilization !== undefined ||
|
|
893
|
+
snapshot.utilizationSource !== undefined;
|
|
894
|
+
if (hasQuota) {
|
|
895
|
+
const observedAtMs =
|
|
896
|
+
snapshot.quotaObservedAtMs ??
|
|
897
|
+
snapshot.utilizationObservedAtMs ??
|
|
898
|
+
snapshot.snapshotAtMs;
|
|
899
|
+
const ageMs = Math.max(0, nowMs - observedAtMs);
|
|
900
|
+
const stale = snapshot.stale === true || ageMs > SHARED_USAGE_TTL_MS;
|
|
901
|
+
parts.push(
|
|
902
|
+
`quota observed ${formatDuration(ageMs)} ago${stale ? " (stale)" : ""}`,
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
return parts.join("; ");
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Cost record with observation timestamp for aggregation.
|
|
909
|
+
* REQ-COST-INCOMPLETE: preserves status classification.
|
|
910
|
+
*/
|
|
911
|
+
export interface CostRecordWithTimestamp extends CostRecordPayload {
|
|
912
|
+
readonly observedAtMs: number;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Read all cost records from the history store.
|
|
917
|
+
* REQ-COST-INCOMPLETE: separates complete, partial, and unpriced records.
|
|
918
|
+
*/
|
|
919
|
+
function* iterateCostHistory(options?: {
|
|
920
|
+
readonly costHistoryPath?: string;
|
|
921
|
+
}): Generator<CostRecordWithTimestamp> {
|
|
922
|
+
for (const record of iterateHistory("cost-delta", options)) {
|
|
923
|
+
if (record.recordType !== "cost-delta") continue;
|
|
924
|
+
const envelope = record as HistoryRecordEnvelope;
|
|
925
|
+
if (typeof envelope.payload !== "object" || envelope.payload === null) {
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
const payload = envelope.payload as CostRecordPayload;
|
|
929
|
+
if (typeof payload.status !== "string") continue;
|
|
930
|
+
yield { ...payload, observedAtMs: envelope.observedAtMs };
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
export function readCostHistory(options?: {
|
|
935
|
+
readonly costHistoryPath?: string;
|
|
936
|
+
}): readonly CostRecordWithTimestamp[] {
|
|
937
|
+
return [...iterateCostHistory(options)];
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Aggregate cost records for a period.
|
|
942
|
+
* REQ-COST-INCOMPLETE: only `complete` records contribute to the principal aggregate.
|
|
943
|
+
* REQ-COVERAGE-STATE: returns coverage state for the requested period.
|
|
944
|
+
*/
|
|
945
|
+
export interface CostAggregate {
|
|
946
|
+
readonly coverageState: CoverageState;
|
|
947
|
+
readonly periodStartMs: number;
|
|
948
|
+
readonly periodEndMs: number;
|
|
949
|
+
/** Sum of `complete` records only. */
|
|
950
|
+
readonly completeTotal: number;
|
|
951
|
+
/** Count of `partial` records (excluded from total). */
|
|
952
|
+
readonly partialCount: number;
|
|
953
|
+
/** Count of `unpriced` records (excluded from total). */
|
|
954
|
+
readonly unpricedCount: number;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
export function computeCostAggregate(
|
|
958
|
+
periodStartMs: number,
|
|
959
|
+
periodEndMs: number,
|
|
960
|
+
extensionDir?: string,
|
|
961
|
+
): CostAggregate {
|
|
962
|
+
const coverageResult = computeCoverageState(
|
|
963
|
+
periodStartMs,
|
|
964
|
+
periodEndMs,
|
|
965
|
+
extensionDir,
|
|
966
|
+
);
|
|
967
|
+
const records = iterateCostHistory(
|
|
968
|
+
extensionDir
|
|
969
|
+
? { costHistoryPath: `${extensionDir}/cost-history.ndjson` }
|
|
970
|
+
: undefined,
|
|
971
|
+
);
|
|
972
|
+
|
|
973
|
+
let completeTotal = 0;
|
|
974
|
+
let partialCount = 0;
|
|
975
|
+
let unpricedCount = 0;
|
|
976
|
+
|
|
977
|
+
for (const record of records) {
|
|
978
|
+
if (
|
|
979
|
+
record.observedAtMs < periodStartMs ||
|
|
980
|
+
record.observedAtMs > periodEndMs
|
|
981
|
+
)
|
|
982
|
+
continue;
|
|
983
|
+
|
|
984
|
+
if (record.status === "complete") {
|
|
985
|
+
completeTotal += record.cost.total;
|
|
986
|
+
} else if (record.status === "partial") {
|
|
987
|
+
partialCount++;
|
|
988
|
+
} else if (record.status === "unpriced") {
|
|
989
|
+
unpricedCount++;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
return {
|
|
994
|
+
coverageState: coverageResult.state,
|
|
995
|
+
periodStartMs,
|
|
996
|
+
periodEndMs,
|
|
997
|
+
completeTotal,
|
|
998
|
+
partialCount,
|
|
999
|
+
unpricedCount,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Expose window samples for status rendering.
|
|
1005
|
+
* REQ-WINDOW-MULTI: returns all windows with their identities.
|
|
1006
|
+
*/
|
|
1007
|
+
export function getWindowSamples(
|
|
1008
|
+
extensionDir?: string,
|
|
1009
|
+
): readonly WindowSample[] {
|
|
1010
|
+
return readWindowHistory(
|
|
1011
|
+
extensionDir
|
|
1012
|
+
? { windowHistoryPath: `${extensionDir}/window-history.ndjson` }
|
|
1013
|
+
: undefined,
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** Fold retained status data to one latest sample per provider/window identity. */
|
|
1018
|
+
export function getLatestWindowSamples(
|
|
1019
|
+
extensionDir?: string,
|
|
1020
|
+
): ReadonlyMap<string, readonly WindowSample[]> {
|
|
1021
|
+
return readLatestWindowHistory(
|
|
1022
|
+
extensionDir
|
|
1023
|
+
? { windowHistoryPath: `${extensionDir}/window-history.ndjson` }
|
|
1024
|
+
: undefined,
|
|
1025
|
+
);
|
|
1026
|
+
}
|