@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.
- package/.serena/project.yml +84 -0
- package/CLAUDE.md +52 -0
- package/biome.json +1 -1
- package/bun.lock +10 -10
- package/dist/bin.mjs +245 -94
- package/package.json +2 -2
- package/src/cli.ts +2 -1
- package/src/cmd/codex.ts +17 -7
- package/src/cmd/env.ts +1 -1
- package/src/cmd/keys.ts +46 -28
- package/src/cmd/models.ts +2 -1
- package/src/core/api/pagination.ts +25 -0
- package/src/core/api/providerModels.ts +32 -0
- package/src/core/auth/refresh.ts +68 -0
- package/src/core/config/fs.ts +33 -2
- package/src/core/config/index.ts +2 -8
- package/src/core/config/paths.ts +6 -3
- package/src/core/http/request.ts +71 -15
- package/src/core/http/retry.ts +68 -0
- package/src/core/interactive/codex.ts +21 -0
- package/src/core/interactive/keys.ts +19 -10
- package/src/core/output/usages.ts +11 -30
- package/src/core/setup/codex.ts +4 -0
- package/src/core/setup/env.ts +14 -6
- package/tests/auth/refresh.test.ts +149 -0
- package/tests/cmd/codex.test.ts +87 -1
- package/tests/cmd/keys.test.ts +48 -14
- package/tests/cmd/models.test.ts +5 -2
- package/tests/cmd/usages.test.ts +5 -5
- package/tests/config/fs.test.ts +22 -1
- package/tests/config/index.test.ts +16 -1
- package/tests/config/paths.test.ts +23 -0
- package/tests/core/api/pagination.test.ts +87 -0
- package/tests/core/interactive/codex.test.ts +25 -1
- package/tests/core/setup/env.test.ts +18 -4
- package/tests/http/request.test.ts +157 -0
- package/tests/http/retry.test.ts +152 -0
- package/tests/output/usages.test.ts +11 -12
- package/tsconfig.json +3 -2
- package/src/core/paths.ts +0 -4
- 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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
44
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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(
|
|
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(
|
|
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
|
-
.
|
|
193
|
+
.option("--show", "Show full API keys")
|
|
194
|
+
.action(async (options: { show?: boolean }, command: Command) => {
|
|
178
195
|
const { consumerService } = createApiClients({});
|
|
179
|
-
|
|
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
|
+
};
|
package/src/core/config/fs.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
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) => {
|
package/src/core/config/index.ts
CHANGED
|
@@ -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
|
};
|
package/src/core/config/paths.ts
CHANGED
|
@@ -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
|
|
5
|
-
|
|
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");
|
package/src/core/http/request.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
headers.
|
|
29
|
-
headers.Cookie = `${getAuthCookieName()}=${auth.accessToken}`;
|
|
26
|
+
if (accessToken) {
|
|
27
|
+
headers.Authorization = `Bearer ${accessToken}`;
|
|
28
|
+
headers.Cookie = `${getAuthCookieName()}=${accessToken}`;
|
|
30
29
|
}
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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
|
|
39
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
74
|
-
return [header, "", ...lines, "", legend].join("\n");
|
|
55
|
+
return [header, "", ...lines].join("\n");
|
|
75
56
|
};
|