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.
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
  /**
@@ -0,0 +1,243 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ DEFAULT_NEAR_LIMIT_THRESHOLD,
7
+ FileUsageStore,
8
+ InMemoryUsageStore,
9
+ type UsageStore,
10
+ } from "./usage-file.js";
11
+
12
+ const tempDirs: string[] = [];
13
+
14
+ afterEach(async () => {
15
+ await Promise.all(
16
+ tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
17
+ );
18
+ },);
19
+
20
+ async function makeTempDir(): Promise<string> {
21
+ const dir = await mkdtemp(join(tmpdir(), "offrouter-usage-file-"));
22
+ tempDirs.push(dir);
23
+ return dir;
24
+ }
25
+
26
+ async function makeFileStore(
27
+ filePath?: string,
28
+ ): Promise<{ dir: string; path: string; store: FileUsageStore }> {
29
+ const dir = await makeTempDir();
30
+ const path = filePath ?? join(dir, "state", "usage.json");
31
+ const store = new FileUsageStore({ filePath: path });
32
+ return { dir, path, store };
33
+ }
34
+
35
+ describe("FileUsageStore", () => {
36
+ it("satisfies the UsageStore interface contract", () => {
37
+ const store: UsageStore = new FileUsageStore({
38
+ filePath: "/tmp/offrouter-usage-store-interface.json",
39
+ });
40
+ expect(typeof store.recordUsage).toBe("function");
41
+ expect(typeof store.isNearLimit).toBe("function");
42
+ expect(typeof store.record).toBe("function");
43
+ });
44
+
45
+ it("persists account usage across store instances sharing the same file", async () => {
46
+ const { path, store } = await makeFileStore();
47
+ await store.recordUsage("anthropic", "anthropic-1", {
48
+ used: 42,
49
+ limit: 100,
50
+ });
51
+
52
+ const reloaded = new FileUsageStore({ filePath: path });
53
+ const snap = await reloaded.getAccountUsage("anthropic", "anthropic-1");
54
+ expect(snap?.used).toBe(42);
55
+ expect(snap?.limit).toBe(100);
56
+ expect(snap?.remaining).toBe(58);
57
+
58
+ // A fresh instance also sees near-limit state.
59
+ expect(await reloaded.isNearLimit("anthropic", "anthropic-1")).toBe(false);
60
+ });
61
+
62
+ it("marks an account near-limit at 85% consumed (threshold 0.2)", async () => {
63
+ const { store } = await makeFileStore();
64
+ const snap = await store.recordUsage("anthropic", "anthropic-1", {
65
+ used: 85,
66
+ limit: 100,
67
+ });
68
+ expect(snap.remaining).toBe(15);
69
+ expect(snap.percentRemaining).toBe(15);
70
+ expect(snap.nearLimit).toBe(true);
71
+ expect(snap.subscriptionStatus).toBe("near-limit");
72
+ expect(await store.isNearLimit("anthropic", "anthropic-1")).toBe(true);
73
+ });
74
+
75
+ it("does not mark an account near-limit at 50% consumed", async () => {
76
+ const { store } = await makeFileStore();
77
+ const snap = await store.recordUsage("anthropic", "anthropic-1", {
78
+ used: 50,
79
+ limit: 100,
80
+ });
81
+ expect(snap.nearLimit).toBe(false);
82
+ expect(snap.subscriptionStatus).toBe("active");
83
+ expect(await store.isNearLimit("anthropic", "anthropic-1")).toBe(false);
84
+ });
85
+
86
+ it("respects a custom near-limit threshold", async () => {
87
+ const { store } = await makeFileStore();
88
+ await store.recordUsage("anthropic", "anthropic-1", {
89
+ used: 85,
90
+ limit: 100,
91
+ });
92
+ // 15% remaining: near-limit at 0.2 default, not at stricter 0.1.
93
+ expect(await store.isNearLimit("anthropic", "anthropic-1")).toBe(true);
94
+ expect(await store.isNearLimit("anthropic", "anthropic-1", 0.1)).toBe(false);
95
+ });
96
+
97
+ it("marks an account exhausted at 100% consumed with dead health", async () => {
98
+ const { store } = await makeFileStore();
99
+ const snap = await store.recordUsage("anthropic", "anthropic-1", {
100
+ used: 100,
101
+ limit: 100,
102
+ });
103
+ expect(snap.subscriptionStatus).toBe("exhausted");
104
+ expect(snap.nearLimit).toBe(false);
105
+ expect(snap.health).toBe("dead");
106
+ });
107
+
108
+ it("writes the usage file with mode 0600 on POSIX", async () => {
109
+ if (process.platform === "win32") {
110
+ return;
111
+ }
112
+ const { path, store } = await makeFileStore();
113
+ await store.recordUsage("anthropic", "anthropic-1", {
114
+ used: 1,
115
+ limit: 100,
116
+ });
117
+
118
+ const info = await stat(path);
119
+ expect(info.mode & 0o777).toBe(0o600);
120
+ });
121
+
122
+ it("falls back to an empty state on a corrupt file instead of crashing", async () => {
123
+ const dir = await makeTempDir();
124
+ const path = join(dir, "state", "usage.json");
125
+ await mkdir(join(dir, "state"), { recursive: true });
126
+ await writeFile(path, "{not valid json at all", "utf8");
127
+
128
+ const store = new FileUsageStore({ filePath: path });
129
+ // Reads do not throw; state is empty.
130
+ const snap = await store.getAccountUsage("anthropic", "anthropic-1");
131
+ expect(snap).toBeUndefined();
132
+ expect(await store.listAccountUsage()).toEqual([]);
133
+
134
+ // A subsequent write replaces the corrupt file with a valid document.
135
+ await store.recordUsage("anthropic", "anthropic-1", {
136
+ used: 90,
137
+ limit: 100,
138
+ });
139
+ const raw = JSON.parse(await readFile(path, "utf8")) as {
140
+ version: number;
141
+ accounts: Record<string, unknown>;
142
+ };
143
+ expect(raw.version).toBe(1);
144
+ expect(Object.keys(raw.accounts)).toEqual(["anthropic:anthropic-1"]);
145
+ });
146
+
147
+ it("concurrent writes from two stores on the same file do not corrupt it", async () => {
148
+ const dir = await makeTempDir();
149
+ const path = join(dir, "state", "usage.json");
150
+ const s1 = new FileUsageStore({ filePath: path });
151
+ const s2 = new FileUsageStore({ filePath: path });
152
+
153
+ await Promise.all([
154
+ s1.recordUsage("anthropic", "anthropic-1", { used: 80, limit: 100 }),
155
+ s2.recordUsage("openai", "openai-1", { used: 90, limit: 100 }),
156
+ ]);
157
+
158
+ // The file is always valid JSON with the expected schema.
159
+ const raw = JSON.parse(await readFile(path, "utf8")) as {
160
+ version: number;
161
+ accounts: Record<string, { providerId: string }>;
162
+ };
163
+ expect(raw.version).toBe(1);
164
+ // Last-writer-wins is acceptable; corruption (invalid JSON) is not.
165
+ for (const doc of Object.values(raw.accounts)) {
166
+ expect(typeof doc.providerId).toBe("string");
167
+ }
168
+ });
169
+
170
+ it("writes on every mutation so a new process sees the latest usage", async () => {
171
+ const { path, store } = await makeFileStore();
172
+ await store.recordUsage("anthropic", "anthropic-1", {
173
+ used: 10,
174
+ limit: 100,
175
+ });
176
+ await store.recordUsage("anthropic", "anthropic-1", {
177
+ used: 88,
178
+ limit: 100,
179
+ });
180
+
181
+ const reloaded = new FileUsageStore({ filePath: path });
182
+ const snap = await reloaded.getAccountUsage("anthropic", "anthropic-1");
183
+ expect(snap?.used).toBe(88);
184
+ expect(snap?.nearLimit).toBe(true);
185
+ });
186
+
187
+ it("delegates provider health/quota logic to an in-memory store", async () => {
188
+ const { store } = await makeFileStore();
189
+ await store.setSubscriptionQuota("claude", {
190
+ limit: 10,
191
+ used: 9,
192
+ status: "active",
193
+ });
194
+ const snap = await store.getProvider("claude");
195
+ expect(snap?.subscriptionQuotaRemaining).toBe(1);
196
+ expect(snap?.health).toBe("healthy");
197
+ });
198
+
199
+ it("returns near-limit accounts across the persisted set", async () => {
200
+ const { store } = await makeFileStore();
201
+ await store.recordUsage("anthropic", "anthropic-1", {
202
+ used: 85,
203
+ limit: 100,
204
+ });
205
+ await store.recordUsage("anthropic", "anthropic-2", {
206
+ used: 10,
207
+ limit: 100,
208
+ });
209
+ const near = await store.getNearLimitAccounts();
210
+ expect(near).toHaveLength(1);
211
+ expect(near[0]?.accountId).toBe("anthropic-1");
212
+ });
213
+
214
+ it("toJSON exposes diagnostics without throwing", async () => {
215
+ const { store } = await makeFileStore();
216
+ await store.recordUsage("anthropic", "anthropic-1", {
217
+ used: 85,
218
+ limit: 100,
219
+ });
220
+ const json = store.toJSON();
221
+ expect(json).toMatchObject({ kind: "file-usage" });
222
+ expect(JSON.stringify(json)).not.toContain("anthropic-1");
223
+ });
224
+
225
+ it("uses the default near-limit threshold constant", () => {
226
+ expect(DEFAULT_NEAR_LIMIT_THRESHOLD).toBe(0.2);
227
+ });
228
+
229
+ it("accepts an injected in-memory delegate for provider state", async () => {
230
+ const dir = await makeTempDir();
231
+ const path = join(dir, "state", "usage.json");
232
+ const delegate = new InMemoryUsageStore();
233
+ const wired = new FileUsageStore({ filePath: path, delegate });
234
+ await wired.setSubscriptionQuota("claude", {
235
+ limit: 5,
236
+ used: 1,
237
+ status: "active",
238
+ });
239
+ // Provider state lives on the injected delegate.
240
+ const snap = await delegate.getProvider("claude");
241
+ expect(snap?.subscriptionQuotaRemaining).toBe(4);
242
+ });
243
+ });