offrouter-core 0.2.0 → 0.2.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/audit.d.ts +137 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +320 -0
- package/dist/audit.js.map +1 -0
- package/dist/auth/credential-chain.d.ts +84 -0
- package/dist/auth/credential-chain.d.ts.map +1 -0
- package/dist/auth/credential-chain.js +150 -0
- package/dist/auth/credential-chain.js.map +1 -0
- package/dist/auth/index.d.ts +10 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +7 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/keychain.d.ts +60 -0
- package/dist/auth/keychain.d.ts.map +1 -0
- package/dist/auth/keychain.js +104 -0
- package/dist/auth/keychain.js.map +1 -0
- package/dist/auth/oauth-pkce.d.ts +138 -0
- package/dist/auth/oauth-pkce.d.ts.map +1 -0
- package/dist/auth/oauth-pkce.js +375 -0
- package/dist/auth/oauth-pkce.js.map +1 -0
- package/dist/auth/oauth-server.d.ts +36 -0
- package/dist/auth/oauth-server.d.ts.map +1 -0
- package/dist/auth/oauth-server.js +210 -0
- package/dist/auth/oauth-server.js.map +1 -0
- package/dist/config.d.ts +76 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +355 -0
- package/dist/config.js.map +1 -0
- package/dist/delegation.d.ts +123 -0
- package/dist/delegation.d.ts.map +1 -0
- package/dist/delegation.js +455 -0
- package/dist/delegation.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/policy.d.ts +45 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +318 -0
- package/dist/policy.js.map +1 -0
- package/{src/protocol.ts → dist/protocol.d.ts} +2 -2
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +6 -0
- package/dist/protocol.js.map +1 -0
- package/dist/providers/adapter.d.ts +137 -0
- package/dist/providers/adapter.d.ts.map +1 -0
- package/dist/providers/adapter.js +163 -0
- package/dist/providers/adapter.js.map +1 -0
- package/dist/providers/catalog-data.d.ts +128 -0
- package/dist/providers/catalog-data.d.ts.map +1 -0
- package/dist/providers/catalog-data.js +397 -0
- package/dist/providers/catalog-data.js.map +1 -0
- package/dist/providers/catalog.d.ts +63 -0
- package/dist/providers/catalog.d.ts.map +1 -0
- package/dist/providers/catalog.js +394 -0
- package/dist/providers/catalog.js.map +1 -0
- package/dist/providers/fake.d.ts +46 -0
- package/dist/providers/fake.d.ts.map +1 -0
- package/dist/providers/fake.js +234 -0
- package/dist/providers/fake.js.map +1 -0
- package/dist/providers/openai-compatible.d.ts +65 -0
- package/dist/providers/openai-compatible.d.ts.map +1 -0
- package/dist/providers/openai-compatible.js +434 -0
- package/dist/providers/openai-compatible.js.map +1 -0
- package/dist/proxy/responses-mapper.d.ts +366 -0
- package/dist/proxy/responses-mapper.d.ts.map +1 -0
- package/dist/proxy/responses-mapper.js +517 -0
- package/dist/proxy/responses-mapper.js.map +1 -0
- package/dist/proxy/responses-server.d.ts +29 -0
- package/dist/proxy/responses-server.d.ts.map +1 -0
- package/dist/proxy/responses-server.js +360 -0
- package/dist/proxy/responses-server.js.map +1 -0
- package/dist/router.d.ts +18 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +296 -0
- package/dist/router.js.map +1 -0
- package/dist/schemas.d.ts +560 -0
- package/dist/schemas.d.ts.map +1 -0
- package/{src/schemas.ts → dist/schemas.js} +83 -103
- package/dist/schemas.js.map +1 -0
- package/dist/secrets.d.ts +112 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +326 -0
- package/dist/secrets.js.map +1 -0
- package/dist/types.d.ts +117 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/usage-file.d.ts +59 -0
- package/dist/usage-file.d.ts.map +1 -0
- package/dist/usage-file.js +316 -0
- package/dist/usage-file.js.map +1 -0
- package/dist/usage.d.ts +235 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +517 -0
- package/dist/usage.js.map +1 -0
- package/package.json +9 -4
- package/src/audit.test.ts +0 -302
- package/src/audit.ts +0 -553
- package/src/auth/credential-chain.test.ts +0 -326
- package/src/auth/credential-chain.ts +0 -235
- package/src/auth/index.ts +0 -45
- package/src/auth/keychain.test.ts +0 -265
- package/src/auth/keychain.ts +0 -168
- package/src/auth/oauth-pkce.test.ts +0 -329
- package/src/auth/oauth-pkce.ts +0 -571
- package/src/auth/oauth-server.test.ts +0 -83
- package/src/auth/oauth-server.ts +0 -296
- package/src/config.test.ts +0 -479
- package/src/config.ts +0 -505
- package/src/delegation.test.ts +0 -368
- package/src/delegation.ts +0 -605
- package/src/index.ts +0 -280
- package/src/policy.test.ts +0 -634
- package/src/policy.ts +0 -420
- package/src/providers/adapter.test.ts +0 -293
- package/src/providers/adapter.ts +0 -328
- package/src/providers/catalog-data.test.ts +0 -258
- package/src/providers/catalog-data.ts +0 -498
- package/src/providers/catalog.test.ts +0 -84
- package/src/providers/catalog.ts +0 -483
- package/src/providers/fake.ts +0 -312
- package/src/providers/openai-compatible.test.ts +0 -366
- package/src/providers/openai-compatible.ts +0 -554
- package/src/proxy/responses-mapper.test.ts +0 -290
- package/src/proxy/responses-mapper.ts +0 -736
- package/src/proxy/responses-server.test.ts +0 -322
- package/src/proxy/responses-server.ts +0 -469
- package/src/router.test.ts +0 -699
- package/src/router.ts +0 -352
- package/src/schemas.test.ts +0 -291
- package/src/secrets.test.ts +0 -271
- package/src/secrets.ts +0 -461
- package/src/types.ts +0 -173
- package/src/usage-file.test.ts +0 -243
- package/src/usage-file.ts +0 -435
- package/src/usage.test.ts +0 -335
- package/src/usage.ts +0 -859
- package/tsconfig.json +0 -9
package/src/usage.ts
DELETED
|
@@ -1,859 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Usage, quota, and provider-health store for OffRouter V1.
|
|
3
|
-
*
|
|
4
|
-
* Distinguishes subscription quota from API-key spend so doctor/status/route
|
|
5
|
-
* explain can say why paid fallback was or was not used.
|
|
6
|
-
*
|
|
7
|
-
* Provider health uses a small sliding-window error budget plus cooldown:
|
|
8
|
-
* - healthy: enough budget remaining, no active cooldown
|
|
9
|
-
* - degraded: elevated errors but still usable
|
|
10
|
-
* - dead: cooldown active after budget exhaustion / hard failures
|
|
11
|
-
*
|
|
12
|
-
* In-memory only for this task; SQLite is deferred until the interface stabilizes.
|
|
13
|
-
*/
|
|
14
|
-
import {
|
|
15
|
-
InMemoryAuditStore,
|
|
16
|
-
type AuditStore,
|
|
17
|
-
type InMemoryAuditStoreOptions,
|
|
18
|
-
} from "./audit.js";
|
|
19
|
-
import type {
|
|
20
|
-
AuthTier,
|
|
21
|
-
ProviderHealth,
|
|
22
|
-
SubscriptionStatus,
|
|
23
|
-
} from "./types.js";
|
|
24
|
-
|
|
25
|
-
/** Canonical event classes that drive health / quota / spend counters. */
|
|
26
|
-
export type UsageEventKind =
|
|
27
|
-
| "request_success"
|
|
28
|
-
| "request_error"
|
|
29
|
-
| "rate_limited"
|
|
30
|
-
| "quota_exhausted"
|
|
31
|
-
| "timeout"
|
|
32
|
-
| "cancelled"
|
|
33
|
-
| "auth_failure"
|
|
34
|
-
| "subscription_quota_set"
|
|
35
|
-
| "subscription_usage"
|
|
36
|
-
| "api_key_spend";
|
|
37
|
-
|
|
38
|
-
export interface SlidingWindowOptions {
|
|
39
|
-
/** Error-window length in ms. Default 60_000. */
|
|
40
|
-
windowMs?: number;
|
|
41
|
-
/** Errors inside the window that keep the provider healthy. Default 2. */
|
|
42
|
-
healthyMaxErrors?: number;
|
|
43
|
-
/** Errors inside the window before dead (above = dead). Default 5. */
|
|
44
|
-
deadMinErrors?: number;
|
|
45
|
-
/** Cooldown after death / hard failure before resurrectable. Default 30_000. */
|
|
46
|
-
cooldownMs?: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface ProviderUsageSnapshot {
|
|
50
|
-
providerId: string;
|
|
51
|
-
modelId?: string;
|
|
52
|
-
health: ProviderHealth;
|
|
53
|
-
/** Why health is at this level (short, audit-safe). */
|
|
54
|
-
healthReason?: string;
|
|
55
|
-
/** Errors counted in the sliding window. */
|
|
56
|
-
recentErrorCount: number;
|
|
57
|
-
/** Epoch ms when cooldown ends, if any. */
|
|
58
|
-
cooldownUntil?: number;
|
|
59
|
-
/** Configured / observed subscription unit quota (opaque units). */
|
|
60
|
-
subscriptionQuotaLimit?: number;
|
|
61
|
-
/** Subscription units consumed (opaque units). */
|
|
62
|
-
subscriptionQuotaUsed: number;
|
|
63
|
-
/** Remaining subscription units when limit known. */
|
|
64
|
-
subscriptionQuotaRemaining?: number;
|
|
65
|
-
subscriptionStatus?: SubscriptionStatus;
|
|
66
|
-
/** Cumulative paid API-key spend in USD (estimated). */
|
|
67
|
-
apiKeySpendUsd: number;
|
|
68
|
-
/** Total successful request count. */
|
|
69
|
-
successCount: number;
|
|
70
|
-
/** Total error count (all time in this process). */
|
|
71
|
-
errorCount: number;
|
|
72
|
-
lastErrorAt?: string;
|
|
73
|
-
lastErrorKind?: UsageEventKind;
|
|
74
|
-
lastSuccessAt?: string;
|
|
75
|
-
updatedAt: string;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export interface UsageEvent {
|
|
79
|
-
providerId: string;
|
|
80
|
-
modelId?: string;
|
|
81
|
-
authTier: AuthTier;
|
|
82
|
-
kind: UsageEventKind;
|
|
83
|
-
/**
|
|
84
|
-
* For subscription_usage / subscription_quota_set: opaque quota units.
|
|
85
|
-
* For api_key_spend: USD amount.
|
|
86
|
-
*/
|
|
87
|
-
amount?: number;
|
|
88
|
-
/** Optional hard set of subscription limit when kind is subscription_quota_set. */
|
|
89
|
-
quotaLimit?: number;
|
|
90
|
-
subscriptionStatus?: SubscriptionStatus;
|
|
91
|
-
/** Override clock for tests. */
|
|
92
|
-
at?: Date;
|
|
93
|
-
/** Human-safe detail (already redacted by caller). */
|
|
94
|
-
detail?: string;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface RecordUsageResult {
|
|
98
|
-
snapshot: ProviderUsageSnapshot;
|
|
99
|
-
/**
|
|
100
|
-
* True when this event moved health from a usable state into dead/degraded,
|
|
101
|
-
* or when subscription became exhausted.
|
|
102
|
-
*/
|
|
103
|
-
healthChanged: boolean;
|
|
104
|
-
previousHealth: ProviderHealth;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export const DEFAULT_NEAR_LIMIT_THRESHOLD = 0.2;
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Snapshot of usage for a specific account within a provider.
|
|
111
|
-
*/
|
|
112
|
-
export interface AccountUsageSnapshot {
|
|
113
|
-
providerId: string;
|
|
114
|
-
accountId: string;
|
|
115
|
-
used: number;
|
|
116
|
-
limit?: number;
|
|
117
|
-
remaining?: number;
|
|
118
|
-
percentRemaining?: number;
|
|
119
|
-
nearLimit: boolean;
|
|
120
|
-
subscriptionStatus: SubscriptionStatus;
|
|
121
|
-
/**
|
|
122
|
-
* Derived account health from quota usage: "dead" when exhausted,
|
|
123
|
-
* "degraded" when near-limit, otherwise "healthy". Useful for
|
|
124
|
-
* persisted stores that need a stable, restorable health signal.
|
|
125
|
-
*/
|
|
126
|
-
health?: ProviderHealth;
|
|
127
|
-
updatedAt: string;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Minimal persisted document for a single account's quota usage.
|
|
132
|
-
* Shared between the in-memory and file-backed usage stores so snapshot
|
|
133
|
-
* math stays in one place.
|
|
134
|
-
*/
|
|
135
|
-
export interface AccountUsageDoc {
|
|
136
|
-
providerId: string;
|
|
137
|
-
accountId: string;
|
|
138
|
-
used: number;
|
|
139
|
-
limit?: number;
|
|
140
|
-
updatedAt: string;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Why paid API-key capacity was or was not selected given current usage state.
|
|
145
|
-
* Consumed by doctor / status / route explain surfaces.
|
|
146
|
-
*/
|
|
147
|
-
export interface PaidFallbackExplanation {
|
|
148
|
-
providerId: string;
|
|
149
|
-
recommendation:
|
|
150
|
-
| "prefer_subscription"
|
|
151
|
-
| "allow_api_key_fallback"
|
|
152
|
-
| "api_key_only"
|
|
153
|
-
| "no_capacity";
|
|
154
|
-
reason: string;
|
|
155
|
-
subscriptionStatus?: SubscriptionStatus;
|
|
156
|
-
subscriptionQuotaRemaining?: number;
|
|
157
|
-
subscriptionHealth?: ProviderHealth;
|
|
158
|
-
apiKeySpendUsd: number;
|
|
159
|
-
usedPaidFallback: boolean;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export interface UsageStore {
|
|
163
|
-
/** Atomically apply a usage event and recompute health. */
|
|
164
|
-
record(event: UsageEvent): Promise<RecordUsageResult>;
|
|
165
|
-
|
|
166
|
-
getProvider(providerId: string): Promise<ProviderUsageSnapshot | undefined>;
|
|
167
|
-
|
|
168
|
-
listProviders(): Promise<ProviderUsageSnapshot[]>;
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Set subscription quota limit/used for a provider without incrementing spend.
|
|
172
|
-
* Useful when adapters discover limit snapshots out of band.
|
|
173
|
-
*/
|
|
174
|
-
setSubscriptionQuota(
|
|
175
|
-
providerId: string,
|
|
176
|
-
input: {
|
|
177
|
-
limit?: number;
|
|
178
|
-
used?: number;
|
|
179
|
-
status?: SubscriptionStatus;
|
|
180
|
-
modelId?: string;
|
|
181
|
-
at?: Date;
|
|
182
|
-
},
|
|
183
|
-
): Promise<ProviderUsageSnapshot>;
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Explain whether subscription capacity remains or paid fallback is justified.
|
|
187
|
-
*/
|
|
188
|
-
explainPaidFallback(
|
|
189
|
-
providerId: string,
|
|
190
|
-
options?: { hasApiKeyCandidate?: boolean },
|
|
191
|
-
): Promise<PaidFallbackExplanation>;
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Record usage for a specific account within a provider.
|
|
195
|
-
*/
|
|
196
|
-
recordUsage(
|
|
197
|
-
providerId: string,
|
|
198
|
-
accountId: string,
|
|
199
|
-
input: { used?: number; limit?: number; at?: Date },
|
|
200
|
-
): Promise<AccountUsageSnapshot>;
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Check whether a specific account is near its quota limit.
|
|
204
|
-
*/
|
|
205
|
-
isNearLimit(providerId: string, accountId: string, threshold?: number): Promise<boolean>;
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Get all accounts whose usage is near their quota limit.
|
|
209
|
-
*/
|
|
210
|
-
getNearLimitAccounts(threshold?: number): Promise<AccountUsageSnapshot[]>;
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Get the usage snapshot for a specific account.
|
|
214
|
-
*/
|
|
215
|
-
getAccountUsage(providerId: string, accountId: string): Promise<AccountUsageSnapshot | undefined>;
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* List all account usage snapshots.
|
|
219
|
-
*/
|
|
220
|
-
listAccountUsage(): Promise<AccountUsageSnapshot[]>;
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* JSON-safe diagnostic view. No secrets.
|
|
224
|
-
*/
|
|
225
|
-
toJSON(): Record<string, unknown>;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export interface InMemoryUsageStoreOptions extends SlidingWindowOptions {
|
|
229
|
-
now?: () => Date;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
interface InternalProviderState {
|
|
233
|
-
providerId: string;
|
|
234
|
-
modelId?: string;
|
|
235
|
-
/** Epoch-ms timestamps of recent errors inside the sliding window. */
|
|
236
|
-
errorTimestamps: number[];
|
|
237
|
-
cooldownUntil?: number;
|
|
238
|
-
health: ProviderHealth;
|
|
239
|
-
healthReason?: string;
|
|
240
|
-
subscriptionQuotaLimit?: number;
|
|
241
|
-
subscriptionQuotaUsed: number;
|
|
242
|
-
subscriptionStatus?: SubscriptionStatus;
|
|
243
|
-
apiKeySpendUsd: number;
|
|
244
|
-
successCount: number;
|
|
245
|
-
errorCount: number;
|
|
246
|
-
lastErrorAt?: string;
|
|
247
|
-
lastErrorKind?: UsageEventKind;
|
|
248
|
-
lastSuccessAt?: string;
|
|
249
|
-
updatedAt: string;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
interface AccountQuotaState {
|
|
253
|
-
providerId: string;
|
|
254
|
-
accountId: string;
|
|
255
|
-
used: number;
|
|
256
|
-
limit?: number;
|
|
257
|
-
updatedAt: string;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const DEFAULT_WINDOW_MS = 60_000;
|
|
261
|
-
const DEFAULT_HEALTHY_MAX_ERRORS = 2;
|
|
262
|
-
const DEFAULT_DEAD_MIN_ERRORS = 5;
|
|
263
|
-
const DEFAULT_COOLDOWN_MS = 30_000;
|
|
264
|
-
|
|
265
|
-
const HARD_ERROR_KINDS: ReadonlySet<UsageEventKind> = new Set([
|
|
266
|
-
"auth_failure",
|
|
267
|
-
"quota_exhausted",
|
|
268
|
-
]);
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* In-memory usage/health store.
|
|
272
|
-
* Mutations are serialized so concurrent updates never lose counters.
|
|
273
|
-
*/
|
|
274
|
-
export class InMemoryUsageStore implements UsageStore {
|
|
275
|
-
readonly #providers = new Map<string, InternalProviderState>();
|
|
276
|
-
readonly #accounts = new Map<string, AccountQuotaState>();
|
|
277
|
-
readonly #windowMs: number;
|
|
278
|
-
readonly #healthyMaxErrors: number;
|
|
279
|
-
readonly #deadMinErrors: number;
|
|
280
|
-
readonly #cooldownMs: number;
|
|
281
|
-
readonly #now: () => Date;
|
|
282
|
-
#chain: Promise<unknown> = Promise.resolve();
|
|
283
|
-
|
|
284
|
-
constructor(options: InMemoryUsageStoreOptions = {}) {
|
|
285
|
-
this.#windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
|
|
286
|
-
this.#healthyMaxErrors =
|
|
287
|
-
options.healthyMaxErrors ?? DEFAULT_HEALTHY_MAX_ERRORS;
|
|
288
|
-
this.#deadMinErrors = options.deadMinErrors ?? DEFAULT_DEAD_MIN_ERRORS;
|
|
289
|
-
this.#cooldownMs = options.cooldownMs ?? DEFAULT_COOLDOWN_MS;
|
|
290
|
-
this.#now = options.now ?? (() => new Date());
|
|
291
|
-
|
|
292
|
-
if (
|
|
293
|
-
this.#healthyMaxErrors < 0 ||
|
|
294
|
-
this.#deadMinErrors <= this.#healthyMaxErrors
|
|
295
|
-
) {
|
|
296
|
-
throw new RangeError(
|
|
297
|
-
"healthyMaxErrors must be >= 0 and deadMinErrors must be > healthyMaxErrors",
|
|
298
|
-
);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
#exclusive<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
303
|
-
const run = this.#chain.then(fn, fn);
|
|
304
|
-
this.#chain = run.then(
|
|
305
|
-
() => undefined,
|
|
306
|
-
() => undefined,
|
|
307
|
-
);
|
|
308
|
-
return run;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
async record(event: UsageEvent): Promise<RecordUsageResult> {
|
|
312
|
-
return this.#exclusive(() => {
|
|
313
|
-
const at = event.at ?? this.#now();
|
|
314
|
-
const state = this.#ensure(event.providerId, event.modelId, at);
|
|
315
|
-
const previousHealth = state.health;
|
|
316
|
-
|
|
317
|
-
if (event.modelId) state.modelId = event.modelId;
|
|
318
|
-
|
|
319
|
-
switch (event.kind) {
|
|
320
|
-
case "request_success":
|
|
321
|
-
state.successCount += 1;
|
|
322
|
-
state.lastSuccessAt = at.toISOString();
|
|
323
|
-
this.#pruneErrors(state, at.getTime());
|
|
324
|
-
break;
|
|
325
|
-
|
|
326
|
-
case "request_error":
|
|
327
|
-
case "rate_limited":
|
|
328
|
-
case "timeout":
|
|
329
|
-
case "auth_failure":
|
|
330
|
-
case "quota_exhausted":
|
|
331
|
-
state.errorCount += 1;
|
|
332
|
-
state.errorTimestamps.push(at.getTime());
|
|
333
|
-
state.lastErrorAt = at.toISOString();
|
|
334
|
-
state.lastErrorKind = event.kind;
|
|
335
|
-
if (event.kind === "rate_limited") {
|
|
336
|
-
state.subscriptionStatus =
|
|
337
|
-
state.subscriptionStatus === "exhausted"
|
|
338
|
-
? "exhausted"
|
|
339
|
-
: "rate-limited";
|
|
340
|
-
}
|
|
341
|
-
if (event.kind === "quota_exhausted") {
|
|
342
|
-
if (event.authTier === "subscription") {
|
|
343
|
-
state.subscriptionStatus = "exhausted";
|
|
344
|
-
if (
|
|
345
|
-
state.subscriptionQuotaLimit !== undefined &&
|
|
346
|
-
state.subscriptionQuotaUsed < state.subscriptionQuotaLimit
|
|
347
|
-
) {
|
|
348
|
-
state.subscriptionQuotaUsed = state.subscriptionQuotaLimit;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
if (HARD_ERROR_KINDS.has(event.kind)) {
|
|
353
|
-
state.cooldownUntil = at.getTime() + this.#cooldownMs;
|
|
354
|
-
}
|
|
355
|
-
break;
|
|
356
|
-
|
|
357
|
-
case "cancelled":
|
|
358
|
-
// Cancellation is not a provider-health error by default.
|
|
359
|
-
break;
|
|
360
|
-
|
|
361
|
-
case "subscription_quota_set":
|
|
362
|
-
if (event.quotaLimit !== undefined) {
|
|
363
|
-
state.subscriptionQuotaLimit = event.quotaLimit;
|
|
364
|
-
}
|
|
365
|
-
if (event.amount !== undefined) {
|
|
366
|
-
state.subscriptionQuotaUsed = event.amount;
|
|
367
|
-
}
|
|
368
|
-
if (event.subscriptionStatus) {
|
|
369
|
-
state.subscriptionStatus = event.subscriptionStatus;
|
|
370
|
-
}
|
|
371
|
-
break;
|
|
372
|
-
|
|
373
|
-
case "subscription_usage": {
|
|
374
|
-
const units = event.amount ?? 1;
|
|
375
|
-
state.subscriptionQuotaUsed += units;
|
|
376
|
-
state.successCount += 1;
|
|
377
|
-
state.lastSuccessAt = at.toISOString();
|
|
378
|
-
if (
|
|
379
|
-
state.subscriptionQuotaLimit !== undefined &&
|
|
380
|
-
state.subscriptionQuotaUsed >= state.subscriptionQuotaLimit
|
|
381
|
-
) {
|
|
382
|
-
state.subscriptionStatus = "exhausted";
|
|
383
|
-
} else if (
|
|
384
|
-
!state.subscriptionStatus ||
|
|
385
|
-
state.subscriptionStatus === "unknown"
|
|
386
|
-
) {
|
|
387
|
-
state.subscriptionStatus = "active";
|
|
388
|
-
}
|
|
389
|
-
break;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
case "api_key_spend": {
|
|
393
|
-
const usd = event.amount ?? 0;
|
|
394
|
-
if (usd < 0) {
|
|
395
|
-
throw new RangeError("api_key_spend amount must be >= 0");
|
|
396
|
-
}
|
|
397
|
-
state.apiKeySpendUsd += usd;
|
|
398
|
-
state.successCount += 1;
|
|
399
|
-
state.lastSuccessAt = at.toISOString();
|
|
400
|
-
break;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
default: {
|
|
404
|
-
const _exhaustive: never = event.kind;
|
|
405
|
-
throw new Error(`Unhandled usage event kind: ${String(_exhaustive)}`);
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
if (event.subscriptionStatus) {
|
|
410
|
-
state.subscriptionStatus = event.subscriptionStatus;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
this.#recomputeHealth(state, at.getTime(), event);
|
|
414
|
-
state.updatedAt = at.toISOString();
|
|
415
|
-
|
|
416
|
-
return {
|
|
417
|
-
snapshot: toSnapshot(state),
|
|
418
|
-
healthChanged: previousHealth !== state.health,
|
|
419
|
-
previousHealth,
|
|
420
|
-
};
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
async getProvider(
|
|
425
|
-
providerId: string,
|
|
426
|
-
): Promise<ProviderUsageSnapshot | undefined> {
|
|
427
|
-
const state = this.#providers.get(providerId);
|
|
428
|
-
if (!state) return undefined;
|
|
429
|
-
const now = this.#now();
|
|
430
|
-
this.#recomputeHealth(state, now.getTime());
|
|
431
|
-
state.updatedAt = now.toISOString();
|
|
432
|
-
return toSnapshot(state);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
async listProviders(): Promise<ProviderUsageSnapshot[]> {
|
|
436
|
-
const now = this.#now();
|
|
437
|
-
const out: ProviderUsageSnapshot[] = [];
|
|
438
|
-
for (const state of this.#providers.values()) {
|
|
439
|
-
this.#recomputeHealth(state, now.getTime());
|
|
440
|
-
state.updatedAt = now.toISOString();
|
|
441
|
-
out.push(toSnapshot(state));
|
|
442
|
-
}
|
|
443
|
-
return out.sort((a, b) => a.providerId.localeCompare(b.providerId));
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
async setSubscriptionQuota(
|
|
447
|
-
providerId: string,
|
|
448
|
-
input: {
|
|
449
|
-
limit?: number;
|
|
450
|
-
used?: number;
|
|
451
|
-
status?: SubscriptionStatus;
|
|
452
|
-
modelId?: string;
|
|
453
|
-
at?: Date;
|
|
454
|
-
},
|
|
455
|
-
): Promise<ProviderUsageSnapshot> {
|
|
456
|
-
const result = await this.record({
|
|
457
|
-
providerId,
|
|
458
|
-
modelId: input.modelId,
|
|
459
|
-
authTier: "subscription",
|
|
460
|
-
kind: "subscription_quota_set",
|
|
461
|
-
amount: input.used,
|
|
462
|
-
quotaLimit: input.limit,
|
|
463
|
-
subscriptionStatus: input.status,
|
|
464
|
-
at: input.at,
|
|
465
|
-
});
|
|
466
|
-
return result.snapshot;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
async explainPaidFallback(
|
|
470
|
-
providerId: string,
|
|
471
|
-
options: { hasApiKeyCandidate?: boolean } = {},
|
|
472
|
-
): Promise<PaidFallbackExplanation> {
|
|
473
|
-
const snap = await this.getProvider(providerId);
|
|
474
|
-
const hasApiKey = options.hasApiKeyCandidate === true;
|
|
475
|
-
const apiKeySpendUsd = snap?.apiKeySpendUsd ?? 0;
|
|
476
|
-
|
|
477
|
-
if (!snap) {
|
|
478
|
-
return {
|
|
479
|
-
providerId,
|
|
480
|
-
recommendation: hasApiKey ? "api_key_only" : "no_capacity",
|
|
481
|
-
reason: hasApiKey
|
|
482
|
-
? "No subscription usage recorded for this provider; API-key candidate may be used if policy allows."
|
|
483
|
-
: "No usage recorded and no API-key candidate announced.",
|
|
484
|
-
apiKeySpendUsd: 0,
|
|
485
|
-
usedPaidFallback: false,
|
|
486
|
-
};
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
const subStatus = snap.subscriptionStatus;
|
|
490
|
-
const remaining = snap.subscriptionQuotaRemaining;
|
|
491
|
-
const health = snap.health;
|
|
492
|
-
|
|
493
|
-
const subscriptionUsable =
|
|
494
|
-
health !== "dead" &&
|
|
495
|
-
subStatus !== "exhausted" &&
|
|
496
|
-
subStatus !== "expired" &&
|
|
497
|
-
subStatus !== "unconfigured" &&
|
|
498
|
-
(remaining === undefined || remaining > 0);
|
|
499
|
-
|
|
500
|
-
if (subscriptionUsable) {
|
|
501
|
-
if (subStatus === "rate-limited" || health === "degraded") {
|
|
502
|
-
return {
|
|
503
|
-
providerId,
|
|
504
|
-
recommendation: hasApiKey
|
|
505
|
-
? "allow_api_key_fallback"
|
|
506
|
-
: "prefer_subscription",
|
|
507
|
-
reason:
|
|
508
|
-
subStatus === "rate-limited"
|
|
509
|
-
? "Subscription is rate-limited; API-key fallback is justified if policy allows."
|
|
510
|
-
: "Subscription capacity is degraded; API-key fallback is available if policy allows.",
|
|
511
|
-
subscriptionStatus: subStatus,
|
|
512
|
-
subscriptionQuotaRemaining: remaining,
|
|
513
|
-
subscriptionHealth: health,
|
|
514
|
-
apiKeySpendUsd,
|
|
515
|
-
usedPaidFallback: apiKeySpendUsd > 0,
|
|
516
|
-
};
|
|
517
|
-
}
|
|
518
|
-
return {
|
|
519
|
-
providerId,
|
|
520
|
-
recommendation: "prefer_subscription",
|
|
521
|
-
reason:
|
|
522
|
-
remaining === undefined
|
|
523
|
-
? "Healthy subscription capacity is preferred over paid API-key routes."
|
|
524
|
-
: `Subscription still has ${remaining} quota units remaining; paid API-key fallback should not be used yet.`,
|
|
525
|
-
subscriptionStatus: subStatus ?? "active",
|
|
526
|
-
subscriptionQuotaRemaining: remaining,
|
|
527
|
-
subscriptionHealth: health,
|
|
528
|
-
apiKeySpendUsd,
|
|
529
|
-
usedPaidFallback: apiKeySpendUsd > 0,
|
|
530
|
-
};
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
if (hasApiKey) {
|
|
534
|
-
let reason: string;
|
|
535
|
-
if (
|
|
536
|
-
subStatus === "exhausted" ||
|
|
537
|
-
(remaining !== undefined && remaining <= 0)
|
|
538
|
-
) {
|
|
539
|
-
reason =
|
|
540
|
-
"Subscription quota is exhausted; paid API-key fallback is justified if policy allows.";
|
|
541
|
-
} else if (subStatus === "expired") {
|
|
542
|
-
reason =
|
|
543
|
-
"Subscription is expired; paid API-key fallback is justified if policy allows.";
|
|
544
|
-
} else if (subStatus === "unconfigured") {
|
|
545
|
-
reason =
|
|
546
|
-
"Subscription is unconfigured; paid API-key fallback is justified if policy allows.";
|
|
547
|
-
} else if (health === "dead") {
|
|
548
|
-
reason = `Subscription provider is dead${
|
|
549
|
-
snap.cooldownUntil
|
|
550
|
-
? ` until ${new Date(snap.cooldownUntil).toISOString()}`
|
|
551
|
-
: ""
|
|
552
|
-
}; paid API-key fallback is justified if policy allows.`;
|
|
553
|
-
} else {
|
|
554
|
-
reason =
|
|
555
|
-
"Subscription capacity is unavailable; paid API-key fallback is justified if policy allows.";
|
|
556
|
-
}
|
|
557
|
-
return {
|
|
558
|
-
providerId,
|
|
559
|
-
recommendation: "allow_api_key_fallback",
|
|
560
|
-
reason,
|
|
561
|
-
subscriptionStatus: subStatus,
|
|
562
|
-
subscriptionQuotaRemaining: remaining,
|
|
563
|
-
subscriptionHealth: health,
|
|
564
|
-
apiKeySpendUsd,
|
|
565
|
-
usedPaidFallback: apiKeySpendUsd > 0,
|
|
566
|
-
};
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
return {
|
|
570
|
-
providerId,
|
|
571
|
-
recommendation: "no_capacity",
|
|
572
|
-
reason:
|
|
573
|
-
"Subscription capacity is unavailable and no API-key candidate is available.",
|
|
574
|
-
subscriptionStatus: subStatus,
|
|
575
|
-
subscriptionQuotaRemaining: remaining,
|
|
576
|
-
subscriptionHealth: health,
|
|
577
|
-
apiKeySpendUsd,
|
|
578
|
-
usedPaidFallback: false,
|
|
579
|
-
};
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
async recordUsage(
|
|
583
|
-
providerId: string,
|
|
584
|
-
accountId: string,
|
|
585
|
-
input: { used?: number; limit?: number; at?: Date },
|
|
586
|
-
): Promise<AccountUsageSnapshot> {
|
|
587
|
-
return this.#exclusive(() => {
|
|
588
|
-
const at = input.at ?? this.#now();
|
|
589
|
-
const key = `${providerId}:${accountId}`;
|
|
590
|
-
let state = this.#accounts.get(key);
|
|
591
|
-
if (!state) {
|
|
592
|
-
state = { providerId, accountId, used: 0, updatedAt: at.toISOString() };
|
|
593
|
-
this.#accounts.set(key, state);
|
|
594
|
-
}
|
|
595
|
-
if (input.used !== undefined) state.used = input.used;
|
|
596
|
-
if (input.limit !== undefined) state.limit = input.limit;
|
|
597
|
-
state.updatedAt = at.toISOString();
|
|
598
|
-
return computeAccountSnapshot(state);
|
|
599
|
-
});
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
async isNearLimit(
|
|
603
|
-
providerId: string,
|
|
604
|
-
accountId: string,
|
|
605
|
-
threshold?: number,
|
|
606
|
-
): Promise<boolean> {
|
|
607
|
-
return this.#exclusive(() => {
|
|
608
|
-
const key = `${providerId}:${accountId}`;
|
|
609
|
-
const state = this.#accounts.get(key);
|
|
610
|
-
if (!state) return false;
|
|
611
|
-
return computeAccountSnapshot(state, threshold).nearLimit;
|
|
612
|
-
});
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
async getNearLimitAccounts(threshold?: number): Promise<AccountUsageSnapshot[]> {
|
|
616
|
-
return this.#exclusive(() => {
|
|
617
|
-
const out: AccountUsageSnapshot[] = [];
|
|
618
|
-
for (const state of this.#accounts.values()) {
|
|
619
|
-
const snap = computeAccountSnapshot(state, threshold);
|
|
620
|
-
if (snap.nearLimit) out.push(snap);
|
|
621
|
-
}
|
|
622
|
-
return out.sort((a, b) => {
|
|
623
|
-
if (a.providerId !== b.providerId) return a.providerId.localeCompare(b.providerId);
|
|
624
|
-
return a.accountId.localeCompare(b.accountId);
|
|
625
|
-
});
|
|
626
|
-
});
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
async getAccountUsage(
|
|
630
|
-
providerId: string,
|
|
631
|
-
accountId: string,
|
|
632
|
-
): Promise<AccountUsageSnapshot | undefined> {
|
|
633
|
-
return this.#exclusive(() => {
|
|
634
|
-
const key = `${providerId}:${accountId}`;
|
|
635
|
-
const state = this.#accounts.get(key);
|
|
636
|
-
if (!state) return undefined;
|
|
637
|
-
return computeAccountSnapshot(state);
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
async listAccountUsage(): Promise<AccountUsageSnapshot[]> {
|
|
642
|
-
return this.#exclusive(() => {
|
|
643
|
-
const out: AccountUsageSnapshot[] = [];
|
|
644
|
-
for (const state of this.#accounts.values()) {
|
|
645
|
-
out.push(computeAccountSnapshot(state));
|
|
646
|
-
}
|
|
647
|
-
return out.sort((a, b) => {
|
|
648
|
-
if (a.providerId !== b.providerId) return a.providerId.localeCompare(b.providerId);
|
|
649
|
-
return a.accountId.localeCompare(b.accountId);
|
|
650
|
-
});
|
|
651
|
-
});
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
toJSON(): Record<string, unknown> {
|
|
655
|
-
const providers: Record<string, unknown> = {};
|
|
656
|
-
for (const [id, state] of this.#providers) {
|
|
657
|
-
providers[id] = {
|
|
658
|
-
health: state.health,
|
|
659
|
-
healthReason: state.healthReason,
|
|
660
|
-
subscriptionQuotaLimit: state.subscriptionQuotaLimit,
|
|
661
|
-
subscriptionQuotaUsed: state.subscriptionQuotaUsed,
|
|
662
|
-
subscriptionStatus: state.subscriptionStatus,
|
|
663
|
-
apiKeySpendUsd: state.apiKeySpendUsd,
|
|
664
|
-
successCount: state.successCount,
|
|
665
|
-
errorCount: state.errorCount,
|
|
666
|
-
cooldownUntil: state.cooldownUntil,
|
|
667
|
-
};
|
|
668
|
-
}
|
|
669
|
-
return {
|
|
670
|
-
kind: "in-memory-usage",
|
|
671
|
-
providers,
|
|
672
|
-
accounts: Object.fromEntries(this.#accounts),
|
|
673
|
-
};
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
#ensure(
|
|
677
|
-
providerId: string,
|
|
678
|
-
modelId: string | undefined,
|
|
679
|
-
at: Date,
|
|
680
|
-
): InternalProviderState {
|
|
681
|
-
let state = this.#providers.get(providerId);
|
|
682
|
-
if (!state) {
|
|
683
|
-
state = {
|
|
684
|
-
providerId,
|
|
685
|
-
modelId,
|
|
686
|
-
errorTimestamps: [],
|
|
687
|
-
health: "healthy",
|
|
688
|
-
subscriptionQuotaUsed: 0,
|
|
689
|
-
apiKeySpendUsd: 0,
|
|
690
|
-
successCount: 0,
|
|
691
|
-
errorCount: 0,
|
|
692
|
-
updatedAt: at.toISOString(),
|
|
693
|
-
};
|
|
694
|
-
this.#providers.set(providerId, state);
|
|
695
|
-
}
|
|
696
|
-
return state;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
#pruneErrors(state: InternalProviderState, nowMs: number): void {
|
|
700
|
-
const cutoff = nowMs - this.#windowMs;
|
|
701
|
-
state.errorTimestamps = state.errorTimestamps.filter((t) => t >= cutoff);
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
#recomputeHealth(
|
|
705
|
-
state: InternalProviderState,
|
|
706
|
-
nowMs: number,
|
|
707
|
-
event?: UsageEvent,
|
|
708
|
-
): void {
|
|
709
|
-
this.#pruneErrors(state, nowMs);
|
|
710
|
-
|
|
711
|
-
if (state.cooldownUntil !== undefined && state.cooldownUntil <= nowMs) {
|
|
712
|
-
state.cooldownUntil = undefined;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
const errors = state.errorTimestamps.length;
|
|
716
|
-
let health: ProviderHealth;
|
|
717
|
-
let reason: string;
|
|
718
|
-
|
|
719
|
-
if (state.cooldownUntil !== undefined && state.cooldownUntil > nowMs) {
|
|
720
|
-
health = "dead";
|
|
721
|
-
reason = `Cooldown active until ${new Date(state.cooldownUntil).toISOString()}`;
|
|
722
|
-
} else if (
|
|
723
|
-
state.subscriptionStatus === "exhausted" ||
|
|
724
|
-
(state.subscriptionQuotaLimit !== undefined &&
|
|
725
|
-
state.subscriptionQuotaUsed >= state.subscriptionQuotaLimit)
|
|
726
|
-
) {
|
|
727
|
-
if (event?.kind === "quota_exhausted" && state.cooldownUntil) {
|
|
728
|
-
health = "dead";
|
|
729
|
-
reason = "Subscription quota exhausted; cooldown applied";
|
|
730
|
-
} else if (errors >= this.#deadMinErrors) {
|
|
731
|
-
health = "dead";
|
|
732
|
-
reason = `Error budget exhausted (${errors} errors in ${this.#windowMs}ms window) with subscription quota exhausted`;
|
|
733
|
-
state.cooldownUntil = nowMs + this.#cooldownMs;
|
|
734
|
-
} else {
|
|
735
|
-
health = "degraded";
|
|
736
|
-
reason = "Subscription quota exhausted";
|
|
737
|
-
}
|
|
738
|
-
} else if (errors >= this.#deadMinErrors) {
|
|
739
|
-
health = "dead";
|
|
740
|
-
reason = `Error budget exhausted (${errors} errors in ${this.#windowMs}ms window)`;
|
|
741
|
-
state.cooldownUntil = nowMs + this.#cooldownMs;
|
|
742
|
-
} else if (errors > this.#healthyMaxErrors) {
|
|
743
|
-
health = "degraded";
|
|
744
|
-
reason = `Elevated error rate (${errors} errors in ${this.#windowMs}ms window)`;
|
|
745
|
-
} else {
|
|
746
|
-
health = "healthy";
|
|
747
|
-
reason =
|
|
748
|
-
errors === 0
|
|
749
|
-
? "No recent errors"
|
|
750
|
-
: `Within error budget (${errors}/${this.#healthyMaxErrors} allowed)`;
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
state.health = health;
|
|
754
|
-
state.healthReason = reason;
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
function toSnapshot(state: InternalProviderState): ProviderUsageSnapshot {
|
|
759
|
-
const limit = state.subscriptionQuotaLimit;
|
|
760
|
-
const used = state.subscriptionQuotaUsed;
|
|
761
|
-
const remaining =
|
|
762
|
-
limit === undefined ? undefined : Math.max(0, limit - used);
|
|
763
|
-
|
|
764
|
-
return {
|
|
765
|
-
providerId: state.providerId,
|
|
766
|
-
modelId: state.modelId,
|
|
767
|
-
health: state.health,
|
|
768
|
-
healthReason: state.healthReason,
|
|
769
|
-
recentErrorCount: state.errorTimestamps.length,
|
|
770
|
-
cooldownUntil: state.cooldownUntil,
|
|
771
|
-
subscriptionQuotaLimit: limit,
|
|
772
|
-
subscriptionQuotaUsed: used,
|
|
773
|
-
subscriptionQuotaRemaining: remaining,
|
|
774
|
-
subscriptionStatus: state.subscriptionStatus,
|
|
775
|
-
apiKeySpendUsd: state.apiKeySpendUsd,
|
|
776
|
-
successCount: state.successCount,
|
|
777
|
-
errorCount: state.errorCount,
|
|
778
|
-
lastErrorAt: state.lastErrorAt,
|
|
779
|
-
lastErrorKind: state.lastErrorKind,
|
|
780
|
-
lastSuccessAt: state.lastSuccessAt,
|
|
781
|
-
updatedAt: state.updatedAt,
|
|
782
|
-
};
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
export function computeAccountSnapshot(
|
|
786
|
-
doc: AccountUsageDoc,
|
|
787
|
-
threshold: number = DEFAULT_NEAR_LIMIT_THRESHOLD,
|
|
788
|
-
): AccountUsageSnapshot {
|
|
789
|
-
const limit = doc.limit;
|
|
790
|
-
const used = doc.used;
|
|
791
|
-
const remaining =
|
|
792
|
-
limit === undefined ? undefined : Math.max(0, limit - used);
|
|
793
|
-
let status: SubscriptionStatus;
|
|
794
|
-
let nearLimit = false;
|
|
795
|
-
let health: ProviderHealth;
|
|
796
|
-
if (limit === undefined) {
|
|
797
|
-
status = "unknown";
|
|
798
|
-
health = "healthy";
|
|
799
|
-
} else if (used >= limit) {
|
|
800
|
-
status = "exhausted";
|
|
801
|
-
health = "dead";
|
|
802
|
-
} else if ((limit - used) / limit < threshold) {
|
|
803
|
-
status = "near-limit";
|
|
804
|
-
nearLimit = true;
|
|
805
|
-
health = "degraded";
|
|
806
|
-
} else {
|
|
807
|
-
status = "active";
|
|
808
|
-
health = "healthy";
|
|
809
|
-
}
|
|
810
|
-
const percentRemaining =
|
|
811
|
-
limit === undefined
|
|
812
|
-
? undefined
|
|
813
|
-
: Math.round(((limit - used) / limit) * 100);
|
|
814
|
-
return {
|
|
815
|
-
providerId: doc.providerId,
|
|
816
|
-
accountId: doc.accountId,
|
|
817
|
-
used,
|
|
818
|
-
limit,
|
|
819
|
-
remaining,
|
|
820
|
-
percentRemaining,
|
|
821
|
-
nearLimit,
|
|
822
|
-
subscriptionStatus: status,
|
|
823
|
-
health,
|
|
824
|
-
updatedAt: doc.updatedAt,
|
|
825
|
-
};
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
/**
|
|
829
|
-
* Composite facade for callers that want one injectable "state" handle.
|
|
830
|
-
* Writes stay on the specialized stores; this is a thin convenience.
|
|
831
|
-
*/
|
|
832
|
-
export interface StateStore {
|
|
833
|
-
audit: AuditStore;
|
|
834
|
-
usage: UsageStore;
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
export class InMemoryStateStore implements StateStore {
|
|
838
|
-
readonly audit: AuditStore;
|
|
839
|
-
readonly usage: UsageStore;
|
|
840
|
-
|
|
841
|
-
constructor(options?: {
|
|
842
|
-
audit?: AuditStore;
|
|
843
|
-
usage?: UsageStore;
|
|
844
|
-
auditOptions?: InMemoryAuditStoreOptions;
|
|
845
|
-
usageOptions?: InMemoryUsageStoreOptions;
|
|
846
|
-
}) {
|
|
847
|
-
this.audit = options?.audit ?? new InMemoryAuditStore(options?.auditOptions);
|
|
848
|
-
this.usage =
|
|
849
|
-
options?.usage ?? new InMemoryUsageStore(options?.usageOptions);
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
toJSON(): Record<string, unknown> {
|
|
853
|
-
return {
|
|
854
|
-
kind: "in-memory-state",
|
|
855
|
-
audit: this.audit.toJSON(),
|
|
856
|
-
usage: this.usage.toJSON(),
|
|
857
|
-
};
|
|
858
|
-
}
|
|
859
|
-
}
|