@getrouter/getrouter-cli 0.1.1 → 0.1.3

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.
Files changed (41) hide show
  1. package/.serena/project.yml +84 -0
  2. package/CLAUDE.md +52 -0
  3. package/biome.json +1 -1
  4. package/bun.lock +10 -10
  5. package/dist/bin.mjs +245 -94
  6. package/package.json +2 -2
  7. package/src/cli.ts +2 -1
  8. package/src/cmd/codex.ts +17 -7
  9. package/src/cmd/env.ts +1 -1
  10. package/src/cmd/keys.ts +46 -28
  11. package/src/cmd/models.ts +2 -1
  12. package/src/core/api/pagination.ts +25 -0
  13. package/src/core/api/providerModels.ts +32 -0
  14. package/src/core/auth/refresh.ts +68 -0
  15. package/src/core/config/fs.ts +33 -2
  16. package/src/core/config/index.ts +2 -8
  17. package/src/core/config/paths.ts +6 -3
  18. package/src/core/http/request.ts +71 -15
  19. package/src/core/http/retry.ts +68 -0
  20. package/src/core/interactive/codex.ts +21 -0
  21. package/src/core/interactive/keys.ts +19 -10
  22. package/src/core/output/usages.ts +11 -30
  23. package/src/core/setup/codex.ts +4 -0
  24. package/src/core/setup/env.ts +14 -6
  25. package/tests/auth/refresh.test.ts +149 -0
  26. package/tests/cmd/codex.test.ts +87 -1
  27. package/tests/cmd/keys.test.ts +48 -14
  28. package/tests/cmd/models.test.ts +5 -2
  29. package/tests/cmd/usages.test.ts +5 -5
  30. package/tests/config/fs.test.ts +22 -1
  31. package/tests/config/index.test.ts +16 -1
  32. package/tests/config/paths.test.ts +23 -0
  33. package/tests/core/api/pagination.test.ts +87 -0
  34. package/tests/core/interactive/codex.test.ts +25 -1
  35. package/tests/core/setup/env.test.ts +18 -4
  36. package/tests/http/request.test.ts +157 -0
  37. package/tests/http/retry.test.ts +152 -0
  38. package/tests/output/usages.test.ts +11 -12
  39. package/tsconfig.json +3 -2
  40. package/src/core/paths.ts +0 -4
  41. package/tests/paths.test.ts +0 -9
package/src/cmd/keys.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Command } from "commander";
2
2
  import { createApiClients } from "../core/api/client";
3
+ import { fetchAllPages } from "../core/api/pagination";
3
4
  import { redactSecrets } from "../core/config/redact";
4
5
  import {
5
6
  confirmDelete,
@@ -23,25 +24,34 @@ const consumerHeaders = [
23
24
  "API_KEY",
24
25
  ];
25
26
 
26
- const consumerRow = (consumer: ConsumerLike) => [
27
- String(consumer.name ?? ""),
28
- String(consumer.enabled ?? ""),
29
- String(consumer.lastAccess ?? ""),
30
- String(consumer.createdAt ?? ""),
31
- String(consumer.apiKey ?? ""),
32
- ];
33
-
34
- const outputConsumerTable = (consumer: ConsumerLike) => {
35
- console.log(renderTable(consumerHeaders, [consumerRow(consumer)]));
27
+ const consumerRow = (consumer: ConsumerLike, showApiKey: boolean) => {
28
+ const { apiKey } = showApiKey
29
+ ? consumer
30
+ : (redactSecrets(consumer as Record<string, unknown>) as ConsumerLike);
31
+ return [
32
+ String(consumer.name ?? ""),
33
+ String(consumer.enabled ?? ""),
34
+ String(consumer.lastAccess ?? ""),
35
+ String(consumer.createdAt ?? ""),
36
+ String(apiKey ?? ""),
37
+ ];
36
38
  };
37
39
 
38
- const outputConsumers = (consumers: routercommonv1_Consumer[]) => {
39
- const rows = consumers.map(consumerRow);
40
- console.log(renderTable(consumerHeaders, rows));
40
+ const outputConsumerTable = (consumer: ConsumerLike, showApiKey: boolean) => {
41
+ console.log(
42
+ renderTable(consumerHeaders, [consumerRow(consumer, showApiKey)], {
43
+ maxColWidth: 64,
44
+ }),
45
+ );
41
46
  };
42
47
 
43
- const redactConsumer = (consumer: routercommonv1_Consumer) =>
44
- redactSecrets(consumer);
48
+ const outputConsumers = (
49
+ consumers: routercommonv1_Consumer[],
50
+ showApiKey: boolean,
51
+ ) => {
52
+ const rows = consumers.map((consumer) => consumerRow(consumer, showApiKey));
53
+ console.log(renderTable(consumerHeaders, rows, { maxColWidth: 64 }));
54
+ };
45
55
 
46
56
  const requireInteractive = (message: string) => {
47
57
  if (!process.stdin.isTTY) {
@@ -82,13 +92,18 @@ const updateConsumer = async (
82
92
 
83
93
  const listConsumers = async (
84
94
  consumerService: Pick<ConsumerService, "ListConsumers">,
95
+ showApiKey: boolean,
85
96
  ) => {
86
- const res = await consumerService.ListConsumers({
87
- pageSize: undefined,
88
- pageToken: undefined,
89
- });
90
- const consumers = (res?.consumers ?? []).map(redactConsumer);
91
- outputConsumers(consumers);
97
+ const consumers = await fetchAllPages(
98
+ (pageToken) =>
99
+ consumerService.ListConsumers({
100
+ pageSize: undefined,
101
+ pageToken,
102
+ }),
103
+ (res) => res?.consumers ?? [],
104
+ (res) => res?.nextPageToken || undefined,
105
+ );
106
+ outputConsumers(consumers, showApiKey);
92
107
  };
93
108
 
94
109
  const resolveConsumerForUpdate = async (
@@ -121,7 +136,7 @@ const createConsumer = async (
121
136
  const enabled = await promptKeyEnabled(true);
122
137
  let consumer = await consumerService.CreateConsumer({});
123
138
  consumer = await updateConsumer(consumerService, consumer, name, enabled);
124
- outputConsumerTable(consumer);
139
+ outputConsumerTable(consumer, true);
125
140
  console.log("Please store this API key securely.");
126
141
  };
127
142
 
@@ -143,7 +158,7 @@ const updateConsumerById = async (
143
158
  name,
144
159
  enabled,
145
160
  );
146
- outputConsumerTable(redactConsumer(consumer));
161
+ outputConsumerTable(consumer, false);
147
162
  };
148
163
 
149
164
  const deleteConsumerById = async (
@@ -159,24 +174,27 @@ const deleteConsumerById = async (
159
174
  const confirmed = await confirmDelete(selected);
160
175
  if (!confirmed) return;
161
176
  await consumerService.DeleteConsumer({ id: selected.id });
162
- outputConsumerTable(redactConsumer(selected));
177
+ outputConsumerTable(selected, false);
163
178
  };
164
179
 
165
180
  export const registerKeysCommands = (program: Command) => {
166
181
  const keys = program.command("keys").description("Manage API keys");
182
+ keys.option("--show", "Show full API keys");
167
183
  keys.allowExcessArguments(false);
168
184
 
169
- keys.action(async () => {
185
+ keys.action(async (options: { show?: boolean }) => {
170
186
  const { consumerService } = createApiClients({});
171
- await listConsumers(consumerService);
187
+ await listConsumers(consumerService, Boolean(options.show));
172
188
  });
173
189
 
174
190
  keys
175
191
  .command("list")
176
192
  .description("List API keys")
177
- .action(async () => {
193
+ .option("--show", "Show full API keys")
194
+ .action(async (options: { show?: boolean }, command: Command) => {
178
195
  const { consumerService } = createApiClients({});
179
- await listConsumers(consumerService);
196
+ const parentShow = Boolean(command.parent?.opts().show);
197
+ await listConsumers(consumerService, Boolean(options.show) || parentShow);
180
198
  });
181
199
 
182
200
  keys
package/src/cmd/models.ts CHANGED
@@ -3,9 +3,10 @@ import { createApiClients } from "../core/api/client";
3
3
  import { renderTable } from "../core/output/table";
4
4
  import type { routercommonv1_Model } from "../generated/router/dashboard/v1";
5
5
 
6
- const modelHeaders = ["NAME", "AUTHOR", "ENABLED", "UPDATED_AT"];
6
+ const modelHeaders = ["ID", "NAME", "AUTHOR", "ENABLED", "UPDATED_AT"];
7
7
 
8
8
  const modelRow = (model: routercommonv1_Model) => [
9
+ String(model.id ?? ""),
9
10
  String(model.name ?? ""),
10
11
  String(model.author ?? ""),
11
12
  String(model.enabled ?? ""),
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Fetches all pages from a paginated API endpoint.
3
+ *
4
+ * @param fetchPage - Function that fetches a single page given a pageToken
5
+ * @param getItems - Function that extracts items from the response
6
+ * @param getNextToken - Function that extracts the next page token from the response
7
+ * @returns Array of all items across all pages
8
+ */
9
+ export const fetchAllPages = async <TResponse, TItem>(
10
+ fetchPage: (pageToken?: string) => Promise<TResponse>,
11
+ getItems: (response: TResponse) => TItem[],
12
+ getNextToken: (response: TResponse) => string | undefined,
13
+ ): Promise<TItem[]> => {
14
+ const allItems: TItem[] = [];
15
+ let pageToken: string | undefined;
16
+
17
+ do {
18
+ const response = await fetchPage(pageToken);
19
+ const items = getItems(response);
20
+ allItems.push(...items);
21
+ pageToken = getNextToken(response);
22
+ } while (pageToken);
23
+
24
+ return allItems;
25
+ };
@@ -0,0 +1,32 @@
1
+ import { requestJson } from "../http/request";
2
+
3
+ const asTrimmedString = (value: unknown): string | null => {
4
+ if (typeof value !== "string") return null;
5
+ const trimmed = value.trim();
6
+ return trimmed.length > 0 ? trimmed : null;
7
+ };
8
+
9
+ const buildProviderModelsPath = (tag?: string) => {
10
+ const query = new URLSearchParams();
11
+ if (tag) query.set("tag", tag);
12
+ const qs = query.toString();
13
+ return `v1/dashboard/providers/models${qs ? `?${qs}` : ""}`;
14
+ };
15
+
16
+ export const listProviderModels = async ({
17
+ tag,
18
+ fetchImpl,
19
+ }: {
20
+ tag?: string;
21
+ fetchImpl?: typeof fetch;
22
+ }): Promise<string[]> => {
23
+ const res = await requestJson<{ models?: unknown }>({
24
+ path: buildProviderModelsPath(tag),
25
+ method: "GET",
26
+ fetchImpl,
27
+ maxRetries: 0,
28
+ });
29
+ const raw = res?.models;
30
+ const models = Array.isArray(raw) ? raw : [];
31
+ return models.map(asTrimmedString).filter(Boolean) as string[];
32
+ };
@@ -0,0 +1,68 @@
1
+ import { readAuth, writeAuth } from "../config";
2
+ import { buildApiUrl } from "../http/url";
3
+
4
+ type AuthToken = {
5
+ accessToken: string | undefined;
6
+ refreshToken: string | undefined;
7
+ expiresAt: string | undefined;
8
+ };
9
+
10
+ const EXPIRY_BUFFER_MS = 60 * 1000; // Refresh 1 minute before expiry
11
+
12
+ export const isTokenExpiringSoon = (expiresAt: string): boolean => {
13
+ if (!expiresAt) return true;
14
+ const t = Date.parse(expiresAt);
15
+ if (Number.isNaN(t)) return true;
16
+ return t <= Date.now() + EXPIRY_BUFFER_MS;
17
+ };
18
+
19
+ export const refreshAccessToken = async ({
20
+ fetchImpl,
21
+ }: {
22
+ fetchImpl?: typeof fetch;
23
+ }): Promise<AuthToken | null> => {
24
+ const auth = readAuth();
25
+ if (!auth.refreshToken) {
26
+ return null;
27
+ }
28
+
29
+ const res = await (fetchImpl ?? fetch)(
30
+ buildApiUrl("v1/dashboard/auth/token"),
31
+ {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json" },
34
+ body: JSON.stringify({ refreshToken: auth.refreshToken }),
35
+ },
36
+ );
37
+
38
+ if (!res.ok) {
39
+ return null;
40
+ }
41
+
42
+ const token = (await res.json()) as AuthToken;
43
+ if (token.accessToken && token.refreshToken) {
44
+ writeAuth({
45
+ accessToken: token.accessToken,
46
+ refreshToken: token.refreshToken,
47
+ expiresAt: token.expiresAt ?? "",
48
+ tokenType: "Bearer",
49
+ });
50
+ }
51
+ return token;
52
+ };
53
+
54
+ export const ensureValidToken = async ({
55
+ fetchImpl,
56
+ }: {
57
+ fetchImpl?: typeof fetch;
58
+ }): Promise<boolean> => {
59
+ const auth = readAuth();
60
+ if (!auth.accessToken || !auth.refreshToken) {
61
+ return false;
62
+ }
63
+ if (!isTokenExpiringSoon(auth.expiresAt)) {
64
+ return true;
65
+ }
66
+ const refreshed = await refreshAccessToken({ fetchImpl });
67
+ return refreshed !== null && Boolean(refreshed.accessToken);
68
+ };
@@ -1,10 +1,41 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
+ const getCorruptBackupPath = (filePath: string) => {
5
+ const dir = path.dirname(filePath);
6
+ const ext = path.extname(filePath);
7
+ const base = path.basename(filePath, ext);
8
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
9
+ const rand = Math.random().toString(16).slice(2, 8);
10
+ return path.join(dir, `${base}.corrupt-${stamp}-${rand}${ext}`);
11
+ };
12
+
4
13
  export const readJsonFile = <T = unknown>(filePath: string): T | null => {
5
14
  if (!fs.existsSync(filePath)) return null;
6
- const raw = fs.readFileSync(filePath, "utf8");
7
- return JSON.parse(raw) as T;
15
+ let raw: string;
16
+ try {
17
+ raw = fs.readFileSync(filePath, "utf8");
18
+ } catch {
19
+ console.warn(`⚠️ Unable to read ${filePath}. Continuing with defaults.`);
20
+ return null;
21
+ }
22
+
23
+ try {
24
+ return JSON.parse(raw) as T;
25
+ } catch {
26
+ const backupPath = getCorruptBackupPath(filePath);
27
+ try {
28
+ fs.renameSync(filePath, backupPath);
29
+ console.warn(
30
+ `⚠️ Invalid JSON in ${filePath}. Moved to ${backupPath} and continuing with defaults.`,
31
+ );
32
+ } catch {
33
+ console.warn(
34
+ `⚠️ Invalid JSON in ${filePath}. Please fix or delete this file, then try again.`,
35
+ );
36
+ }
37
+ return null;
38
+ }
8
39
  };
9
40
 
10
41
  export const writeJsonFile = (filePath: string, value: unknown) => {
@@ -1,7 +1,6 @@
1
1
  import fs from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
2
  import { readJsonFile, writeJsonFile } from "./fs";
3
+ import { getAuthPath, getConfigPath } from "./paths";
5
4
  import {
6
5
  type AuthState,
7
6
  type ConfigFile,
@@ -9,12 +8,6 @@ import {
9
8
  defaultConfig,
10
9
  } from "./types";
11
10
 
12
- const resolveConfigDir = () =>
13
- process.env.GETROUTER_CONFIG_DIR || path.join(os.homedir(), ".getrouter");
14
-
15
- const getConfigPath = () => path.join(resolveConfigDir(), "config.json");
16
- const getAuthPath = () => path.join(resolveConfigDir(), "auth.json");
17
-
18
11
  export const readConfig = (): ConfigFile => ({
19
12
  ...defaultConfig(),
20
13
  ...(readJsonFile<ConfigFile>(getConfigPath()) ?? {}),
@@ -32,6 +25,7 @@ export const writeAuth = (auth: AuthState) => {
32
25
  const authPath = getAuthPath();
33
26
  writeJsonFile(authPath, auth);
34
27
  if (process.platform !== "win32") {
28
+ // Restrict token file permissions on Unix-like systems.
35
29
  fs.chmodSync(authPath, 0o600);
36
30
  }
37
31
  };
@@ -1,5 +1,8 @@
1
+ import os from "node:os";
1
2
  import path from "node:path";
2
- import { getConfigDir } from "../paths";
3
3
 
4
- export const getConfigPath = () => path.join(getConfigDir(), "config.json");
5
- export const getAuthPath = () => path.join(getConfigDir(), "auth.json");
4
+ export const resolveConfigDir = () =>
5
+ process.env.GETROUTER_CONFIG_DIR || path.join(os.homedir(), ".getrouter");
6
+
7
+ export const getConfigPath = () => path.join(resolveConfigDir(), "config.json");
8
+ export const getAuthPath = () => path.join(resolveConfigDir(), "auth.json");
@@ -1,5 +1,7 @@
1
+ import { refreshAccessToken } from "../auth/refresh";
1
2
  import { readAuth } from "../config";
2
3
  import { createApiError } from "./errors";
4
+ import { isServerError, withRetry } from "./retry";
3
5
  import { buildApiUrl } from "./url";
4
6
 
5
7
  type RequestInput = {
@@ -7,6 +9,9 @@ type RequestInput = {
7
9
  method: string;
8
10
  body?: unknown;
9
11
  fetchImpl?: typeof fetch;
12
+ maxRetries?: number;
13
+ /** For testing: override the sleep function used for retry delays */
14
+ _retrySleep?: (ms: number) => Promise<void>;
10
15
  };
11
16
 
12
17
  const getAuthCookieName = () =>
@@ -14,28 +19,79 @@ const getAuthCookieName = () =>
14
19
  process.env.KRATOS_AUTH_COOKIE ||
15
20
  "access_token";
16
21
 
17
- export const requestJson = async <T = unknown>({
18
- path,
19
- method,
20
- body,
21
- fetchImpl,
22
- }: RequestInput): Promise<T> => {
22
+ const buildHeaders = (accessToken?: string): Record<string, string> => {
23
23
  const headers: Record<string, string> = {
24
24
  "Content-Type": "application/json",
25
25
  };
26
- const auth = readAuth();
27
- if (auth.accessToken) {
28
- headers.Authorization = `Bearer ${auth.accessToken}`;
29
- headers.Cookie = `${getAuthCookieName()}=${auth.accessToken}`;
26
+ if (accessToken) {
27
+ headers.Authorization = `Bearer ${accessToken}`;
28
+ headers.Cookie = `${getAuthCookieName()}=${accessToken}`;
30
29
  }
31
- const res = await (fetchImpl ?? fetch)(buildApiUrl(path), {
30
+ return headers;
31
+ };
32
+
33
+ const doFetch = async (
34
+ url: string,
35
+ method: string,
36
+ headers: Record<string, string>,
37
+ body: unknown,
38
+ fetchImpl?: typeof fetch,
39
+ ): Promise<Response> => {
40
+ return (fetchImpl ?? fetch)(url, {
32
41
  method,
33
42
  headers,
34
43
  body: body == null ? undefined : JSON.stringify(body),
35
44
  });
36
- if (!res.ok) {
37
- const payload = await res.json().catch(() => null);
38
- throw createApiError(payload, res.statusText, res.status);
45
+ };
46
+
47
+ const shouldRetryResponse = (error: unknown): boolean => {
48
+ if (
49
+ typeof error === "object" &&
50
+ error !== null &&
51
+ "status" in error &&
52
+ typeof (error as { status: unknown }).status === "number"
53
+ ) {
54
+ return isServerError((error as { status: number }).status);
39
55
  }
40
- return (await res.json()) as T;
56
+ // Retry on network errors (TypeError from fetch)
57
+ return error instanceof TypeError;
58
+ };
59
+
60
+ export const requestJson = async <T = unknown>({
61
+ path,
62
+ method,
63
+ body,
64
+ fetchImpl,
65
+ maxRetries = 3,
66
+ _retrySleep,
67
+ }: RequestInput): Promise<T> => {
68
+ return withRetry(
69
+ async () => {
70
+ const auth = readAuth();
71
+ const url = buildApiUrl(path);
72
+ const headers = buildHeaders(auth.accessToken);
73
+
74
+ let res = await doFetch(url, method, headers, body, fetchImpl);
75
+
76
+ // On 401, attempt token refresh and retry once
77
+ if (res.status === 401 && auth.refreshToken) {
78
+ const refreshed = await refreshAccessToken({ fetchImpl });
79
+ if (refreshed?.accessToken) {
80
+ const newHeaders = buildHeaders(refreshed.accessToken);
81
+ res = await doFetch(url, method, newHeaders, body, fetchImpl);
82
+ }
83
+ }
84
+
85
+ if (!res.ok) {
86
+ const payload = await res.json().catch(() => null);
87
+ throw createApiError(payload, res.statusText, res.status);
88
+ }
89
+ return (await res.json()) as T;
90
+ },
91
+ {
92
+ maxRetries,
93
+ shouldRetry: shouldRetryResponse,
94
+ sleep: _retrySleep,
95
+ },
96
+ );
41
97
  };
@@ -0,0 +1,68 @@
1
+ export type RetryOptions = {
2
+ maxRetries?: number;
3
+ initialDelayMs?: number;
4
+ maxDelayMs?: number;
5
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
6
+ onRetry?: (error: unknown, attempt: number, delayMs: number) => void;
7
+ sleep?: (ms: number) => Promise<void>;
8
+ };
9
+
10
+ const defaultSleep = (ms: number) =>
11
+ new Promise<void>((resolve) => setTimeout(resolve, ms));
12
+
13
+ const isRetryableError = (error: unknown): boolean => {
14
+ // Network errors (fetch failures)
15
+ if (error instanceof TypeError) {
16
+ return true;
17
+ }
18
+ // Errors with status codes
19
+ if (
20
+ typeof error === "object" &&
21
+ error !== null &&
22
+ "status" in error &&
23
+ typeof (error as { status: unknown }).status === "number"
24
+ ) {
25
+ const status = (error as { status: number }).status;
26
+ // Retry on 5xx server errors, 408 timeout, 429 rate limit
27
+ return status >= 500 || status === 408 || status === 429;
28
+ }
29
+ return false;
30
+ };
31
+
32
+ export const withRetry = async <T>(
33
+ fn: () => Promise<T>,
34
+ options: RetryOptions = {},
35
+ ): Promise<T> => {
36
+ const {
37
+ maxRetries = 3,
38
+ initialDelayMs = 1000,
39
+ maxDelayMs = 10000,
40
+ shouldRetry = isRetryableError,
41
+ onRetry,
42
+ sleep = defaultSleep,
43
+ } = options;
44
+
45
+ let lastError: unknown;
46
+ let delay = initialDelayMs;
47
+
48
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
49
+ try {
50
+ return await fn();
51
+ } catch (error) {
52
+ lastError = error;
53
+
54
+ if (attempt >= maxRetries || !shouldRetry(error, attempt)) {
55
+ throw error;
56
+ }
57
+
58
+ onRetry?.(error, attempt + 1, delay);
59
+ await sleep(delay);
60
+ delay = Math.min(delay * 2, maxDelayMs);
61
+ }
62
+ }
63
+
64
+ throw lastError;
65
+ };
66
+
67
+ export const isServerError = (status: number): boolean =>
68
+ status >= 500 || status === 408 || status === 429;
@@ -1,3 +1,4 @@
1
+ import { listProviderModels } from "../api/providerModels";
1
2
  import type { FuzzyChoice } from "./fuzzy";
2
3
 
3
4
  export type ReasoningChoice = {
@@ -35,6 +36,26 @@ export const MODEL_CHOICES: FuzzyChoice<string>[] = [
35
36
  },
36
37
  ];
37
38
 
39
+ export const getCodexModelChoices = async (): Promise<
40
+ FuzzyChoice<string>[]
41
+ > => {
42
+ try {
43
+ const remoteModels = await listProviderModels({ tag: "codex" });
44
+ const remoteChoices = remoteModels.map((model) => ({
45
+ title: model,
46
+ value: model,
47
+ keywords: [model, "codex"],
48
+ }));
49
+
50
+ if (remoteChoices.length > 0) {
51
+ remoteChoices.sort((a, b) => a.title.localeCompare(b.title));
52
+ return remoteChoices;
53
+ }
54
+ } catch {}
55
+
56
+ return MODEL_CHOICES;
57
+ };
58
+
38
59
  export const REASONING_CHOICES: ReasoningChoice[] = [
39
60
  {
40
61
  id: "low",
@@ -3,6 +3,7 @@ import type {
3
3
  ConsumerService as DashboardConsumerService,
4
4
  routercommonv1_Consumer,
5
5
  } from "../../generated/router/dashboard/v1";
6
+ import { fetchAllPages } from "../api/pagination";
6
7
  import { fuzzySelect } from "./fuzzy";
7
8
 
8
9
  type Consumer = routercommonv1_Consumer;
@@ -101,11 +102,15 @@ export const promptKeyEnabled = async (initial: boolean): Promise<boolean> => {
101
102
  export const selectConsumer = async (
102
103
  consumerService: ConsumerService,
103
104
  ): Promise<routercommonv1_Consumer | null> => {
104
- const res = await consumerService.ListConsumers({
105
- pageSize: undefined,
106
- pageToken: undefined,
107
- });
108
- const consumers = res?.consumers ?? [];
105
+ const consumers = await fetchAllPages(
106
+ (pageToken) =>
107
+ consumerService.ListConsumers({
108
+ pageSize: undefined,
109
+ pageToken,
110
+ }),
111
+ (res) => res?.consumers ?? [],
112
+ (res) => res?.nextPageToken || undefined,
113
+ );
109
114
  if (consumers.length === 0) {
110
115
  throw new Error("No available API keys");
111
116
  }
@@ -128,11 +133,15 @@ export const selectConsumerList = async (
128
133
  consumerService: ConsumerService,
129
134
  message: string,
130
135
  ): Promise<routercommonv1_Consumer | null> => {
131
- const res = await consumerService.ListConsumers({
132
- pageSize: undefined,
133
- pageToken: undefined,
134
- });
135
- const consumers = res?.consumers ?? [];
136
+ const consumers = await fetchAllPages(
137
+ (pageToken) =>
138
+ consumerService.ListConsumers({
139
+ pageSize: undefined,
140
+ pageToken,
141
+ }),
142
+ (res) => res?.consumers ?? [],
143
+ (res) => res?.nextPageToken || undefined,
144
+ );
136
145
  if (consumers.length === 0) {
137
146
  throw new Error("No available API keys");
138
147
  }
@@ -1,7 +1,6 @@
1
1
  import type { AggregatedUsage } from "../usages/aggregate";
2
2
 
3
- const INPUT_BLOCK = "█";
4
- const OUTPUT_BLOCK = "▒";
3
+ const TOTAL_BLOCK = "█";
5
4
  const DEFAULT_WIDTH = 24;
6
5
 
7
6
  const formatTokens = (value: number) => {
@@ -35,41 +34,23 @@ export const renderUsageChart = (
35
34
  return `${header}\n\nNo usage data available.`;
36
35
  }
37
36
  const normalized = rows.map((row) => {
38
- const input = Number(row.inputTokens);
39
- const output = Number(row.outputTokens);
40
- const safeInput = Number.isFinite(input) ? input : 0;
41
- const safeOutput = Number.isFinite(output) ? output : 0;
37
+ const total = Number(row.totalTokens);
38
+ const safeTotal = Number.isFinite(total) ? total : 0;
42
39
  return {
43
40
  day: row.day,
44
- input: safeInput,
45
- output: safeOutput,
46
- total: safeInput + safeOutput,
41
+ total: safeTotal,
47
42
  };
48
43
  });
49
44
  const totals = normalized.map((row) => row.total);
50
45
  const maxTotal = Math.max(0, ...totals);
51
46
  const lines = normalized.map((row) => {
52
- const total = row.total;
53
- if (maxTotal === 0 || total === 0) {
54
- return `${row.day} ${"".padEnd(width, " ")} I:0 O:0`;
47
+ if (maxTotal === 0 || row.total === 0) {
48
+ return `${row.day} ${"".padEnd(width, " ")} 0`;
55
49
  }
56
- const scaled = Math.max(1, Math.round((total / maxTotal) * width));
57
- let inputBars = Math.round((row.input / total) * scaled);
58
- let outputBars = Math.max(0, scaled - inputBars);
59
- if (row.input > 0 && row.output > 0) {
60
- if (inputBars === 0) {
61
- inputBars = 1;
62
- outputBars = Math.max(0, scaled - 1);
63
- } else if (outputBars === 0) {
64
- outputBars = 1;
65
- inputBars = Math.max(0, scaled - 1);
66
- }
67
- }
68
- const bar = `${INPUT_BLOCK.repeat(inputBars)}${OUTPUT_BLOCK.repeat(outputBars)}`;
69
- const inputLabel = formatTokens(row.input);
70
- const outputLabel = formatTokens(row.output);
71
- return `${row.day} ${bar.padEnd(width, " ")} I:${inputLabel} O:${outputLabel}`;
50
+ const scaled = Math.max(1, Math.round((row.total / maxTotal) * width));
51
+ const bar = TOTAL_BLOCK.repeat(scaled);
52
+ const totalLabel = formatTokens(row.total);
53
+ return `${row.day} ${bar.padEnd(width, " ")} ${totalLabel}`;
72
54
  });
73
- const legend = `Legend: ${INPUT_BLOCK} input ${OUTPUT_BLOCK} output`;
74
- return [header, "", ...lines, "", legend].join("\n");
55
+ return [header, "", ...lines].join("\n");
75
56
  };