offrouter-core 0.0.0 → 0.2.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.
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Local OAuth callback server for OffRouter's live PKCE login flow.
3
+ *
4
+ * Binds to 127.0.0.1 ONLY (never 0.0.0.0) so the provider can redirect back
5
+ * to this machine without exposing the callback to the network. The server
6
+ * captures the authorization `code` + `state` from the redirect and hands them
7
+ * to the caller; it never touches tokens, secrets, or the network beyond the
8
+ * single loopback HTTP socket.
9
+ */
10
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
11
+ import { URL } from "node:url";
12
+
13
+ /** Loopback-only hostname. Hardcoded to prevent accidental 0.0.0.0 binds. */
14
+ const LOOPBACK_HOST = "127.0.0.1";
15
+
16
+ /** Path the provider redirects to (http://127.0.0.1:PORT/callback). */
17
+ const CALLBACK_PATH = "/callback";
18
+
19
+ export interface CallbackServerOptions {
20
+ /**
21
+ * State value expected from the provider. Validated in waitForCode against
22
+ * the state returned by the redirect; a mismatch rejects the promise.
23
+ * May also be supplied per-call to waitForCode.
24
+ */
25
+ expectedState?: string;
26
+ }
27
+
28
+ export interface WaitForCodeOptions {
29
+ /** Overrides the server-level expectedState for this call. */
30
+ expectedState?: string;
31
+ /** How long to wait for a callback before rejecting (default 5 minutes). */
32
+ timeoutMs?: number;
33
+ /** Optional abort signal to cancel the wait early. */
34
+ signal?: AbortSignal;
35
+ }
36
+
37
+ export interface CallbackCode {
38
+ code: string;
39
+ state: string;
40
+ }
41
+
42
+ export interface CallbackServerHandle {
43
+ /** Full redirect_uri to register with the provider (http://127.0.0.1:PORT/callback). */
44
+ url: string;
45
+ /** Actual bound port (useful when 0 was requested). */
46
+ port: number;
47
+ /** Resolve with {code, state} on a valid callback; reject on timeout/state mismatch. */
48
+ waitForCode(options?: WaitForCodeOptions): Promise<CallbackCode>;
49
+ /** Stop listening and free the port. Idempotent. */
50
+ close(): Promise<void>;
51
+ }
52
+
53
+ /**
54
+ * Start a loopback HTTP server that captures an OAuth authorization code.
55
+ * Pass port 0 to let the OS choose a free port (read handle.port afterwards).
56
+ */
57
+ export function startCallbackServer(
58
+ port: number,
59
+ options: CallbackServerOptions = {},
60
+ ): Promise<CallbackServerHandle> {
61
+ return new Promise((resolve, reject) => {
62
+ const serverLevelState = options.expectedState;
63
+
64
+ // Deferred for the first valid callback (has a `code` param).
65
+ let captured: CallbackCode | null = null;
66
+ const waiters: Array<{
67
+ resolve: (value: CallbackCode) => void;
68
+ reject: (error: Error) => void;
69
+ }> = [];
70
+
71
+ const httpServer: Server = createServer(
72
+ (req: IncomingMessage, res: ServerResponse) => {
73
+ handleCallback(req, res);
74
+ },
75
+ );
76
+
77
+ function handleCallback(req: IncomingMessage, res: ServerResponse): void {
78
+ // Only respond to GET on the callback path; everything else 404s.
79
+ if (req.method !== "GET") {
80
+ res.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
81
+ res.end("Method Not Allowed");
82
+ return;
83
+ }
84
+
85
+ let parsedUrl: URL;
86
+ try {
87
+ parsedUrl = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}`);
88
+ } catch {
89
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
90
+ res.end("Bad Request");
91
+ return;
92
+ }
93
+
94
+ const pathname = parsedUrl.pathname;
95
+ const code = parsedUrl.searchParams.get("code");
96
+ const state = parsedUrl.searchParams.get("state");
97
+ const error = parsedUrl.searchParams.get("error");
98
+
99
+ // OAuth error from the provider (e.g. user denied consent).
100
+ if (error) {
101
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
102
+ res.end(authorizationDeniedPage(error));
103
+ return;
104
+ }
105
+
106
+ if (pathname !== CALLBACK_PATH) {
107
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
108
+ res.end("Not Found");
109
+ return;
110
+ }
111
+
112
+ // Missing code -> provider returned nothing usable.
113
+ if (!code) {
114
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
115
+ res.end(missingCodePage());
116
+ return;
117
+ }
118
+
119
+ // Capture the first valid callback exactly once.
120
+ const callback: CallbackCode = { code, state: state ?? "" };
121
+ if (!captured) {
122
+ captured = callback;
123
+ // Resolve any pending waiters that accept this callback.
124
+ const pending = waiters.splice(0, waiters.length);
125
+ for (const w of pending) {
126
+ w.resolve(callback);
127
+ }
128
+ }
129
+
130
+ // Always show the user a friendly page; security validation happens in
131
+ // waitForCode (state may not be known at request time).
132
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
133
+ res.end(successPage());
134
+ }
135
+
136
+ httpServer.on("error", (err: NodeJS.ErrnoException) => {
137
+ reject(
138
+ new Error(
139
+ `Failed to start OAuth callback server on 127.0.0.1:${port}: ${err.message}`,
140
+ { cause: err },
141
+ ),
142
+ );
143
+ });
144
+
145
+ httpServer.listen(port, LOOPBACK_HOST, () => {
146
+ const address = httpServer.address();
147
+ const boundPort =
148
+ address && typeof address === "object" ? address.port : port;
149
+ const redirectUrl = `http://${LOOPBACK_HOST}:${boundPort}${CALLBACK_PATH}`;
150
+
151
+ const handle: CallbackServerHandle = {
152
+ url: redirectUrl,
153
+ port: boundPort,
154
+ waitForCode(waitOpts: WaitForCodeOptions = {}): Promise<CallbackCode> {
155
+ const expected =
156
+ waitOpts.expectedState !== undefined
157
+ ? waitOpts.expectedState
158
+ : serverLevelState;
159
+ const timeoutMs = waitOpts.timeoutMs ?? 5 * 60 * 1000;
160
+
161
+ return new Promise<CallbackCode>((resolveWait, rejectWait) => {
162
+ let settled = false;
163
+
164
+ const settle = (
165
+ outcome: CallbackCode | Error,
166
+ isReject: boolean,
167
+ ): void => {
168
+ if (settled) return;
169
+ settled = true;
170
+ clearTimeout(timer);
171
+ waitOpts.signal?.removeEventListener("abort", onAbort);
172
+ if (isReject) {
173
+ rejectWait(outcome as Error);
174
+ } else {
175
+ resolveWait(outcome as CallbackCode);
176
+ }
177
+ };
178
+
179
+ const onAbort = (): void => {
180
+ settle(
181
+ new Error("OAuth callback wait was aborted."),
182
+ true,
183
+ );
184
+ };
185
+
186
+ const timer = setTimeout(() => {
187
+ settle(new Error("OAuth login timed out waiting for callback."), true);
188
+ }, timeoutMs);
189
+
190
+ if (waitOpts.signal?.aborted) {
191
+ settle(new Error("OAuth callback wait was aborted."), true);
192
+ return;
193
+ }
194
+ waitOpts.signal?.addEventListener("abort", onAbort);
195
+
196
+ const resolveWith = (callback: CallbackCode): void => {
197
+ if (expected !== undefined && callback.state !== expected) {
198
+ settle(
199
+ new Error(
200
+ `OAuth state mismatch: expected "${redactState(expected)}" ` +
201
+ `but received "${redactState(callback.state)}". ` +
202
+ `This may indicate a CSRF attempt or a stale login tab.`,
203
+ ),
204
+ true,
205
+ );
206
+ return;
207
+ }
208
+ settle(callback, false);
209
+ };
210
+
211
+ if (captured) {
212
+ resolveWith(captured);
213
+ return;
214
+ }
215
+
216
+ waiters.push({ resolve: resolveWith, reject: rejectWait });
217
+ });
218
+ },
219
+ close(): Promise<void> {
220
+ return new Promise<void>((resolveClose) => {
221
+ httpServer.close(() => resolveClose());
222
+ // close() waits for in-flight connections; closeAllConnections is
223
+ // available on Node 18+ and speeds shutdown without dropping the
224
+ // response the user's browser is mid-receiving.
225
+ // (Not called here to keep the success page delivery intact.)
226
+ });
227
+ },
228
+ };
229
+
230
+ resolve(handle);
231
+ });
232
+ });
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Static HTML pages (ASCII-only, no secret material)
237
+ // ---------------------------------------------------------------------------
238
+
239
+ function successPage(): string {
240
+ return [
241
+ "<!doctype html>",
242
+ "<html lang=\"en\">",
243
+ "<head><meta charset=\"utf-8\"><title>OffRouter login</title></head>",
244
+ "<body>",
245
+ "<h1>OffRouter login authorized</h1>",
246
+ "<p>You can close this tab and return to your terminal.</p>",
247
+ "</body>",
248
+ "</html>",
249
+ "",
250
+ ].join("\n");
251
+ }
252
+
253
+ function authorizationDeniedPage(error: string): string {
254
+ const safe = escapeHtml(error);
255
+ return [
256
+ "<!doctype html>",
257
+ "<html lang=\"en\">",
258
+ "<head><meta charset=\"utf-8\"><title>OffRouter login</title></head>",
259
+ "<body>",
260
+ "<h1>OffRouter login not completed</h1>",
261
+ `<p>The provider reported: ${safe}</p>`,
262
+ "<p>You can close this tab and try again from your terminal.</p>",
263
+ "</body>",
264
+ "</html>",
265
+ "",
266
+ ].join("\n");
267
+ }
268
+
269
+ function missingCodePage(): string {
270
+ return [
271
+ "<!doctype html>",
272
+ "<html lang=\"en\">",
273
+ "<head><meta charset=\"utf-8\"><title>OffRouter login</title></head>",
274
+ "<body>",
275
+ "<h1>OffRouter login incomplete</h1>",
276
+ "<p>The callback did not include an authorization code.</p>",
277
+ "</body>",
278
+ "</html>",
279
+ "",
280
+ ].join("\n");
281
+ }
282
+
283
+ function escapeHtml(value: string): string {
284
+ return value
285
+ .replace(/&/g, "&amp;")
286
+ .replace(/</g, "&lt;")
287
+ .replace(/>/g, "&gt;")
288
+ .replace(/"/g, "&quot;")
289
+ .replace(/'/g, "&#39;");
290
+ }
291
+
292
+ /** Truncate a state token for error messages so it is never echoed in full. */
293
+ function redactState(state: string): string {
294
+ if (state.length <= 8) return "***";
295
+ return `${state.slice(0, 4)}...${state.slice(-4)}`;
296
+ }
@@ -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(
@@ -168,6 +168,22 @@ enabled = true
168
168
  expect(allowed.allowed).toHaveLength(1);
169
169
  });
170
170
 
171
+ it("parses policy.near_limit_threshold into policy.nearLimitThreshold", async () => {
172
+ const home = await trackDir(makeHome);
173
+ await writeFile(
174
+ join(home, "config.toml"),
175
+ `allowlisted_profiles = ["claude-personal"]
176
+
177
+ [policy]
178
+ near_limit_threshold = 0.15
179
+ `,
180
+ "utf8",
181
+ );
182
+
183
+ const config = await loadConfig({ env: { OFFROUTER_HOME: home } });
184
+ expect(config.policy.nearLimitThreshold).toBe(0.15);
185
+ });
186
+
171
187
  it("loads named profile overlays from profiles/*.toml", async () => {
172
188
  const home = await trackDir(makeHome);
173
189
  await writeFile(
@@ -323,7 +339,7 @@ enabled = true
323
339
  "project-enabled",
324
340
  ]);
325
341
  expect(trusted.providers).toEqual({
326
- "project-agent": { id: "project-agent", enabled: true },
342
+ "project-agent": { id: "project-agent", enabled: true, accounts: [{ id: "project-agent" }] },
327
343
  });
328
344
  expect(trusted.sources).toContain(
329
345
  join(project, ".offrouter", "config.toml"),
@@ -412,4 +428,52 @@ weird_flag = 1
412
428
  expect(config.sources).toEqual([]);
413
429
  expect(config.warnings).toEqual([]);
414
430
  });
431
+
432
+ describe("multi-account provider config", () => {
433
+ it("parses TOML with explicit accounts array", async () => {
434
+ const home = await trackDir(makeHome);
435
+ await writeFile(
436
+ join(home, "config.toml"),
437
+ `
438
+ allowlisted_profiles = ["claude-personal"]
439
+
440
+ [[providers.anthropic.accounts]]
441
+ id = "anthropic-1"
442
+ label = "Work Account"
443
+
444
+ [[providers.anthropic.accounts]]
445
+ id = "anthropic-2"
446
+ label = "Personal Account"
447
+ `,
448
+ "utf8",
449
+ );
450
+
451
+ const config = await loadConfig({ env: { OFFROUTER_HOME: home } });
452
+ expect(config.providers.anthropic).toBeDefined();
453
+ expect(config.providers.anthropic.accounts).toEqual([
454
+ { id: "anthropic-1", label: "Work Account" },
455
+ { id: "anthropic-2", label: "Personal Account" },
456
+ ]);
457
+ expect(config.providers.anthropic.enabled).toBe(true);
458
+ });
459
+
460
+ it("creates implicit account when no accounts array is present", async () => {
461
+ const home = await trackDir(makeHome);
462
+ await writeFile(
463
+ join(home, "config.toml"),
464
+ `
465
+ allowlisted_profiles = ["codex-personal"]
466
+
467
+ [providers.openai]
468
+ enabled = true
469
+ `,
470
+ "utf8",
471
+ );
472
+
473
+ const config = await loadConfig({ env: { OFFROUTER_HOME: home } });
474
+ expect(config.providers.openai).toBeDefined();
475
+ expect(config.providers.openai.accounts).toEqual([{ id: "openai" }]);
476
+ expect(config.providers.openai.enabled).toBe(true);
477
+ });
478
+ });
415
479
  });
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 = {
@@ -101,6 +111,7 @@ const PolicySectionShape = {
101
111
  allow_api_key_fallback: z.boolean().optional(),
102
112
  allow_third_party_subscription_adapters: z.boolean().optional(),
103
113
  denied_providers: z.array(z.string().min(1)).optional(),
114
+ near_limit_threshold: z.number().min(0).max(1).optional(),
104
115
  } satisfies z.ZodRawShape;
105
116
 
106
117
  const RootConfigShape = {
@@ -122,7 +133,10 @@ const ProfileFileShape = {
122
133
  const RootConfigSchema = z.object(RootConfigShape).strict();
123
134
  const ProfileFileSchema = z.object(ProfileFileShape).strict();
124
135
 
125
- const PROVIDER_ALLOWED: AllowedShape = allowedFromZodShape(ProviderConfigShape);
136
+ const PROVIDER_ALLOWED: AllowedShape = {
137
+ ...allowedFromZodShape(ProviderConfigShape),
138
+ accounts: "leaf",
139
+ };
126
140
  const ROOT_ALLOWED: AllowedShape = {
127
141
  ...allowedFromZodShape(RootConfigShape),
128
142
  policy: allowedFromZodShape(PolicySectionShape),
@@ -355,6 +369,9 @@ function mergeRootInto(
355
369
  target.policy.allowThirdPartySubscriptionAdapters =
356
370
  root.policy.allow_third_party_subscription_adapters;
357
371
  }
372
+ if (root.policy.near_limit_threshold !== undefined) {
373
+ target.policy.nearLimitThreshold = root.policy.near_limit_threshold;
374
+ }
358
375
  if (root.policy.denied_providers) {
359
376
  target.policy.deniedProviders = uniqueStrings([
360
377
  ...(target.policy.deniedProviders ?? []),
@@ -365,9 +382,13 @@ function mergeRootInto(
365
382
 
366
383
  if (root.providers) {
367
384
  for (const [id, provider] of Object.entries(root.providers)) {
385
+ const accounts: ConfiguredAccount[] = provider.accounts
386
+ ? provider.accounts.map((a: { id: string; label?: string }) => ({ id: a.id, label: a.label }))
387
+ : [{ id }];
368
388
  target.providers[id] = {
369
389
  id,
370
390
  enabled: provider.enabled ?? true,
391
+ accounts,
371
392
  };
372
393
  }
373
394
  }
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,16 @@ 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";
122
+ export {
123
+ FileUsageStore,
124
+ } from "./usage-file.js";
125
+ export type {
126
+ FileUsageStoreFileSystem,
127
+ FileUsageStoreOptions,
128
+ } from "./usage-file.js";
121
129
  export type {
130
+ AccountUsageSnapshot,
122
131
  InMemoryUsageStoreOptions,
123
132
  PaidFallbackExplanation,
124
133
  ProviderUsageSnapshot,
@@ -196,14 +205,21 @@ export type {
196
205
  // Auth module (credential chain, OAuth PKCE, keychain helpers)
197
206
  export {
198
207
  Redacted,
208
+ defaultCallbackPort,
209
+ deleteOAuthTokens,
199
210
  exchangeCodeForTokens,
200
211
  generateCodeChallenge,
201
212
  generateCodeVerifier,
213
+ getOAuthTokens,
202
214
  getProviderToken,
203
215
  isOAuthProvider,
216
+ isOAuthTokenExpired,
204
217
  PKCE_CONFIGS,
205
218
  refreshTokens,
219
+ resolveClientId,
206
220
  resolveCredential,
221
+ runOAuthLogin,
222
+ setOAuthTokens,
207
223
  setProviderToken,
208
224
  startOAuthFlow,
209
225
  } from "./auth/index.js";
@@ -213,10 +229,13 @@ export type {
213
229
  OAuthFlowResult,
214
230
  OAuthFlowState,
215
231
  OAuthProviderConfig,
232
+ OAuthStoredTokens,
216
233
  OAuthStartOptions,
217
234
  OAuthTokenOptions,
218
235
  OAuthTokenResponse,
219
236
  ResolveCredentialOptions,
237
+ RunOAuthLoginOptions,
238
+ RunOAuthLoginResult,
220
239
  } from "./auth/index.js";
221
240
 
222
241
  // OpenAI Responses-compatible local proxy
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
  });