offrouter-cli 0.1.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.
@@ -1 +1 @@
1
- {"version":3,"file":"status.js","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"status.js","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offrouter-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "offrouter": "dist/index.js"
package/src/cli.test.ts CHANGED
@@ -3,6 +3,7 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { FileUsageStore } from "offrouter-core";
6
7
  import { createProgram, runCli } from "./index.js";
7
8
 
8
9
  async function capture(
@@ -1574,4 +1575,131 @@ describe("offrouter CLI", () => {
1574
1575
  const program = createProgram();
1575
1576
  expect(program.name()).toBe("offrouter");
1576
1577
  });
1578
+
1579
+ it("offrouter status --providers --json shows real quotaRemaining from FileUsageStore", async () => {
1580
+ const home = await mkdtemp(join(tmpdir(), "offrouter-cli-status-usage-"));
1581
+ try {
1582
+ await writeFile(
1583
+ join(home, "config.toml"),
1584
+ [
1585
+ 'allowlisted_profiles = ["claude-personal"]',
1586
+ "",
1587
+ "[providers.anthropic]",
1588
+ "enabled = true",
1589
+ 'accounts = [{ id = "my-account" }]',
1590
+ "",
1591
+ ].join("\n"),
1592
+ "utf8",
1593
+ );
1594
+ const usageStore = new FileUsageStore({
1595
+ filePath: join(home, "state", "usage.json"),
1596
+ });
1597
+ await usageStore.recordUsage("anthropic", "my-account", {
1598
+ used: 85,
1599
+ limit: 100,
1600
+ });
1601
+
1602
+ const { code, stdout } = await capture(
1603
+ ["status", "--providers", "--json"],
1604
+ { ...process.env, OFFROUTER_HOME: home },
1605
+ );
1606
+ expect(code).toBe(0);
1607
+ const json = JSON.parse(stdout) as {
1608
+ providers: Array<{ accounts: Array<{ quotaRemaining: number | null }> }>;
1609
+ };
1610
+ const account = json.providers?.[0]?.accounts?.[0];
1611
+ expect(account).toBeDefined();
1612
+ expect(account!.quotaRemaining).toBe(15);
1613
+ } finally {
1614
+ await rm(home, { recursive: true, force: true });
1615
+ }
1616
+ });
1617
+
1618
+ it("offrouter doctor --json includes nearLimitWarnings when accounts are near-limit", async () => {
1619
+ const home = await mkdtemp(join(tmpdir(), "offrouter-cli-doctor-near-"));
1620
+ try {
1621
+ await writeFile(
1622
+ join(home, "config.toml"),
1623
+ [
1624
+ 'allowlisted_profiles = ["claude-personal"]',
1625
+ "",
1626
+ "[providers.anthropic]",
1627
+ "enabled = true",
1628
+ 'accounts = [{ id = "my-account" }]',
1629
+ "",
1630
+ ].join("\n"),
1631
+ "utf8",
1632
+ );
1633
+ const usageStore = new FileUsageStore({
1634
+ filePath: join(home, "state", "usage.json"),
1635
+ });
1636
+ await usageStore.recordUsage("anthropic", "my-account", {
1637
+ used: 90,
1638
+ limit: 100,
1639
+ });
1640
+
1641
+ const { code, stdout } = await capture(
1642
+ ["doctor", "--profile", "claude-personal", "--json"],
1643
+ { ...process.env, OFFROUTER_HOME: home },
1644
+ );
1645
+ expect(code).toBe(0);
1646
+ const json = JSON.parse(stdout) as {
1647
+ nearLimitWarnings?: string[];
1648
+ accounts: Array<{ nearLimit: boolean | null }>;
1649
+ };
1650
+ expect(json.nearLimitWarnings).toBeDefined();
1651
+ expect(json.nearLimitWarnings!.length).toBeGreaterThan(0);
1652
+ expect(json.nearLimitWarnings![0]).toMatch(/near its limit/i);
1653
+ expect(json.accounts?.[0]?.nearLimit).toBe(true);
1654
+ } finally {
1655
+ await rm(home, { recursive: true, force: true });
1656
+ }
1657
+ });
1658
+
1659
+ it("offrouter route explain --json includes nearLimitWarning when an account is near-limit", async () => {
1660
+ const home = await mkdtemp(join(tmpdir(), "offrouter-cli-route-near-"));
1661
+ try {
1662
+ await writeFile(
1663
+ join(home, "config.toml"),
1664
+ [
1665
+ 'allowlisted_profiles = ["claude-personal"]',
1666
+ "",
1667
+ "[providers.anthropic]",
1668
+ "enabled = true",
1669
+ 'accounts = [{ id = "my-account" }]',
1670
+ "",
1671
+ ].join("\n"),
1672
+ "utf8",
1673
+ );
1674
+ const usageStore = new FileUsageStore({
1675
+ filePath: join(home, "state", "usage.json"),
1676
+ });
1677
+ await usageStore.recordUsage("anthropic", "my-account", {
1678
+ used: 95,
1679
+ limit: 100,
1680
+ });
1681
+
1682
+ const { code, stdout } = await capture(
1683
+ [
1684
+ "route",
1685
+ "explain",
1686
+ "explain this repo",
1687
+ "--profile",
1688
+ "claude-personal",
1689
+ "--json",
1690
+ ],
1691
+ { ...process.env, OFFROUTER_HOME: home },
1692
+ );
1693
+ expect(code).toBe(0);
1694
+ const json = JSON.parse(stdout) as {
1695
+ nearLimitWarning?: string;
1696
+ blocked: boolean;
1697
+ };
1698
+ // The route is blocked (no candidates), but the warning should be present.
1699
+ expect(json.nearLimitWarning).toBeDefined();
1700
+ expect(json.nearLimitWarning).toMatch(/near its limit/i);
1701
+ } finally {
1702
+ await rm(home, { recursive: true, force: true });
1703
+ }
1704
+ });
1577
1705
  });
@@ -4,8 +4,10 @@ import { delimiter } from "node:path";
4
4
  import { dirname, join, normalize } from "node:path";
5
5
  import {
6
6
  evaluatePolicy,
7
+ FileUsageStore,
7
8
  loadConfig,
8
9
  redact,
10
+ resolveOffRouterHome,
9
11
  routeRequest,
10
12
  type ConfigWarning,
11
13
  type HarnessKind,
@@ -335,6 +337,7 @@ export function registerDoctorCommand(
335
337
  label: string;
336
338
  nearLimit: boolean | null;
337
339
  }[] = [];
340
+ const nearLimitWarnings: string[] = [];
338
341
  try {
339
342
  const config = await loadConfig({ env, workspaceDir: cwd });
340
343
  policyConfig = config.policy;
@@ -349,14 +352,35 @@ export function registerDoctorCommand(
349
352
  warnings: config.warnings.length,
350
353
  warningDetails: redactConfigWarnings(config.warnings, env),
351
354
  };
355
+
356
+ // Read persistent usage data for near-limit detection.
357
+ const usageStore = new FileUsageStore({
358
+ filePath: join(resolveOffRouterHome({ env }), "state", "usage.json"),
359
+ });
360
+ const allUsage = await usageStore.listAccountUsage();
361
+ const usageByKey = new Map(
362
+ allUsage.map((u) => [`${u.providerId}:${u.accountId}`, u]),
363
+ );
364
+
352
365
  accountReadiness = Object.values(config.providers).flatMap((p) =>
353
- p.accounts.map((a) => ({
354
- providerId: p.id,
355
- accountId: a.id,
356
- label: a.label ?? a.id,
357
- nearLimit: null as boolean | null,
358
- })),
366
+ p.accounts.map((a) => {
367
+ const snap = usageByKey.get(`${p.id}:${a.id}`);
368
+ return {
369
+ providerId: p.id,
370
+ accountId: a.id,
371
+ label: a.label ?? a.id,
372
+ nearLimit: snap?.nearLimit ?? null,
373
+ };
374
+ }),
359
375
  );
376
+
377
+ for (const a of accountReadiness) {
378
+ if (a.nearLimit) {
379
+ nearLimitWarnings.push(
380
+ `Account ${a.providerId}/${a.accountId} is near its limit (${a.label})`,
381
+ );
382
+ }
383
+ }
360
384
  } catch (err) {
361
385
  configError = formatConfigError(err);
362
386
  const safeConfigError = redactConfigError(configError, env);
@@ -436,6 +460,7 @@ export function registerDoctorCommand(
436
460
  config: configSummary,
437
461
  configError,
438
462
  accounts: accountReadiness,
463
+ ...(nearLimitWarnings.length > 0 ? { nearLimitWarnings } : {}),
439
464
  };
440
465
 
441
466
  if (opts.json) {
@@ -465,6 +490,11 @@ export function registerDoctorCommand(
465
490
  for (const a of accountReadiness) {
466
491
  writeText(stdout, ` account ${a.providerId}/${a.accountId}: ${a.label}`);
467
492
  }
493
+ if (nearLimitWarnings.length > 0) {
494
+ for (const warning of nearLimitWarnings) {
495
+ writeText(stdout, ` ! ${warning}`);
496
+ }
497
+ }
468
498
  writeText(
469
499
  stdout,
470
500
  `Selected profile: ${profile.profile} - ${profile.status}`,
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Tests for the `offrouter login` command.
3
+ *
4
+ * Mocks `offrouter-core` functions so the login command runs without real OAuth.
5
+ * FileSecretStore is NOT mocked; it runs against a real temp directory.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import { mkdtempSync, rmSync, existsSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Mock offrouter-core for login tests
14
+ // ---------------------------------------------------------------------------
15
+ const mocks = vi.hoisted(() => ({
16
+ mockRunOAuthLogin: vi.fn(),
17
+ mockGetOAuthTokens: vi.fn(),
18
+ mockIsOAuthTokenExpired: vi.fn(),
19
+ mockRefreshTokens: vi.fn(),
20
+ mockResolveClientId: vi.fn(),
21
+ mockResolveOffRouterHome: vi.fn(),
22
+ }));
23
+
24
+ // Keep FileSecretStore real but mock the core functions.
25
+ vi.mock("offrouter-core", async () => {
26
+ const actual = await vi.importActual<typeof import("offrouter-core")>("offrouter-core");
27
+ return {
28
+ ...actual,
29
+ runOAuthLogin: mocks.mockRunOAuthLogin,
30
+ getOAuthTokens: mocks.mockGetOAuthTokens,
31
+ isOAuthTokenExpired: mocks.mockIsOAuthTokenExpired,
32
+ refreshTokens: mocks.mockRefreshTokens,
33
+ resolveClientId: mocks.mockResolveClientId,
34
+ resolveOffRouterHome: mocks.mockResolveOffRouterHome,
35
+ };
36
+ });
37
+
38
+ import { createProgram } from "../index.js";
39
+
40
+ function capture(
41
+ argv: string[],
42
+ env: NodeJS.ProcessEnv = { ...process.env },
43
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
44
+ return new Promise((resolve) => {
45
+ let stdout = "";
46
+ let stderr = "";
47
+ const program = createProgram({
48
+ env,
49
+ stdout: {
50
+ write(chunk: string) {
51
+ stdout += chunk;
52
+ return true;
53
+ },
54
+ },
55
+ stderr: {
56
+ write(chunk: string) {
57
+ stderr += chunk;
58
+ return true;
59
+ },
60
+ },
61
+ });
62
+
63
+ program
64
+ .parseAsync(argv, { from: "user" })
65
+ .then(() => {
66
+ resolve({ code: 0, stdout, stderr });
67
+ })
68
+ .catch((err) => {
69
+ const code = err?.exitCode ?? 1;
70
+ resolve({ code, stdout, stderr });
71
+ });
72
+ });
73
+ }
74
+
75
+ describe("offrouter login command", () => {
76
+ let tmpHome: string;
77
+
78
+ beforeEach(() => {
79
+ vi.resetAllMocks();
80
+ tmpHome = mkdtempSync(join(tmpdir(), "offrouter-login-test-"));
81
+ mocks.mockResolveOffRouterHome.mockReturnValue(tmpHome);
82
+ });
83
+
84
+ afterEach(() => {
85
+ if (tmpHome && existsSync(tmpHome)) {
86
+ rmSync(tmpHome, { recursive: true, force: true });
87
+ }
88
+ });
89
+
90
+ describe("help", () => {
91
+ it("offrouter login --help shows login command", async () => {
92
+ const { code, stdout, stderr } = await capture(["login", "--help"]);
93
+ expect(code).toBe(0);
94
+ const output = stdout + stderr;
95
+ expect(output).toMatch(/login/i);
96
+ expect(output).toMatch(/provider/i);
97
+ expect(output).toMatch(/anthropic/i);
98
+ expect(output).toMatch(/openai/i);
99
+ });
100
+ });
101
+
102
+ describe("login <provider>", () => {
103
+ it("calls runOAuthLogin with the provider and stores tokens", async () => {
104
+ mocks.mockResolveClientId.mockReturnValue("test-client-id");
105
+ mocks.mockRunOAuthLogin.mockResolvedValue({
106
+ provider: "anthropic",
107
+ redirectUri: "http://127.0.0.1:1456/callback",
108
+ expiresAt: Date.now() + 3600_000,
109
+ scope: "api offline_access",
110
+ });
111
+
112
+ const { code, stdout, stderr } = await capture(
113
+ ["login", "anthropic"],
114
+ {
115
+ ...process.env,
116
+ OFFROUTER_HOME: tmpHome,
117
+ },
118
+ );
119
+
120
+ expect(code).toBe(0);
121
+ expect(stderr).toBe("");
122
+
123
+ expect(mocks.mockRunOAuthLogin).toHaveBeenCalledWith(
124
+ expect.objectContaining({
125
+ provider: "anthropic",
126
+ clientId: "test-client-id",
127
+ }),
128
+ );
129
+
130
+ expect(stdout).toMatch(/successful|Login/);
131
+ expect(stdout).toContain("anthropic");
132
+ });
133
+
134
+ it("prints error for unsupported provider", async () => {
135
+ const { code, stdout } = await capture(["login", "gemini"]);
136
+ expect(code).toBe(0);
137
+ expect(stdout).toMatch(/not supported/i);
138
+ expect(mocks.mockRunOAuthLogin).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it("prints error when no client id is configured", async () => {
142
+ const { code, stdout } = await capture(
143
+ ["login", "anthropic"],
144
+ { ...process.env, OFFROUTER_HOME: tmpHome },
145
+ );
146
+ expect(code).toBe(0);
147
+ expect(stdout).toMatch(/client id required|client.*id/i);
148
+ expect(mocks.mockRunOAuthLogin).not.toHaveBeenCalled();
149
+ });
150
+ });
151
+
152
+ describe("login --list", () => {
153
+ it("shows providers with no tokens when none stored", async () => {
154
+ mocks.mockGetOAuthTokens.mockResolvedValue(undefined);
155
+
156
+ const { code, stdout } = await capture(
157
+ ["login", "--list"],
158
+ { ...process.env, OFFROUTER_HOME: tmpHome },
159
+ );
160
+ expect(code).toBe(0);
161
+ expect(stdout).toMatch(/OAuth providers/i);
162
+ expect(stdout).toContain("anthropic");
163
+ expect(stdout).toContain("openai");
164
+ });
165
+
166
+ it("shows active token status", async () => {
167
+ mocks.mockGetOAuthTokens.mockResolvedValue({
168
+ accessToken: "tok",
169
+ refreshToken: "refresh",
170
+ expiresAt: Date.now() + 3600_000,
171
+ obtainedAt: Date.now(),
172
+ });
173
+ mocks.mockIsOAuthTokenExpired.mockReturnValue(false);
174
+
175
+ const { code, stdout } = await capture(
176
+ ["login", "--list"],
177
+ { ...process.env, OFFROUTER_HOME: tmpHome },
178
+ );
179
+ expect(code).toBe(0);
180
+ expect(stdout).toContain("active");
181
+ });
182
+
183
+ it("shows expired token status", async () => {
184
+ mocks.mockGetOAuthTokens.mockResolvedValue({
185
+ accessToken: "tok",
186
+ refreshToken: "refresh",
187
+ expiresAt: Date.now() - 1000,
188
+ obtainedAt: Date.now() - 7200_000,
189
+ });
190
+ mocks.mockIsOAuthTokenExpired.mockReturnValue(true);
191
+
192
+ const { code, stdout } = await capture(
193
+ ["login", "--list"],
194
+ { ...process.env, OFFROUTER_HOME: tmpHome },
195
+ );
196
+ expect(code).toBe(0);
197
+ expect(stdout).toContain("expired");
198
+ });
199
+ });
200
+
201
+ describe("login --status <provider>", () => {
202
+ it("shows status for unsupported provider", async () => {
203
+ const { code, stdout } = await capture(
204
+ ["login", "--status", "gemini"],
205
+ );
206
+ expect(code).toBe(0);
207
+ expect(stdout).toMatch(/not supported/i);
208
+ });
209
+ });
210
+
211
+ describe("login --json", () => {
212
+ it("returns JSON for successful login", async () => {
213
+ mocks.mockResolveClientId.mockReturnValue("test-oa-id");
214
+ mocks.mockRunOAuthLogin.mockResolvedValue({
215
+ provider: "openai",
216
+ redirectUri: "http://127.0.0.1:1455/callback",
217
+ expiresAt: 9999999999000,
218
+ scope: "offline_access",
219
+ });
220
+
221
+ const { code, stdout } = await capture(
222
+ ["login", "openai", "--json"],
223
+ {
224
+ ...process.env,
225
+ OFFROUTER_HOME: tmpHome,
226
+ },
227
+ );
228
+
229
+ expect(code).toBe(0);
230
+ const json = JSON.parse(stdout) as Record<string, unknown>;
231
+ expect(json.ok).toBe(true);
232
+ expect(json.provider).toBe("openai");
233
+ expect(json.redirectUri).toContain("127.0.0.1");
234
+ });
235
+
236
+ it("returns JSON error for unsupported provider", async () => {
237
+ const { code, stdout } = await capture(
238
+ ["login", "gemini", "--json"],
239
+ { ...process.env, OFFROUTER_HOME: tmpHome },
240
+ );
241
+
242
+ expect(code).toBe(0);
243
+ const json = JSON.parse(stdout) as Record<string, unknown>;
244
+ expect(json.error).toMatch(/not supported/i);
245
+ });
246
+ });
247
+ });