offrouter-core 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/auth/credential-chain.ts +6 -3
- package/src/auth/keychain.test.ts +37 -0
- package/src/auth/keychain.ts +20 -2
- package/src/config.test.ts +50 -2
- package/src/config.ts +18 -1
- package/src/index.ts +3 -1
- package/src/policy.ts +2 -0
- package/src/providers/adapter.ts +4 -0
- package/src/router.test.ts +137 -0
- package/src/router.ts +34 -5
- package/src/schemas.test.ts +51 -0
- package/src/schemas.ts +5 -0
- package/src/types.ts +6 -0
- package/src/usage.test.ts +52 -0
- package/src/usage.ts +163 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "offrouter-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"test": "vitest run"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"smol-toml": "
|
|
19
|
-
"zod": "
|
|
18
|
+
"smol-toml": "0.0.0",
|
|
19
|
+
"zod": "0.0.0"
|
|
20
20
|
}
|
|
21
21
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { SecretStore } from "../secrets.js";
|
|
10
|
+
import { accountStorageKey } from "./keychain.js";
|
|
10
11
|
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
12
13
|
// Redacted wrapper
|
|
@@ -51,7 +52,7 @@ export class Redacted<T extends string = string> {
|
|
|
51
52
|
export type CredentialSource =
|
|
52
53
|
| { type: "env"; name: string }
|
|
53
54
|
| { type: "config"; key: string }
|
|
54
|
-
| { type: "keychain"; providerId: string }
|
|
55
|
+
| { type: "keychain"; providerId: string; accountId?: string }
|
|
55
56
|
| { type: "oauth"; provider: string };
|
|
56
57
|
|
|
57
58
|
/**
|
|
@@ -114,7 +115,7 @@ async function trySource(
|
|
|
114
115
|
return tryConfig(source.key, opts.config);
|
|
115
116
|
}
|
|
116
117
|
case "keychain": {
|
|
117
|
-
return tryKeychain(source.providerId, opts.store);
|
|
118
|
+
return tryKeychain(source.providerId, opts.store, source.accountId);
|
|
118
119
|
}
|
|
119
120
|
case "oauth": {
|
|
120
121
|
// OAuth is never auto-triggered during credential resolution.
|
|
@@ -168,9 +169,11 @@ function tryConfig(key: string, config?: Record<string, unknown>): string | null
|
|
|
168
169
|
async function tryKeychain(
|
|
169
170
|
providerId: string,
|
|
170
171
|
store: SecretStore,
|
|
172
|
+
accountId?: string,
|
|
171
173
|
): Promise<string | null> {
|
|
172
174
|
try {
|
|
173
|
-
const
|
|
175
|
+
const key = accountStorageKey(providerId, accountId);
|
|
176
|
+
const value = await store.get(key);
|
|
174
177
|
if (typeof value === "string" && value.trim().length > 0) {
|
|
175
178
|
return value.trim();
|
|
176
179
|
}
|
|
@@ -72,4 +72,41 @@ describe("setProviderToken", () => {
|
|
|
72
72
|
rmSync(dir, { recursive: true, force: true });
|
|
73
73
|
}
|
|
74
74
|
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("multi-account tokens", () => {
|
|
78
|
+
it("stores and retrieves per-account tokens with backward compatibility", async () => {
|
|
79
|
+
const dir = mkdtempSync(join(tmpdir(), "offrouter-keychain-test-"));
|
|
80
|
+
try {
|
|
81
|
+
const store = new FileSecretStore({ filePath: join(dir, "secrets.json") });
|
|
82
|
+
await setProviderToken("anthropic", "tok-legacy", store);
|
|
83
|
+
await setProviderToken("anthropic", "tok-2", store, "anthropic-2");
|
|
84
|
+
|
|
85
|
+
const legacy = await getProviderToken("anthropic", store);
|
|
86
|
+
expect(legacy!.rawValue).toBe("tok-legacy");
|
|
87
|
+
|
|
88
|
+
const acct2 = await getProviderToken("anthropic", store, "anthropic-2");
|
|
89
|
+
expect(acct2!.rawValue).toBe("tok-2");
|
|
90
|
+
|
|
91
|
+
// accountId === providerId uses legacy key
|
|
92
|
+
const explicitDefault = await getProviderToken("anthropic", store, "anthropic");
|
|
93
|
+
expect(explicitDefault!.rawValue).toBe("tok-legacy");
|
|
94
|
+
} finally {
|
|
95
|
+
rmSync(dir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("setProviderToken with accountId matching providerId overwrites legacy slot", async () => {
|
|
100
|
+
const dir = mkdtempSync(join(tmpdir(), "offrouter-keychain-test-"));
|
|
101
|
+
try {
|
|
102
|
+
const store = new FileSecretStore({ filePath: join(dir, "secrets.json") });
|
|
103
|
+
await setProviderToken("anthropic", "tok-legacy", store);
|
|
104
|
+
await setProviderToken("anthropic", "tok-implicit", store, "anthropic");
|
|
105
|
+
|
|
106
|
+
const result = await getProviderToken("anthropic", store);
|
|
107
|
+
expect(result!.rawValue).toBe("tok-implicit");
|
|
108
|
+
} finally {
|
|
109
|
+
rmSync(dir, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
75
112
|
});
|
package/src/auth/keychain.ts
CHANGED
|
@@ -3,10 +3,24 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Provides getProviderToken/setProviderToken that use the SecretStore
|
|
5
5
|
* abstraction (file or OS keychain). Never scrapes other CLI credential stores.
|
|
6
|
+
*
|
|
7
|
+
* Per-account token storage: when accountId is provided and differs from
|
|
8
|
+
* providerId, the storage key is "${providerId}:${accountId}". Otherwise the
|
|
9
|
+
* plain providerId is used for backward compatibility with existing tokens.
|
|
6
10
|
*/
|
|
7
11
|
import type { SecretStore } from "../secrets.js";
|
|
8
12
|
import { Redacted } from "./credential-chain.js";
|
|
9
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Compute the storage key for a provider+account combination.
|
|
16
|
+
* Backward compatible: when accountId is absent or equals providerId, returns
|
|
17
|
+
* the plain providerId so existing tokens resolve.
|
|
18
|
+
*/
|
|
19
|
+
export function accountStorageKey(providerId: string, accountId?: string): string {
|
|
20
|
+
if (!accountId || accountId === providerId) return providerId;
|
|
21
|
+
return `${providerId}:${accountId}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
/**
|
|
11
25
|
* Retrieve a provider token from the secret store.
|
|
12
26
|
* Returns undefined if no token is stored or the store is unavailable.
|
|
@@ -14,9 +28,11 @@ import { Redacted } from "./credential-chain.js";
|
|
|
14
28
|
export async function getProviderToken(
|
|
15
29
|
providerId: string,
|
|
16
30
|
store: SecretStore,
|
|
31
|
+
accountId?: string,
|
|
17
32
|
): Promise<Redacted<string> | undefined> {
|
|
18
33
|
try {
|
|
19
|
-
const
|
|
34
|
+
const key = accountStorageKey(providerId, accountId);
|
|
35
|
+
const value = await store.get(key);
|
|
20
36
|
if (typeof value === "string" && value.length > 0) {
|
|
21
37
|
return new Redacted(value);
|
|
22
38
|
}
|
|
@@ -34,6 +50,8 @@ export async function setProviderToken(
|
|
|
34
50
|
providerId: string,
|
|
35
51
|
token: string,
|
|
36
52
|
store: SecretStore,
|
|
53
|
+
accountId?: string,
|
|
37
54
|
): Promise<void> {
|
|
38
|
-
|
|
55
|
+
const key = accountStorageKey(providerId, accountId);
|
|
56
|
+
await store.set(key, token);
|
|
39
57
|
}
|
package/src/config.test.ts
CHANGED
|
@@ -157,7 +157,7 @@ enabled = true
|
|
|
157
157
|
expect(config.policyDefaults.subscriptionFirst).toBe(true);
|
|
158
158
|
expect(config.policyDefaults.allowApiKeyFallback).toBe(true);
|
|
159
159
|
expect(config.providers).toEqual({
|
|
160
|
-
claude: { id: "claude", enabled: true },
|
|
160
|
+
claude: { id: "claude", enabled: true, accounts: [{ id: "claude" }] },
|
|
161
161
|
});
|
|
162
162
|
|
|
163
163
|
const allowed = evaluatePolicy(
|
|
@@ -323,7 +323,7 @@ enabled = true
|
|
|
323
323
|
"project-enabled",
|
|
324
324
|
]);
|
|
325
325
|
expect(trusted.providers).toEqual({
|
|
326
|
-
"project-agent": { id: "project-agent", enabled: true },
|
|
326
|
+
"project-agent": { id: "project-agent", enabled: true, accounts: [{ id: "project-agent" }] },
|
|
327
327
|
});
|
|
328
328
|
expect(trusted.sources).toContain(
|
|
329
329
|
join(project, ".offrouter", "config.toml"),
|
|
@@ -412,4 +412,52 @@ weird_flag = 1
|
|
|
412
412
|
expect(config.sources).toEqual([]);
|
|
413
413
|
expect(config.warnings).toEqual([]);
|
|
414
414
|
});
|
|
415
|
+
|
|
416
|
+
describe("multi-account provider config", () => {
|
|
417
|
+
it("parses TOML with explicit accounts array", async () => {
|
|
418
|
+
const home = await trackDir(makeHome);
|
|
419
|
+
await writeFile(
|
|
420
|
+
join(home, "config.toml"),
|
|
421
|
+
`
|
|
422
|
+
allowlisted_profiles = ["claude-personal"]
|
|
423
|
+
|
|
424
|
+
[[providers.anthropic.accounts]]
|
|
425
|
+
id = "anthropic-1"
|
|
426
|
+
label = "Work Account"
|
|
427
|
+
|
|
428
|
+
[[providers.anthropic.accounts]]
|
|
429
|
+
id = "anthropic-2"
|
|
430
|
+
label = "Personal Account"
|
|
431
|
+
`,
|
|
432
|
+
"utf8",
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const config = await loadConfig({ env: { OFFROUTER_HOME: home } });
|
|
436
|
+
expect(config.providers.anthropic).toBeDefined();
|
|
437
|
+
expect(config.providers.anthropic.accounts).toEqual([
|
|
438
|
+
{ id: "anthropic-1", label: "Work Account" },
|
|
439
|
+
{ id: "anthropic-2", label: "Personal Account" },
|
|
440
|
+
]);
|
|
441
|
+
expect(config.providers.anthropic.enabled).toBe(true);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("creates implicit account when no accounts array is present", async () => {
|
|
445
|
+
const home = await trackDir(makeHome);
|
|
446
|
+
await writeFile(
|
|
447
|
+
join(home, "config.toml"),
|
|
448
|
+
`
|
|
449
|
+
allowlisted_profiles = ["codex-personal"]
|
|
450
|
+
|
|
451
|
+
[providers.openai]
|
|
452
|
+
enabled = true
|
|
453
|
+
`,
|
|
454
|
+
"utf8",
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
const config = await loadConfig({ env: { OFFROUTER_HOME: home } });
|
|
458
|
+
expect(config.providers.openai).toBeDefined();
|
|
459
|
+
expect(config.providers.openai.accounts).toEqual([{ id: "openai" }]);
|
|
460
|
+
expect(config.providers.openai.enabled).toBe(true);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
415
463
|
});
|
package/src/config.ts
CHANGED
|
@@ -20,6 +20,7 @@ export interface PolicyDefaults {
|
|
|
20
20
|
export interface ConfiguredProvider {
|
|
21
21
|
id: string;
|
|
22
22
|
enabled: boolean;
|
|
23
|
+
accounts: ConfiguredAccount[];
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
/** Named profile overlay loaded from profiles/*.toml. */
|
|
@@ -30,6 +31,12 @@ export interface ConfiguredProfile {
|
|
|
30
31
|
subscriptionFirst?: boolean;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/** Single configured account within a provider. */
|
|
35
|
+
export interface ConfiguredAccount {
|
|
36
|
+
id: string;
|
|
37
|
+
label?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
export interface ConfigWarning {
|
|
34
41
|
filePath: string;
|
|
35
42
|
path: string;
|
|
@@ -94,6 +101,9 @@ function allowedFromZodShape(shape: z.ZodRawShape): AllowedShape {
|
|
|
94
101
|
|
|
95
102
|
const ProviderConfigShape = {
|
|
96
103
|
enabled: z.boolean().optional(),
|
|
104
|
+
accounts: z
|
|
105
|
+
.array(z.object({ id: z.string().min(1), label: z.string().optional() }).strict())
|
|
106
|
+
.optional(),
|
|
97
107
|
} satisfies z.ZodRawShape;
|
|
98
108
|
|
|
99
109
|
const PolicySectionShape = {
|
|
@@ -122,7 +132,10 @@ const ProfileFileShape = {
|
|
|
122
132
|
const RootConfigSchema = z.object(RootConfigShape).strict();
|
|
123
133
|
const ProfileFileSchema = z.object(ProfileFileShape).strict();
|
|
124
134
|
|
|
125
|
-
const PROVIDER_ALLOWED: AllowedShape =
|
|
135
|
+
const PROVIDER_ALLOWED: AllowedShape = {
|
|
136
|
+
...allowedFromZodShape(ProviderConfigShape),
|
|
137
|
+
accounts: "leaf",
|
|
138
|
+
};
|
|
126
139
|
const ROOT_ALLOWED: AllowedShape = {
|
|
127
140
|
...allowedFromZodShape(RootConfigShape),
|
|
128
141
|
policy: allowedFromZodShape(PolicySectionShape),
|
|
@@ -365,9 +378,13 @@ function mergeRootInto(
|
|
|
365
378
|
|
|
366
379
|
if (root.providers) {
|
|
367
380
|
for (const [id, provider] of Object.entries(root.providers)) {
|
|
381
|
+
const accounts: ConfiguredAccount[] = provider.accounts
|
|
382
|
+
? provider.accounts.map((a: { id: string; label?: string }) => ({ id: a.id, label: a.label }))
|
|
383
|
+
: [{ id }];
|
|
368
384
|
target.providers[id] = {
|
|
369
385
|
id,
|
|
370
386
|
enabled: provider.enabled ?? true,
|
|
387
|
+
accounts,
|
|
371
388
|
};
|
|
372
389
|
}
|
|
373
390
|
}
|
package/src/index.ts
CHANGED
|
@@ -83,6 +83,7 @@ export {
|
|
|
83
83
|
resolveOffRouterHome,
|
|
84
84
|
} from "./config.js";
|
|
85
85
|
export type {
|
|
86
|
+
ConfiguredAccount,
|
|
86
87
|
ConfiguredProfile,
|
|
87
88
|
ConfiguredProvider,
|
|
88
89
|
ConfigWarning,
|
|
@@ -117,8 +118,9 @@ export type {
|
|
|
117
118
|
MarkCancelledOptions,
|
|
118
119
|
} from "./audit.js";
|
|
119
120
|
|
|
120
|
-
export { InMemoryStateStore, InMemoryUsageStore } from "./usage.js";
|
|
121
|
+
export { DEFAULT_NEAR_LIMIT_THRESHOLD, InMemoryStateStore, InMemoryUsageStore } from "./usage.js";
|
|
121
122
|
export type {
|
|
123
|
+
AccountUsageSnapshot,
|
|
122
124
|
InMemoryUsageStoreOptions,
|
|
123
125
|
PaidFallbackExplanation,
|
|
124
126
|
ProviderUsageSnapshot,
|
package/src/policy.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface PolicyConfig {
|
|
|
25
25
|
allowThirdPartySubscriptionAdapters?: boolean;
|
|
26
26
|
/** Optional explicit provider deny list by providerId. */
|
|
27
27
|
deniedProviders?: string[];
|
|
28
|
+
/** Fraction of subscription quota remaining at/under which an account is "near-limit". Default 0.2. */
|
|
29
|
+
nearLimitThreshold?: number;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
const HEALTHY_SUBSCRIPTION_STATUSES: ReadonlySet<SubscriptionStatus> = new Set([
|
package/src/providers/adapter.ts
CHANGED
|
@@ -209,6 +209,7 @@ export interface AuthStatus {
|
|
|
209
209
|
subscriptionStatus?: SubscriptionStatus;
|
|
210
210
|
limitsDiscoverable: boolean;
|
|
211
211
|
health: ProviderHealth;
|
|
212
|
+
accountId?: string;
|
|
212
213
|
detail?: string;
|
|
213
214
|
/**
|
|
214
215
|
* Which credential source satisfied auth (if any).
|
|
@@ -231,6 +232,7 @@ export interface LimitSnapshot {
|
|
|
231
232
|
limit?: number;
|
|
232
233
|
resetAt?: string;
|
|
233
234
|
unit?: string;
|
|
235
|
+
accountId?: string;
|
|
234
236
|
metadata?: Record<string, string | number | boolean | null>;
|
|
235
237
|
}
|
|
236
238
|
|
|
@@ -320,5 +322,7 @@ export function capabilityToCandidate(
|
|
|
320
322
|
contextWindowTokens: capability.contextWindowTokens,
|
|
321
323
|
maxOutputTokens: capability.maxOutputTokens,
|
|
322
324
|
estimatedCostUsd: extras.estimatedCostUsd ?? capability.estimatedCostUsd,
|
|
325
|
+
accountId: extras.accountId,
|
|
326
|
+
subscriptionQuotaPercentRemaining: extras.subscriptionQuotaPercentRemaining,
|
|
323
327
|
};
|
|
324
328
|
}
|
package/src/router.test.ts
CHANGED
|
@@ -559,4 +559,141 @@ describe("routeRequest", () => {
|
|
|
559
559
|
expect(decision.auditSummary).not.toContain("raw user text");
|
|
560
560
|
}
|
|
561
561
|
});
|
|
562
|
+
|
|
563
|
+
describe("multi-account routing", () => {
|
|
564
|
+
it("selects first healthy subscription when two accounts have same rank", () => {
|
|
565
|
+
const decision = routeRequest(
|
|
566
|
+
baseRequest({
|
|
567
|
+
constraints: { subscriptionFirst: true, allowApiKeyFallback: false },
|
|
568
|
+
}),
|
|
569
|
+
[
|
|
570
|
+
candidate({
|
|
571
|
+
providerId: "anthropic",
|
|
572
|
+
modelId: "sonnet",
|
|
573
|
+
authTier: "subscription",
|
|
574
|
+
subscriptionStatus: "active",
|
|
575
|
+
health: "healthy",
|
|
576
|
+
accountId: "anthropic-1",
|
|
577
|
+
estimatedCostUsd: 0,
|
|
578
|
+
}),
|
|
579
|
+
candidate({
|
|
580
|
+
providerId: "anthropic",
|
|
581
|
+
modelId: "sonnet",
|
|
582
|
+
authTier: "subscription",
|
|
583
|
+
subscriptionStatus: "active",
|
|
584
|
+
health: "healthy",
|
|
585
|
+
accountId: "anthropic-2",
|
|
586
|
+
estimatedCostUsd: 0,
|
|
587
|
+
}),
|
|
588
|
+
],
|
|
589
|
+
personalConfig,
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
expect(decision.blocked).toBeFalsy();
|
|
593
|
+
// Same rankKey, stable sort by input order: first candidate wins
|
|
594
|
+
expect(decision.route?.accountId).toBe("anthropic-1");
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("skips exhausted account and picks the next healthy one", () => {
|
|
598
|
+
const decision = routeRequest(
|
|
599
|
+
baseRequest({
|
|
600
|
+
constraints: { subscriptionFirst: true, allowApiKeyFallback: true },
|
|
601
|
+
}),
|
|
602
|
+
[
|
|
603
|
+
candidate({
|
|
604
|
+
providerId: "anthropic",
|
|
605
|
+
modelId: "sonnet",
|
|
606
|
+
authTier: "subscription",
|
|
607
|
+
subscriptionStatus: "exhausted",
|
|
608
|
+
health: "healthy",
|
|
609
|
+
accountId: "anthropic-1",
|
|
610
|
+
estimatedCostUsd: 0,
|
|
611
|
+
}),
|
|
612
|
+
candidate({
|
|
613
|
+
providerId: "anthropic",
|
|
614
|
+
modelId: "sonnet",
|
|
615
|
+
authTier: "subscription",
|
|
616
|
+
subscriptionStatus: "active",
|
|
617
|
+
health: "healthy",
|
|
618
|
+
accountId: "anthropic-2",
|
|
619
|
+
estimatedCostUsd: 0,
|
|
620
|
+
}),
|
|
621
|
+
],
|
|
622
|
+
personalConfig,
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
expect(decision.blocked).toBeFalsy();
|
|
626
|
+
expect(decision.route?.accountId).toBe("anthropic-2");
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("prefers healthy active account over near-limit account", () => {
|
|
630
|
+
const decision = routeRequest(
|
|
631
|
+
baseRequest({
|
|
632
|
+
constraints: { subscriptionFirst: true, allowApiKeyFallback: false },
|
|
633
|
+
}),
|
|
634
|
+
[
|
|
635
|
+
candidate({
|
|
636
|
+
providerId: "anthropic",
|
|
637
|
+
modelId: "sonnet",
|
|
638
|
+
authTier: "subscription",
|
|
639
|
+
subscriptionStatus: "near-limit",
|
|
640
|
+
subscriptionQuotaPercentRemaining: 15,
|
|
641
|
+
health: "healthy",
|
|
642
|
+
accountId: "anthropic-1",
|
|
643
|
+
estimatedCostUsd: 0,
|
|
644
|
+
}),
|
|
645
|
+
candidate({
|
|
646
|
+
providerId: "anthropic",
|
|
647
|
+
modelId: "sonnet",
|
|
648
|
+
authTier: "subscription",
|
|
649
|
+
subscriptionStatus: "active",
|
|
650
|
+
health: "healthy",
|
|
651
|
+
accountId: "anthropic-2",
|
|
652
|
+
estimatedCostUsd: 0,
|
|
653
|
+
}),
|
|
654
|
+
],
|
|
655
|
+
personalConfig,
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
expect(decision.blocked).toBeFalsy();
|
|
659
|
+
expect(decision.route?.accountId).toBe("anthropic-2");
|
|
660
|
+
expect(decision.nearLimitWarning).toBeUndefined();
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("selects near-limit subscription when no healthy alternative exists and warns", () => {
|
|
664
|
+
const decision = routeRequest(
|
|
665
|
+
baseRequest({
|
|
666
|
+
constraints: { subscriptionFirst: true, allowApiKeyFallback: true },
|
|
667
|
+
}),
|
|
668
|
+
[
|
|
669
|
+
candidate({
|
|
670
|
+
providerId: "anthropic",
|
|
671
|
+
modelId: "sonnet",
|
|
672
|
+
authTier: "subscription",
|
|
673
|
+
subscriptionStatus: "near-limit",
|
|
674
|
+
subscriptionQuotaPercentRemaining: 15,
|
|
675
|
+
health: "healthy",
|
|
676
|
+
accountId: "anthropic-1",
|
|
677
|
+
estimatedCostUsd: 0,
|
|
678
|
+
}),
|
|
679
|
+
candidate({
|
|
680
|
+
providerId: "openrouter",
|
|
681
|
+
modelId: "fast",
|
|
682
|
+
authTier: "api-key",
|
|
683
|
+
authScope: "third-party",
|
|
684
|
+
estimatedCostUsd: 0.01,
|
|
685
|
+
}),
|
|
686
|
+
],
|
|
687
|
+
personalConfig,
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
expect(decision.blocked).toBeFalsy();
|
|
691
|
+
expect(decision.route?.accountId).toBe("anthropic-1");
|
|
692
|
+
expect(decision.nearLimitWarning).toBeTruthy();
|
|
693
|
+
expect(decision.nearLimitWarning).toContain("near its limit");
|
|
694
|
+
expect(decision.explanation).toContain("near its limit");
|
|
695
|
+
expect(decision.route?.provider).toBe("anthropic");
|
|
696
|
+
expect(decision.route?.model).toBe("sonnet");
|
|
697
|
+
});
|
|
698
|
+
});
|
|
562
699
|
});
|
package/src/router.ts
CHANGED
|
@@ -23,17 +23,18 @@ function candidateId(c: ProviderCandidate): string {
|
|
|
23
23
|
/**
|
|
24
24
|
* Rank score: higher is better.
|
|
25
25
|
* Order of preference:
|
|
26
|
-
* 1. Healthy subscription (
|
|
27
|
-
* 2.
|
|
28
|
-
* 3.
|
|
29
|
-
* 4.
|
|
26
|
+
* 1. Healthy active subscription (status !== "near-limit")
|
|
27
|
+
* 2. Healthy near-limit subscription
|
|
28
|
+
* 3. Local
|
|
29
|
+
* 4. Degraded subscription (active + degraded)
|
|
30
|
+
* 5. API-key
|
|
30
31
|
* Within same tier: lower estimatedCostUsd, then providerId, then modelId.
|
|
31
32
|
*/
|
|
32
33
|
function rankKey(c: ProviderCandidate): [number, number, string, string] {
|
|
33
34
|
let tierRank: number;
|
|
34
35
|
if (c.authTier === "subscription") {
|
|
35
36
|
if (c.health === "healthy") {
|
|
36
|
-
tierRank = 400;
|
|
37
|
+
tierRank = c.subscriptionStatus === "near-limit" ? 350 : 400;
|
|
37
38
|
} else if (c.health === "degraded") {
|
|
38
39
|
tierRank = 200;
|
|
39
40
|
} else {
|
|
@@ -298,6 +299,32 @@ export function routeRequest(
|
|
|
298
299
|
fallbackChain[0] = { ...fallbackChain[0], reason: finalFallback };
|
|
299
300
|
}
|
|
300
301
|
|
|
302
|
+
// Compute near-limit warning
|
|
303
|
+
let nearLimitWarning: string | undefined;
|
|
304
|
+
if (
|
|
305
|
+
winner.authTier === "subscription" &&
|
|
306
|
+
winner.subscriptionStatus === "near-limit"
|
|
307
|
+
) {
|
|
308
|
+
const hasHealthyAlt = sorted.some(
|
|
309
|
+
(c) =>
|
|
310
|
+
c !== winner &&
|
|
311
|
+
c.authTier === "subscription" &&
|
|
312
|
+
c.health === "healthy" &&
|
|
313
|
+
c.subscriptionStatus !== "near-limit",
|
|
314
|
+
);
|
|
315
|
+
if (!hasHealthyAlt) {
|
|
316
|
+
const acct = winner.accountId ?? winner.providerId;
|
|
317
|
+
const pct = winner.subscriptionQuotaPercentRemaining;
|
|
318
|
+
const pctText =
|
|
319
|
+
pct !== undefined ? ` (${Math.round(pct)}% remaining)` : "";
|
|
320
|
+
nearLimitWarning = `Your ${winner.providerId} subscription (${acct}) is near its limit${pctText}. Consider switching or adding another account.`;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (nearLimitWarning) {
|
|
325
|
+
finalExplanation = `${finalExplanation} ${nearLimitWarning}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
301
328
|
return {
|
|
302
329
|
protocolVersion: ROUTE_PROTOCOL_VERSION,
|
|
303
330
|
decisionId,
|
|
@@ -307,6 +334,7 @@ export function routeRequest(
|
|
|
307
334
|
model: winner.modelId,
|
|
308
335
|
authTier: winner.authTier,
|
|
309
336
|
authScope: winner.authScope,
|
|
337
|
+
accountId: winner.accountId ?? winner.providerId,
|
|
310
338
|
},
|
|
311
339
|
reason: finalReason,
|
|
312
340
|
explanation: finalExplanation,
|
|
@@ -316,6 +344,7 @@ export function routeRequest(
|
|
|
316
344
|
auditSummary: `route=${winner.providerId}/${winner.modelId} tier=${winner.authTier} profile=${request.harness.profile} digest=${request.task.promptDigest}`,
|
|
317
345
|
blocked: false,
|
|
318
346
|
needsConfiguration: false,
|
|
347
|
+
nearLimitWarning,
|
|
319
348
|
};
|
|
320
349
|
}
|
|
321
350
|
|
package/src/schemas.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
ProviderHealthSchema,
|
|
9
9
|
RouteDecisionSchema,
|
|
10
10
|
RouteRequestSchema,
|
|
11
|
+
SelectedRouteSchema,
|
|
11
12
|
SubscriptionStatusSchema,
|
|
12
13
|
} from "./schemas.js";
|
|
13
14
|
import { ROUTE_PROTOCOL_VERSION } from "./protocol.js";
|
|
@@ -28,6 +29,7 @@ describe("routing vocabulary schemas", () => {
|
|
|
28
29
|
it("accepts all SubscriptionStatus values", () => {
|
|
29
30
|
for (const status of [
|
|
30
31
|
"active",
|
|
32
|
+
"near-limit",
|
|
31
33
|
"exhausted",
|
|
32
34
|
"rate-limited",
|
|
33
35
|
"expired",
|
|
@@ -237,4 +239,53 @@ describe("routing schemas", () => {
|
|
|
237
239
|
}),
|
|
238
240
|
).toThrow();
|
|
239
241
|
});
|
|
242
|
+
|
|
243
|
+
describe("multi-account schema fields", () => {
|
|
244
|
+
it("accepts ProviderCandidate with accountId and subscriptionQuotaPercentRemaining", () => {
|
|
245
|
+
const candidate = ProviderCandidateSchema.parse({
|
|
246
|
+
providerId: "anthropic",
|
|
247
|
+
modelId: "sonnet",
|
|
248
|
+
authTier: "subscription",
|
|
249
|
+
authScope: "first-party",
|
|
250
|
+
subscriptionStatus: "active",
|
|
251
|
+
accountId: "anthropic-1",
|
|
252
|
+
subscriptionQuotaPercentRemaining: 15,
|
|
253
|
+
health: "healthy",
|
|
254
|
+
supportsTools: true,
|
|
255
|
+
supportsStreaming: true,
|
|
256
|
+
supportsJson: true,
|
|
257
|
+
supportsImages: false,
|
|
258
|
+
});
|
|
259
|
+
expect(candidate.accountId).toBe("anthropic-1");
|
|
260
|
+
expect(candidate.subscriptionQuotaPercentRemaining).toBe(15);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("accepts near-limit SubscriptionStatus", () => {
|
|
264
|
+
expect(SubscriptionStatusSchema.parse("near-limit")).toBe("near-limit");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("accepts RouteDecision with nearLimitWarning", () => {
|
|
268
|
+
const decision = RouteDecisionSchema.parse({
|
|
269
|
+
protocolVersion: "offrouter.route.v1",
|
|
270
|
+
decisionId: "dec_nl",
|
|
271
|
+
requestId: "req_nl",
|
|
272
|
+
route: { provider: "anthropic", model: "sonnet", authTier: "subscription" },
|
|
273
|
+
reason: "subscription_primary",
|
|
274
|
+
explanation: "Selected near-limit subscription.",
|
|
275
|
+
nearLimitWarning:
|
|
276
|
+
"Your anthropic subscription (anthropic-1) is near its limit (15% remaining). Consider switching or adding another account.",
|
|
277
|
+
});
|
|
278
|
+
expect(decision.nearLimitWarning).toContain("near its limit");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("accepts SelectedRoute with accountId", () => {
|
|
282
|
+
const route = {
|
|
283
|
+
provider: "anthropic",
|
|
284
|
+
model: "sonnet",
|
|
285
|
+
authTier: "subscription",
|
|
286
|
+
accountId: "anthropic-1",
|
|
287
|
+
};
|
|
288
|
+
expect(() => SelectedRouteSchema.parse(route)).not.toThrow();
|
|
289
|
+
});
|
|
290
|
+
});
|
|
240
291
|
});
|
package/src/schemas.ts
CHANGED
|
@@ -15,6 +15,7 @@ export const AuthScopeSchema = z.enum([
|
|
|
15
15
|
|
|
16
16
|
export const SubscriptionStatusSchema = z.enum([
|
|
17
17
|
"active",
|
|
18
|
+
"near-limit",
|
|
18
19
|
"exhausted",
|
|
19
20
|
"rate-limited",
|
|
20
21
|
"expired",
|
|
@@ -98,6 +99,8 @@ export const ProviderCandidateSchema = z
|
|
|
98
99
|
authScope: AuthScopeSchema,
|
|
99
100
|
subscriptionStatus: SubscriptionStatusSchema.optional(),
|
|
100
101
|
health: ProviderHealthSchema,
|
|
102
|
+
accountId: z.string().min(1).optional(),
|
|
103
|
+
subscriptionQuotaPercentRemaining: z.number().min(0).max(100).optional(),
|
|
101
104
|
supportsTools: z.boolean(),
|
|
102
105
|
supportsStreaming: z.boolean(),
|
|
103
106
|
supportsJson: z.boolean(),
|
|
@@ -166,6 +169,7 @@ export const SelectedRouteSchema = z
|
|
|
166
169
|
model: z.string().min(1),
|
|
167
170
|
authTier: AuthTierSchema,
|
|
168
171
|
authScope: AuthScopeSchema.optional(),
|
|
172
|
+
accountId: z.string().min(1).optional(),
|
|
169
173
|
})
|
|
170
174
|
.strict();
|
|
171
175
|
|
|
@@ -192,6 +196,7 @@ export const RouteDecisionSchema = z
|
|
|
192
196
|
auditSummary: z.string().optional(),
|
|
193
197
|
blocked: z.boolean().optional(),
|
|
194
198
|
needsConfiguration: z.boolean().optional(),
|
|
199
|
+
nearLimitWarning: z.string().optional(),
|
|
195
200
|
})
|
|
196
201
|
.strict();
|
|
197
202
|
|
package/src/types.ts
CHANGED
|
@@ -9,6 +9,7 @@ export type AuthScope = "first-party" | "third-party" | "unknown";
|
|
|
9
9
|
|
|
10
10
|
export type SubscriptionStatus =
|
|
11
11
|
| "active"
|
|
12
|
+
| "near-limit"
|
|
12
13
|
| "exhausted"
|
|
13
14
|
| "rate-limited"
|
|
14
15
|
| "expired"
|
|
@@ -73,6 +74,8 @@ export interface ProviderCandidate {
|
|
|
73
74
|
authScope: AuthScope;
|
|
74
75
|
subscriptionStatus?: SubscriptionStatus;
|
|
75
76
|
health: ProviderHealth;
|
|
77
|
+
accountId?: string;
|
|
78
|
+
subscriptionQuotaPercentRemaining?: number;
|
|
76
79
|
supportsTools: boolean;
|
|
77
80
|
supportsStreaming: boolean;
|
|
78
81
|
supportsJson: boolean;
|
|
@@ -129,6 +132,7 @@ export interface SelectedRoute {
|
|
|
129
132
|
model: string;
|
|
130
133
|
authTier: AuthTier;
|
|
131
134
|
authScope?: AuthScope;
|
|
135
|
+
accountId?: string;
|
|
132
136
|
}
|
|
133
137
|
|
|
134
138
|
export interface FallbackChainEntry {
|
|
@@ -155,6 +159,8 @@ export interface RouteDecision {
|
|
|
155
159
|
auditSummary?: string;
|
|
156
160
|
blocked?: boolean;
|
|
157
161
|
needsConfiguration?: boolean;
|
|
162
|
+
/** Warning when the selected route is a near-limit subscription with no healthy alternative. */
|
|
163
|
+
nearLimitWarning?: string;
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
/**
|
package/src/usage.test.ts
CHANGED
|
@@ -281,3 +281,55 @@ describe("InMemoryStateStore", () => {
|
|
|
281
281
|
expect(JSON.stringify(json)).not.toMatch(/sk-/);
|
|
282
282
|
});
|
|
283
283
|
});
|
|
284
|
+
|
|
285
|
+
describe("near-limit / per-account usage", () => {
|
|
286
|
+
it("recordUsage returns near-limit snapshot when quota is nearly consumed", async () => {
|
|
287
|
+
const store = new InMemoryUsageStore();
|
|
288
|
+
const snap = await store.recordUsage("anthropic", "anthropic-1", { used: 85, limit: 100 });
|
|
289
|
+
expect(snap.remaining).toBe(15);
|
|
290
|
+
expect(snap.percentRemaining).toBe(15);
|
|
291
|
+
expect(snap.nearLimit).toBe(true);
|
|
292
|
+
expect(snap.subscriptionStatus).toBe("near-limit");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("isNearLimit respects custom threshold", async () => {
|
|
296
|
+
const store = new InMemoryUsageStore();
|
|
297
|
+
await store.recordUsage("anthropic", "anthropic-1", { used: 85, limit: 100 });
|
|
298
|
+
expect(await store.isNearLimit("anthropic", "anthropic-1")).toBe(true);
|
|
299
|
+
expect(await store.isNearLimit("anthropic", "anthropic-1", 0.1)).toBe(false);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("recordUsage detects exhaustion", async () => {
|
|
303
|
+
const store = new InMemoryUsageStore();
|
|
304
|
+
const snap = await store.recordUsage("anthropic", "anthropic-2", { used: 100, limit: 100 });
|
|
305
|
+
expect(snap.nearLimit).toBe(false);
|
|
306
|
+
expect(snap.subscriptionStatus).toBe("exhausted");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("getNearLimitAccounts returns only accounts near their limit", async () => {
|
|
310
|
+
const store = new InMemoryUsageStore();
|
|
311
|
+
await store.recordUsage("anthropic", "anthropic-1", { used: 85, limit: 100 });
|
|
312
|
+
await store.recordUsage("anthropic", "anthropic-2", { used: 100, limit: 100 });
|
|
313
|
+
const nearLimit = await store.getNearLimitAccounts();
|
|
314
|
+
expect(nearLimit).toHaveLength(1);
|
|
315
|
+
expect(nearLimit[0]!.accountId).toBe("anthropic-1");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("getAccountUsage returns correct snapshots for different accounts", async () => {
|
|
319
|
+
const store = new InMemoryUsageStore();
|
|
320
|
+
await store.recordUsage("anthropic", "anthropic-1", { used: 85, limit: 100 });
|
|
321
|
+
await store.recordUsage("anthropic", "anthropic-2", { used: 10, limit: 100 });
|
|
322
|
+
const snap1 = await store.getAccountUsage("anthropic", "anthropic-1");
|
|
323
|
+
const snap2 = await store.getAccountUsage("anthropic", "anthropic-2");
|
|
324
|
+
expect(snap1?.used).toBe(85);
|
|
325
|
+
expect(snap2?.used).toBe(10);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("existing setSubscriptionQuota and record still behave as before", async () => {
|
|
329
|
+
const store = new InMemoryUsageStore();
|
|
330
|
+
await store.setSubscriptionQuota("test", { limit: 50, used: 10, status: "active" });
|
|
331
|
+
const snap = await store.getProvider("test");
|
|
332
|
+
expect(snap?.subscriptionQuotaLimit).toBe(50);
|
|
333
|
+
expect(snap?.subscriptionQuotaUsed).toBe(10);
|
|
334
|
+
});
|
|
335
|
+
});
|
package/src/usage.ts
CHANGED
|
@@ -104,6 +104,23 @@ export interface RecordUsageResult {
|
|
|
104
104
|
previousHealth: ProviderHealth;
|
|
105
105
|
}
|
|
106
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
|
+
updatedAt: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
107
124
|
/**
|
|
108
125
|
* Why paid API-key capacity was or was not selected given current usage state.
|
|
109
126
|
* Consumed by doctor / status / route explain surfaces.
|
|
@@ -154,6 +171,35 @@ export interface UsageStore {
|
|
|
154
171
|
options?: { hasApiKeyCandidate?: boolean },
|
|
155
172
|
): Promise<PaidFallbackExplanation>;
|
|
156
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Record usage for a specific account within a provider.
|
|
176
|
+
*/
|
|
177
|
+
recordUsage(
|
|
178
|
+
providerId: string,
|
|
179
|
+
accountId: string,
|
|
180
|
+
input: { used?: number; limit?: number; at?: Date },
|
|
181
|
+
): Promise<AccountUsageSnapshot>;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Check whether a specific account is near its quota limit.
|
|
185
|
+
*/
|
|
186
|
+
isNearLimit(providerId: string, accountId: string, threshold?: number): Promise<boolean>;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get all accounts whose usage is near their quota limit.
|
|
190
|
+
*/
|
|
191
|
+
getNearLimitAccounts(threshold?: number): Promise<AccountUsageSnapshot[]>;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get the usage snapshot for a specific account.
|
|
195
|
+
*/
|
|
196
|
+
getAccountUsage(providerId: string, accountId: string): Promise<AccountUsageSnapshot | undefined>;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* List all account usage snapshots.
|
|
200
|
+
*/
|
|
201
|
+
listAccountUsage(): Promise<AccountUsageSnapshot[]>;
|
|
202
|
+
|
|
157
203
|
/**
|
|
158
204
|
* JSON-safe diagnostic view. No secrets.
|
|
159
205
|
*/
|
|
@@ -184,6 +230,14 @@ interface InternalProviderState {
|
|
|
184
230
|
updatedAt: string;
|
|
185
231
|
}
|
|
186
232
|
|
|
233
|
+
interface AccountQuotaState {
|
|
234
|
+
providerId: string;
|
|
235
|
+
accountId: string;
|
|
236
|
+
used: number;
|
|
237
|
+
limit?: number;
|
|
238
|
+
updatedAt: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
187
241
|
const DEFAULT_WINDOW_MS = 60_000;
|
|
188
242
|
const DEFAULT_HEALTHY_MAX_ERRORS = 2;
|
|
189
243
|
const DEFAULT_DEAD_MIN_ERRORS = 5;
|
|
@@ -200,6 +254,7 @@ const HARD_ERROR_KINDS: ReadonlySet<UsageEventKind> = new Set([
|
|
|
200
254
|
*/
|
|
201
255
|
export class InMemoryUsageStore implements UsageStore {
|
|
202
256
|
readonly #providers = new Map<string, InternalProviderState>();
|
|
257
|
+
readonly #accounts = new Map<string, AccountQuotaState>();
|
|
203
258
|
readonly #windowMs: number;
|
|
204
259
|
readonly #healthyMaxErrors: number;
|
|
205
260
|
readonly #deadMinErrors: number;
|
|
@@ -505,6 +560,78 @@ export class InMemoryUsageStore implements UsageStore {
|
|
|
505
560
|
};
|
|
506
561
|
}
|
|
507
562
|
|
|
563
|
+
async recordUsage(
|
|
564
|
+
providerId: string,
|
|
565
|
+
accountId: string,
|
|
566
|
+
input: { used?: number; limit?: number; at?: Date },
|
|
567
|
+
): Promise<AccountUsageSnapshot> {
|
|
568
|
+
return this.#exclusive(() => {
|
|
569
|
+
const at = input.at ?? this.#now();
|
|
570
|
+
const key = `${providerId}:${accountId}`;
|
|
571
|
+
let state = this.#accounts.get(key);
|
|
572
|
+
if (!state) {
|
|
573
|
+
state = { providerId, accountId, used: 0, updatedAt: at.toISOString() };
|
|
574
|
+
this.#accounts.set(key, state);
|
|
575
|
+
}
|
|
576
|
+
if (input.used !== undefined) state.used = input.used;
|
|
577
|
+
if (input.limit !== undefined) state.limit = input.limit;
|
|
578
|
+
state.updatedAt = at.toISOString();
|
|
579
|
+
return toAccountSnapshot(state);
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async isNearLimit(
|
|
584
|
+
providerId: string,
|
|
585
|
+
accountId: string,
|
|
586
|
+
threshold?: number,
|
|
587
|
+
): Promise<boolean> {
|
|
588
|
+
return this.#exclusive(() => {
|
|
589
|
+
const key = `${providerId}:${accountId}`;
|
|
590
|
+
const state = this.#accounts.get(key);
|
|
591
|
+
if (!state) return false;
|
|
592
|
+
return toAccountSnapshot(state, threshold).nearLimit;
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async getNearLimitAccounts(threshold?: number): Promise<AccountUsageSnapshot[]> {
|
|
597
|
+
return this.#exclusive(() => {
|
|
598
|
+
const out: AccountUsageSnapshot[] = [];
|
|
599
|
+
for (const state of this.#accounts.values()) {
|
|
600
|
+
const snap = toAccountSnapshot(state, threshold);
|
|
601
|
+
if (snap.nearLimit) out.push(snap);
|
|
602
|
+
}
|
|
603
|
+
return out.sort((a, b) => {
|
|
604
|
+
if (a.providerId !== b.providerId) return a.providerId.localeCompare(b.providerId);
|
|
605
|
+
return a.accountId.localeCompare(b.accountId);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async getAccountUsage(
|
|
611
|
+
providerId: string,
|
|
612
|
+
accountId: string,
|
|
613
|
+
): Promise<AccountUsageSnapshot | undefined> {
|
|
614
|
+
return this.#exclusive(() => {
|
|
615
|
+
const key = `${providerId}:${accountId}`;
|
|
616
|
+
const state = this.#accounts.get(key);
|
|
617
|
+
if (!state) return undefined;
|
|
618
|
+
return toAccountSnapshot(state);
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async listAccountUsage(): Promise<AccountUsageSnapshot[]> {
|
|
623
|
+
return this.#exclusive(() => {
|
|
624
|
+
const out: AccountUsageSnapshot[] = [];
|
|
625
|
+
for (const state of this.#accounts.values()) {
|
|
626
|
+
out.push(toAccountSnapshot(state));
|
|
627
|
+
}
|
|
628
|
+
return out.sort((a, b) => {
|
|
629
|
+
if (a.providerId !== b.providerId) return a.providerId.localeCompare(b.providerId);
|
|
630
|
+
return a.accountId.localeCompare(b.accountId);
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
508
635
|
toJSON(): Record<string, unknown> {
|
|
509
636
|
const providers: Record<string, unknown> = {};
|
|
510
637
|
for (const [id, state] of this.#providers) {
|
|
@@ -523,6 +650,7 @@ export class InMemoryUsageStore implements UsageStore {
|
|
|
523
650
|
return {
|
|
524
651
|
kind: "in-memory-usage",
|
|
525
652
|
providers,
|
|
653
|
+
accounts: Object.fromEntries(this.#accounts),
|
|
526
654
|
};
|
|
527
655
|
}
|
|
528
656
|
|
|
@@ -635,6 +763,41 @@ function toSnapshot(state: InternalProviderState): ProviderUsageSnapshot {
|
|
|
635
763
|
};
|
|
636
764
|
}
|
|
637
765
|
|
|
766
|
+
function toAccountSnapshot(
|
|
767
|
+
state: AccountQuotaState,
|
|
768
|
+
threshold?: number,
|
|
769
|
+
): AccountUsageSnapshot {
|
|
770
|
+
const t = threshold ?? DEFAULT_NEAR_LIMIT_THRESHOLD;
|
|
771
|
+
const limit = state.limit;
|
|
772
|
+
const used = state.used;
|
|
773
|
+
const remaining = limit === undefined ? undefined : Math.max(0, limit - used);
|
|
774
|
+
let status: SubscriptionStatus;
|
|
775
|
+
let nearLimit = false;
|
|
776
|
+
if (limit === undefined) {
|
|
777
|
+
status = "unknown";
|
|
778
|
+
} else if (used >= limit) {
|
|
779
|
+
status = "exhausted";
|
|
780
|
+
} else if ((limit - used) / limit < t) {
|
|
781
|
+
status = "near-limit";
|
|
782
|
+
nearLimit = true;
|
|
783
|
+
} else {
|
|
784
|
+
status = "active";
|
|
785
|
+
}
|
|
786
|
+
const percentRemaining =
|
|
787
|
+
limit === undefined ? undefined : Math.round(((limit - used) / limit) * 100);
|
|
788
|
+
return {
|
|
789
|
+
providerId: state.providerId,
|
|
790
|
+
accountId: state.accountId,
|
|
791
|
+
used,
|
|
792
|
+
limit,
|
|
793
|
+
remaining,
|
|
794
|
+
percentRemaining,
|
|
795
|
+
nearLimit,
|
|
796
|
+
subscriptionStatus: status,
|
|
797
|
+
updatedAt: state.updatedAt,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
638
801
|
/**
|
|
639
802
|
* Composite facade for callers that want one injectable "state" handle.
|
|
640
803
|
* Writes stay on the specialized stores; this is a thin convenience.
|