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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offrouter-core",
3
- "version": "0.0.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": "^1.7.0",
19
- "zod": "^3.23.0"
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 value = await store.get(providerId);
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
  });
@@ -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 value = await store.get(providerId);
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
- await store.set(providerId, token);
55
+ const key = accountStorageKey(providerId, accountId);
56
+ await store.set(key, token);
39
57
  }
@@ -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 = allowedFromZodShape(ProviderConfigShape);
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([
@@ -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
  }
@@ -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 (active + healthy health)
27
- * 2. Local
28
- * 3. Degraded subscription (active + degraded)
29
- * 4. API-key
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
 
@@ -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.