ai-cc-router 0.4.3 → 0.5.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/dist/cli/cmd-accounts.js +2 -8
- package/dist/cli/cmd-setup.js +2 -1
- package/dist/cli/cmd-status.js +100 -17
- package/dist/config/manager.js +21 -1
- package/dist/proxy/server.js +193 -4
- package/dist/proxy/token-pool.js +142 -11
- package/dist/proxy/token-refresher.js +2 -9
- package/dist/proxy/types.js +18 -0
- package/dist/ui/Dashboard.js +224 -34
- package/dist/ui/accountsApi.js +45 -0
- package/package.json +1 -1
package/dist/cli/cmd-accounts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
-
import { loadAccounts, accountsFileExists, writeAccountsAtomic } from "../config/manager.js";
|
|
2
|
+
import { loadAccounts, accountsFileExists, writeAccountsAtomic, serialize } from "../config/manager.js";
|
|
3
3
|
import { saveAccounts } from "../proxy/token-refresher.js";
|
|
4
4
|
import { formatExpiry, redactToken } from "../utils/token-extractor.js";
|
|
5
5
|
import { PROXY_PORT } from "../config/paths.js";
|
|
@@ -107,13 +107,7 @@ export function registerAccounts(program) {
|
|
|
107
107
|
console.log(chalk.gray("Cancelled."));
|
|
108
108
|
return;
|
|
109
109
|
}
|
|
110
|
-
writeAccountsAtomic(filtered
|
|
111
|
-
id: a.id,
|
|
112
|
-
accessToken: a.tokens.accessToken,
|
|
113
|
-
refreshToken: a.tokens.refreshToken,
|
|
114
|
-
expiresAt: a.tokens.expiresAt,
|
|
115
|
-
scopes: a.tokens.scopes,
|
|
116
|
-
})));
|
|
110
|
+
writeAccountsAtomic(serialize(filtered));
|
|
117
111
|
console.log(chalk.green(`✓ Removed "${id}". ${filtered.length} account(s) remaining.`));
|
|
118
112
|
if (filtered.length === 0) {
|
|
119
113
|
console.log(chalk.yellow(" No accounts left. Run: cc-router setup"));
|
package/dist/cli/cmd-setup.js
CHANGED
|
@@ -7,7 +7,7 @@ import { writeClaudeSettings, readClaudeProxySettings } from "../utils/claude-co
|
|
|
7
7
|
import { saveAccounts } from "../proxy/token-refresher.js";
|
|
8
8
|
import { loadAccounts, accountsFileExists, readConfig, writeConfig, generateProxySecret } from "../config/manager.js";
|
|
9
9
|
import { PROXY_PORT } from "../config/paths.js";
|
|
10
|
-
import { DEFAULT_RATE_LIMITS } from "../proxy/types.js";
|
|
10
|
+
import { DEFAULT_RATE_LIMITS, ACCOUNT_USER_DEFAULTS } from "../proxy/types.js";
|
|
11
11
|
import { existsSync } from "fs";
|
|
12
12
|
import { checkMitmproxyInstalled, isCaCertInstalled, generateCaCert, installCaCert, writeAddonScript, getNetworkExtensionStatus, openNetworkExtensionSettings, } from "../interceptor/mitmproxy-manager.js";
|
|
13
13
|
import { printDesktopSupportExplainer, printNetworkExtensionInstructions } from "./cmd-client.js";
|
|
@@ -104,6 +104,7 @@ export async function setupSingleAccount(index) {
|
|
|
104
104
|
lastRefresh: 0,
|
|
105
105
|
consecutiveErrors: 0,
|
|
106
106
|
rateLimits: { ...DEFAULT_RATE_LIMITS },
|
|
107
|
+
...ACCOUNT_USER_DEFAULTS,
|
|
107
108
|
};
|
|
108
109
|
}
|
|
109
110
|
// ─── Full wizard ──────────────────────────────────────────────────────────────
|
package/dist/cli/cmd-status.js
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { PROXY_PORT } from "../config/paths.js";
|
|
3
3
|
import { readConfig } from "../config/manager.js";
|
|
4
|
-
|
|
5
|
-
* Resolves where the health endpoint lives.
|
|
6
|
-
*
|
|
7
|
-
* In client mode → remote CC-Router URL (from config)
|
|
8
|
-
* Otherwise → http://localhost:<port>
|
|
9
|
-
*/
|
|
10
|
-
function resolveTarget() {
|
|
4
|
+
export function resolveStatusTarget(port) {
|
|
11
5
|
const cfg = readConfig();
|
|
12
6
|
if (cfg.client) {
|
|
13
7
|
const base = cfg.client.remoteUrl.replace(/\/+$/, "");
|
|
14
8
|
const headers = {};
|
|
15
9
|
if (cfg.client.remoteSecret)
|
|
16
10
|
headers["authorization"] = `Bearer ${cfg.client.remoteSecret}`;
|
|
17
|
-
return {
|
|
11
|
+
return {
|
|
12
|
+
baseUrl: base,
|
|
13
|
+
healthUrl: `${base}/cc-router/health`,
|
|
14
|
+
headers,
|
|
15
|
+
authToken: cfg.client.remoteSecret,
|
|
16
|
+
};
|
|
18
17
|
}
|
|
19
|
-
|
|
18
|
+
const base = `http://localhost:${port}`;
|
|
19
|
+
const headers = {};
|
|
20
|
+
if (cfg.proxySecret)
|
|
21
|
+
headers["authorization"] = `Bearer ${cfg.proxySecret}`;
|
|
22
|
+
return {
|
|
23
|
+
baseUrl: base,
|
|
24
|
+
healthUrl: `${base}/cc-router/health`,
|
|
25
|
+
headers,
|
|
26
|
+
authToken: cfg.proxySecret,
|
|
27
|
+
};
|
|
20
28
|
}
|
|
21
29
|
export function registerStatus(program) {
|
|
22
30
|
program
|
|
@@ -30,11 +38,11 @@ export function registerStatus(program) {
|
|
|
30
38
|
await jsonOutput(port);
|
|
31
39
|
return;
|
|
32
40
|
}
|
|
33
|
-
await
|
|
41
|
+
await dashboardLoop(port);
|
|
34
42
|
});
|
|
35
43
|
}
|
|
36
44
|
async function jsonOutput(port) {
|
|
37
|
-
const { healthUrl, headers } =
|
|
45
|
+
const { healthUrl, headers } = resolveStatusTarget(port);
|
|
38
46
|
try {
|
|
39
47
|
const res = await fetch(healthUrl, {
|
|
40
48
|
headers,
|
|
@@ -58,16 +66,91 @@ async function jsonOutput(port) {
|
|
|
58
66
|
process.exit(1);
|
|
59
67
|
}
|
|
60
68
|
}
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Launches the Ink dashboard and handles "re-launch" intents.
|
|
71
|
+
*
|
|
72
|
+
* The dashboard cannot run inquirer prompts while Ink owns stdin, so when
|
|
73
|
+
* the user presses `n` to add an account, Ink unmounts and this loop runs
|
|
74
|
+
* the OAuth flow synchronously. Once tokens are obtained and POSTed to the
|
|
75
|
+
* server, the dashboard is re-rendered and polling resumes.
|
|
76
|
+
*/
|
|
77
|
+
async function dashboardLoop(port) {
|
|
63
78
|
// Dynamic imports keep these heavy deps out of the cold-start path
|
|
64
79
|
const [{ render }, { createElement }, { Dashboard }] = await Promise.all([
|
|
65
80
|
import("ink"),
|
|
66
81
|
import("react"),
|
|
67
82
|
import("../ui/Dashboard.js"),
|
|
68
83
|
]);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
84
|
+
while (true) {
|
|
85
|
+
const target = resolveStatusTarget(port);
|
|
86
|
+
const intent = await new Promise((resolve) => {
|
|
87
|
+
const onIntent = (i) => resolve(i);
|
|
88
|
+
const instance = render(createElement(Dashboard, {
|
|
89
|
+
port,
|
|
90
|
+
baseUrl: target.baseUrl,
|
|
91
|
+
authToken: target.authToken,
|
|
92
|
+
onIntent,
|
|
93
|
+
}),
|
|
94
|
+
// Let Ink handle Ctrl+C — it calls exit() which cleanly unmounts
|
|
95
|
+
{ exitOnCtrlC: true });
|
|
96
|
+
// If Ink exits on its own (Ctrl+C or `q`) without firing onIntent, treat as quit.
|
|
97
|
+
instance.waitUntilExit().then(() => resolve("quit"));
|
|
98
|
+
});
|
|
99
|
+
if (intent === "quit")
|
|
100
|
+
return;
|
|
101
|
+
// Intent: addAccount — unmount and run the OAuth flow, then POST the
|
|
102
|
+
// resulting tokens to the server we're connected to (local or remote).
|
|
103
|
+
console.log();
|
|
104
|
+
console.log(chalk.cyan("→ Adding a new account..."));
|
|
105
|
+
console.log();
|
|
106
|
+
const added = await runAddAccountFlow(target);
|
|
107
|
+
if (added) {
|
|
108
|
+
console.log(chalk.green(`\n✓ Account "${added}" added. Returning to dashboard...\n`));
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
console.log(chalk.yellow("\n No account added. Returning to dashboard...\n"));
|
|
112
|
+
}
|
|
113
|
+
// Fall through → loop re-renders the dashboard
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Runs the existing setupSingleAccount() OAuth flow, then POSTs the resulting
|
|
118
|
+
* tokens to /cc-router/accounts on the active target. Returns the new id on
|
|
119
|
+
* success, or null if the user aborted / an error occurred.
|
|
120
|
+
*/
|
|
121
|
+
async function runAddAccountFlow(target) {
|
|
122
|
+
try {
|
|
123
|
+
const { setupSingleAccount } = await import("./cmd-setup.js");
|
|
124
|
+
// The index shown in the flow is just for display, pick something neutral.
|
|
125
|
+
const account = await setupSingleAccount(1);
|
|
126
|
+
if (!account)
|
|
127
|
+
return null;
|
|
128
|
+
const res = await fetch(`${target.baseUrl}/cc-router/accounts`, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: {
|
|
131
|
+
"content-type": "application/json",
|
|
132
|
+
...target.headers,
|
|
133
|
+
},
|
|
134
|
+
body: JSON.stringify({
|
|
135
|
+
id: account.id,
|
|
136
|
+
accessToken: account.tokens.accessToken,
|
|
137
|
+
refreshToken: account.tokens.refreshToken,
|
|
138
|
+
expiresAt: account.tokens.expiresAt,
|
|
139
|
+
scopes: account.tokens.scopes,
|
|
140
|
+
}),
|
|
141
|
+
signal: AbortSignal.timeout(5_000),
|
|
142
|
+
});
|
|
143
|
+
if (!res.ok) {
|
|
144
|
+
const text = await res.text().catch(() => "");
|
|
145
|
+
console.error(chalk.red(`\n✗ Server rejected account: HTTP ${res.status}`));
|
|
146
|
+
if (text)
|
|
147
|
+
console.error(chalk.gray(` ${text}`));
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
return account.id;
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
console.error(chalk.red(`\n✗ Failed to add account: ${err.message}`));
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
73
156
|
}
|
package/dist/config/manager.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, copyFileSync } from "fs";
|
|
2
2
|
import { randomBytes } from "crypto";
|
|
3
3
|
import { CONFIG_DIR, ACCOUNTS_PATH, CONFIG_PATH } from "./paths.js";
|
|
4
|
-
import { DEFAULT_RATE_LIMITS } from "../proxy/types.js";
|
|
4
|
+
import { DEFAULT_RATE_LIMITS, ACCOUNT_USER_DEFAULTS, clampPercent } from "../proxy/types.js";
|
|
5
5
|
export function ensureConfigDir() {
|
|
6
6
|
if (!existsSync(CONFIG_DIR)) {
|
|
7
7
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
@@ -83,5 +83,25 @@ function deserialize(records) {
|
|
|
83
83
|
lastRefresh: 0,
|
|
84
84
|
consecutiveErrors: 0,
|
|
85
85
|
rateLimits: { ...DEFAULT_RATE_LIMITS },
|
|
86
|
+
enabled: a.enabled !== false, // default true
|
|
87
|
+
sessionLimitPercent: a.sessionLimitPercent !== undefined
|
|
88
|
+
? clampPercent(a.sessionLimitPercent)
|
|
89
|
+
: ACCOUNT_USER_DEFAULTS.sessionLimitPercent,
|
|
90
|
+
weeklyLimitPercent: a.weeklyLimitPercent !== undefined
|
|
91
|
+
? clampPercent(a.weeklyLimitPercent)
|
|
92
|
+
: ACCOUNT_USER_DEFAULTS.weeklyLimitPercent,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
/** Serialize runtime Account[] back to the flat on-disk AccountRecord[] shape. */
|
|
96
|
+
export function serialize(accounts) {
|
|
97
|
+
return accounts.map(a => ({
|
|
98
|
+
id: a.id,
|
|
99
|
+
accessToken: a.tokens.accessToken,
|
|
100
|
+
refreshToken: a.tokens.refreshToken,
|
|
101
|
+
expiresAt: a.tokens.expiresAt,
|
|
102
|
+
scopes: a.tokens.scopes,
|
|
103
|
+
enabled: a.enabled,
|
|
104
|
+
sessionLimitPercent: a.sessionLimitPercent,
|
|
105
|
+
weeklyLimitPercent: a.weeklyLimitPercent,
|
|
86
106
|
}));
|
|
87
107
|
}
|
package/dist/proxy/server.js
CHANGED
|
@@ -2,9 +2,9 @@ import express from "express";
|
|
|
2
2
|
import { createProxyMiddleware } from "http-proxy-middleware";
|
|
3
3
|
import { ServerResponse } from "http";
|
|
4
4
|
import { timingSafeEqual } from "crypto";
|
|
5
|
-
import { TokenPool } from "./token-pool.js";
|
|
5
|
+
import { TokenPool, EmptyPoolError } from "./token-pool.js";
|
|
6
6
|
import { needsRefresh, refreshAccountToken, saveAccounts, startRefreshLoop } from "./token-refresher.js";
|
|
7
|
-
import { loadAccounts, accountsFileExists, readAccountsFromPath, readConfig } from "../config/manager.js";
|
|
7
|
+
import { loadAccounts, accountsFileExists, readAccountsFromPath, readConfig, serialize } from "../config/manager.js";
|
|
8
8
|
import { checkForUpdate, performUpdate, restartSelf } from "../utils/self-update.js";
|
|
9
9
|
import { trackEvent, startHeartbeat } from "../utils/telemetry.js";
|
|
10
10
|
import { loadTelemetryState } from "../config/telemetry.js";
|
|
@@ -76,6 +76,13 @@ export async function startServer(opts = {}) {
|
|
|
76
76
|
process.exit(1);
|
|
77
77
|
}
|
|
78
78
|
const pool = new TokenPool(accounts);
|
|
79
|
+
// Log when the pool falls back to a capped account — makes the cap bypass
|
|
80
|
+
// visible in the dashboard's "RECENT ACTIVITY" instead of being silent.
|
|
81
|
+
pool.onCapBypass = (a) => {
|
|
82
|
+
const msg = `all accounts capped — routing to ${a.id} (5h: ${Math.round(a.rateLimits.fiveHourUtil * 100)}%, 7d: ${Math.round(a.rateLimits.sevenDayUtil * 100)}%)`;
|
|
83
|
+
logError(a.id, 0, msg);
|
|
84
|
+
stats.addLog({ ts: Date.now(), accountId: a.id, model: "-", type: "error", details: msg });
|
|
85
|
+
};
|
|
79
86
|
startRefreshLoop(accounts);
|
|
80
87
|
const app = express();
|
|
81
88
|
// ─── Proxy auth middleware ─────────────────────────────────────────────────
|
|
@@ -124,6 +131,173 @@ export async function startServer(opts = {}) {
|
|
|
124
131
|
recentLogs: stats.getRecentLogs(50),
|
|
125
132
|
});
|
|
126
133
|
});
|
|
134
|
+
// ─── Account management endpoints (authenticated) ─────────────────────────
|
|
135
|
+
// These are mounted BEFORE the /v1/* proxy middleware so they don't get
|
|
136
|
+
// forwarded to Anthropic. express.json() is scoped to this sub-router so
|
|
137
|
+
// the SSE streaming on /v1/* is never touched (see comment at /v1 handler).
|
|
138
|
+
const accountsRouter = express.Router();
|
|
139
|
+
accountsRouter.use(express.json({ limit: "32kb" }));
|
|
140
|
+
// Shape returned to clients — NEVER includes access/refresh tokens.
|
|
141
|
+
const publicAccountView = (a) => ({
|
|
142
|
+
id: a.id,
|
|
143
|
+
enabled: a.enabled,
|
|
144
|
+
sessionLimitPercent: a.sessionLimitPercent,
|
|
145
|
+
weeklyLimitPercent: a.weeklyLimitPercent,
|
|
146
|
+
healthy: a.healthy,
|
|
147
|
+
busy: a.busy,
|
|
148
|
+
requestCount: a.requestCount,
|
|
149
|
+
errorCount: a.errorCount,
|
|
150
|
+
expiresInMs: a.tokens.expiresAt - Date.now(),
|
|
151
|
+
lastUsedMs: a.lastUsed,
|
|
152
|
+
lastRefreshMs: a.lastRefresh,
|
|
153
|
+
rateLimits: a.rateLimits,
|
|
154
|
+
});
|
|
155
|
+
accountsRouter.get("/", (_req, res) => {
|
|
156
|
+
res.json({ accounts: pool.getAll().map(publicAccountView) });
|
|
157
|
+
});
|
|
158
|
+
/**
|
|
159
|
+
* Persist the pool to disk, returning a structured result instead of
|
|
160
|
+
* throwing. Callers hold a rollback closure for in-memory state in case
|
|
161
|
+
* the disk write fails — so a ENOSPC / EACCES doesn't leave the server
|
|
162
|
+
* silently out of sync with accounts.json.
|
|
163
|
+
*/
|
|
164
|
+
const tryPersist = (rollback) => {
|
|
165
|
+
try {
|
|
166
|
+
saveAccounts(pool.getAll());
|
|
167
|
+
return { ok: true };
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
171
|
+
try {
|
|
172
|
+
rollback();
|
|
173
|
+
}
|
|
174
|
+
catch { /* best effort */ }
|
|
175
|
+
logError("accounts", 0, `Failed to persist accounts.json: ${message}`);
|
|
176
|
+
return { ok: false, message };
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
accountsRouter.patch("/:id", (req, res) => {
|
|
180
|
+
const { id } = req.params;
|
|
181
|
+
const body = (req.body ?? {});
|
|
182
|
+
const patch = {};
|
|
183
|
+
if (body.enabled !== undefined) {
|
|
184
|
+
if (typeof body.enabled !== "boolean") {
|
|
185
|
+
res.status(400).json({ error: "enabled must be boolean" });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
patch.enabled = body.enabled;
|
|
189
|
+
}
|
|
190
|
+
for (const key of ["sessionLimitPercent", "weeklyLimitPercent"]) {
|
|
191
|
+
const v = body[key];
|
|
192
|
+
if (v === undefined)
|
|
193
|
+
continue;
|
|
194
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 100) {
|
|
195
|
+
res.status(400).json({ error: `${key} must be a number between 0 and 100` });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
patch[key] = v;
|
|
199
|
+
}
|
|
200
|
+
// Snapshot the previous values so we can roll back on persistence failure
|
|
201
|
+
const existing = pool.findById(id);
|
|
202
|
+
if (!existing) {
|
|
203
|
+
res.status(404).json({ error: `Account "${id}" not found` });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const prev = {
|
|
207
|
+
enabled: existing.enabled,
|
|
208
|
+
sessionLimitPercent: existing.sessionLimitPercent,
|
|
209
|
+
weeklyLimitPercent: existing.weeklyLimitPercent,
|
|
210
|
+
};
|
|
211
|
+
const updated = pool.updateAccount(id, patch);
|
|
212
|
+
if (!updated) {
|
|
213
|
+
res.status(404).json({ error: `Account "${id}" not found` });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const result = tryPersist(() => {
|
|
217
|
+
pool.updateAccount(id, prev);
|
|
218
|
+
});
|
|
219
|
+
if (!result.ok) {
|
|
220
|
+
res.status(500).json({ error: `Failed to persist accounts.json: ${result.message}` });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
res.json({ account: publicAccountView(updated) });
|
|
224
|
+
});
|
|
225
|
+
accountsRouter.post("/", (req, res) => {
|
|
226
|
+
const body = (req.body ?? {});
|
|
227
|
+
const required = ["id", "accessToken", "refreshToken", "expiresAt"];
|
|
228
|
+
for (const k of required) {
|
|
229
|
+
if (body[k] === undefined || body[k] === null || body[k] === "") {
|
|
230
|
+
res.status(400).json({ error: `Missing required field: ${k}` });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (typeof body.id !== "string" || typeof body.accessToken !== "string" ||
|
|
235
|
+
typeof body.refreshToken !== "string" || typeof body.expiresAt !== "number") {
|
|
236
|
+
res.status(400).json({ error: "Invalid field types on account record" });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (pool.findById(body.id)) {
|
|
240
|
+
res.status(409).json({ error: `Account "${body.id}" already exists` });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const record = {
|
|
244
|
+
id: body.id,
|
|
245
|
+
accessToken: body.accessToken,
|
|
246
|
+
refreshToken: body.refreshToken,
|
|
247
|
+
expiresAt: body.expiresAt,
|
|
248
|
+
scopes: Array.isArray(body.scopes) ? body.scopes : ["user:inference", "user:profile"],
|
|
249
|
+
enabled: body.enabled,
|
|
250
|
+
sessionLimitPercent: body.sessionLimitPercent,
|
|
251
|
+
weeklyLimitPercent: body.weeklyLimitPercent,
|
|
252
|
+
};
|
|
253
|
+
let added;
|
|
254
|
+
try {
|
|
255
|
+
added = pool.addAccount(record);
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
res.status(400).json({ error: err.message });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const result = tryPersist(() => {
|
|
262
|
+
pool.removeAccount(record.id);
|
|
263
|
+
});
|
|
264
|
+
if (!result.ok) {
|
|
265
|
+
res.status(500).json({ error: `Failed to persist accounts.json: ${result.message}` });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
res.status(201).json({ account: publicAccountView(added) });
|
|
269
|
+
});
|
|
270
|
+
accountsRouter.delete("/:id", (req, res) => {
|
|
271
|
+
const { id } = req.params;
|
|
272
|
+
// Refuse to remove the last account — downstream /v1/* would have no
|
|
273
|
+
// token to route with and the pool would throw EmptyPoolError on the
|
|
274
|
+
// next request. Users who want an empty pool should `cc-router stop`.
|
|
275
|
+
if (pool.getAll().length <= 1) {
|
|
276
|
+
res.status(409).json({ error: "Cannot remove the last account — at least one must remain" });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const existing = pool.findById(id);
|
|
280
|
+
if (!existing) {
|
|
281
|
+
res.status(404).json({ error: `Account "${id}" not found` });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
// Snapshot for rollback. serialize() gives us a persistable AccountRecord.
|
|
285
|
+
const snapshot = serialize([existing])[0];
|
|
286
|
+
const removed = pool.removeAccount(id);
|
|
287
|
+
if (!removed) {
|
|
288
|
+
res.status(404).json({ error: `Account "${id}" not found` });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const result = tryPersist(() => {
|
|
292
|
+
pool.addAccount(snapshot);
|
|
293
|
+
});
|
|
294
|
+
if (!result.ok) {
|
|
295
|
+
res.status(500).json({ error: `Failed to persist accounts.json: ${result.message}` });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
res.json({ ok: true, id });
|
|
299
|
+
});
|
|
300
|
+
app.use("/cc-router/accounts", accountsRouter);
|
|
127
301
|
// ─── Proxy middleware ──────────────────────────────────────────────────────
|
|
128
302
|
// IMPORTANT: selfHandleResponse must be false (default) for SSE streaming to
|
|
129
303
|
// work transparently. Setting it to true breaks streaming.
|
|
@@ -310,8 +484,23 @@ export async function startServer(opts = {}) {
|
|
|
310
484
|
// ─── /v1/* — select account, refresh if needed, then proxy ───────────────
|
|
311
485
|
// CRITICAL: Do NOT use express.json() here — it consumes the body stream
|
|
312
486
|
// and breaks SSE streaming passthrough.
|
|
313
|
-
app.use("/v1", async (req,
|
|
314
|
-
|
|
487
|
+
app.use("/v1", async (req, res, next) => {
|
|
488
|
+
let account;
|
|
489
|
+
try {
|
|
490
|
+
account = pool.getNext();
|
|
491
|
+
}
|
|
492
|
+
catch (err) {
|
|
493
|
+
if (err instanceof EmptyPoolError) {
|
|
494
|
+
stats.totalErrors++;
|
|
495
|
+
logError("proxy", 503, err.message);
|
|
496
|
+
res.status(503).json({
|
|
497
|
+
type: "error",
|
|
498
|
+
error: { type: "no_accounts", message: err.message },
|
|
499
|
+
});
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
throw err;
|
|
503
|
+
}
|
|
315
504
|
// Synchronous refresh if token expires within the buffer window
|
|
316
505
|
if (needsRefresh(account)) {
|
|
317
506
|
const ok = await refreshAccountToken(account);
|
package/dist/proxy/token-pool.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { DEFAULT_RATE_LIMITS, ACCOUNT_USER_DEFAULTS, clampPercent } from "./types.js";
|
|
2
|
+
export class EmptyPoolError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "EmptyPoolError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
1
8
|
/** Returns the earliest non-zero reset timestamp (seconds) for an account. */
|
|
2
9
|
function earliestReset(a) {
|
|
3
10
|
const r = a.rateLimits;
|
|
@@ -5,6 +12,15 @@ function earliestReset(a) {
|
|
|
5
12
|
return Math.min(r.fiveHourReset, r.sevenDayReset);
|
|
6
13
|
return r.fiveHourReset || r.sevenDayReset || Infinity;
|
|
7
14
|
}
|
|
15
|
+
/** True when the account's user-defined caps have been reached. */
|
|
16
|
+
function overUserCap(a) {
|
|
17
|
+
return (a.rateLimits.fiveHourUtil * 100 >= a.sessionLimitPercent ||
|
|
18
|
+
a.rateLimits.sevenDayUtil * 100 >= a.weeklyLimitPercent);
|
|
19
|
+
}
|
|
20
|
+
/** Filter out accounts the user has taken out of the rotation. */
|
|
21
|
+
function isUsable(a) {
|
|
22
|
+
return a.enabled && !overUserCap(a);
|
|
23
|
+
}
|
|
8
24
|
export class TokenPool {
|
|
9
25
|
accounts;
|
|
10
26
|
currentIndex = 0;
|
|
@@ -12,24 +28,52 @@ export class TokenPool {
|
|
|
12
28
|
this.accounts = accounts;
|
|
13
29
|
}
|
|
14
30
|
/**
|
|
15
|
-
* Round-robin selection among
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
31
|
+
* Round-robin selection among accounts that are:
|
|
32
|
+
* • healthy
|
|
33
|
+
* • not busy
|
|
34
|
+
* • not rate-limited by Anthropic
|
|
35
|
+
* • enabled (user toggle)
|
|
36
|
+
* • under the user-configured 5h/7d caps
|
|
37
|
+
*
|
|
38
|
+
* Fallback chain when nothing is available:
|
|
39
|
+
* 1. Any healthy+usable (enabled & under caps) account — pick earliest reset.
|
|
40
|
+
* 2. Any healthy account — pick earliest reset. This intentionally ignores
|
|
41
|
+
* user caps when every option is capped; limits are advisory, not a hard
|
|
42
|
+
* ban that would leave Claude Code with no working account. The fallback
|
|
43
|
+
* is logged via the optional onCapBypass callback so the dashboard can
|
|
44
|
+
* surface it instead of silently exceeding the cap.
|
|
45
|
+
* 3. accounts[0] as a last resort (only if every account is unhealthy).
|
|
46
|
+
*
|
|
47
|
+
* Throws `EmptyPoolError` when there are no accounts at all — callers in
|
|
48
|
+
* the request path should map this to a 503. The DELETE endpoint guards
|
|
49
|
+
* against this state by refusing to remove the last account.
|
|
19
50
|
*/
|
|
20
51
|
getNext() {
|
|
21
|
-
|
|
52
|
+
if (this.accounts.length === 0) {
|
|
53
|
+
throw new EmptyPoolError("token pool is empty — add an account first");
|
|
54
|
+
}
|
|
55
|
+
const available = this.accounts.filter(a => a.healthy &&
|
|
56
|
+
!a.busy &&
|
|
57
|
+
a.rateLimits.status !== "rate_limited" &&
|
|
58
|
+
isUsable(a));
|
|
22
59
|
if (available.length === 0) {
|
|
60
|
+
const healthyUsable = this.accounts.filter(a => a.healthy && isUsable(a));
|
|
61
|
+
if (healthyUsable.length > 0) {
|
|
62
|
+
return healthyUsable.reduce((best, a) => earliestReset(a) < earliestReset(best) ? a : best);
|
|
63
|
+
}
|
|
23
64
|
const healthy = this.accounts.filter(a => a.healthy);
|
|
24
65
|
if (healthy.length === 0) {
|
|
25
66
|
return this.accounts[0];
|
|
26
67
|
}
|
|
27
|
-
// All healthy
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
68
|
+
// All healthy accounts are either busy, rate-limited, or over user caps.
|
|
69
|
+
// Fall back to the one that'll reset soonest — see docstring. Notify
|
|
70
|
+
// the listener so the bypass becomes visible in the dashboard.
|
|
71
|
+
const fallback = healthy.reduce((best, a) => earliestReset(a) < earliestReset(best) ? a : best);
|
|
72
|
+
const someCapped = this.accounts.some(a => a.healthy && overUserCap(a));
|
|
73
|
+
if (someCapped && this.onCapBypass) {
|
|
74
|
+
this.onCapBypass(fallback);
|
|
75
|
+
}
|
|
76
|
+
return fallback;
|
|
33
77
|
}
|
|
34
78
|
const account = available[this.currentIndex % available.length];
|
|
35
79
|
this.currentIndex = (this.currentIndex + 1) % available.length;
|
|
@@ -37,6 +81,9 @@ export class TokenPool {
|
|
|
37
81
|
account.lastUsed = Date.now();
|
|
38
82
|
return account;
|
|
39
83
|
}
|
|
84
|
+
/** Optional listener fired when a request is routed to a capped account
|
|
85
|
+
* because every account in the pool was over its user-configured cap. */
|
|
86
|
+
onCapBypass;
|
|
40
87
|
getAll() {
|
|
41
88
|
return this.accounts;
|
|
42
89
|
}
|
|
@@ -54,6 +101,90 @@ export class TokenPool {
|
|
|
54
101
|
lastUsedMs: a.lastUsed,
|
|
55
102
|
lastRefreshMs: a.lastRefresh,
|
|
56
103
|
rateLimits: a.rateLimits,
|
|
104
|
+
enabled: a.enabled,
|
|
105
|
+
sessionLimitPercent: a.sessionLimitPercent,
|
|
106
|
+
weeklyLimitPercent: a.weeklyLimitPercent,
|
|
57
107
|
}));
|
|
58
108
|
}
|
|
109
|
+
// ─── Mutation API (used by the authenticated HTTP endpoints) ───────────────
|
|
110
|
+
findById(id) {
|
|
111
|
+
return this.accounts.find(a => a.id === id) ?? null;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Apply a partial update to an account's user-controlled fields.
|
|
115
|
+
* Only `enabled`, `sessionLimitPercent`, and `weeklyLimitPercent` are
|
|
116
|
+
* touched — token fields are never accepted via this API.
|
|
117
|
+
* Returns the updated account, or null if the id was not found.
|
|
118
|
+
*/
|
|
119
|
+
updateAccount(id, patch) {
|
|
120
|
+
const a = this.findById(id);
|
|
121
|
+
if (!a)
|
|
122
|
+
return null;
|
|
123
|
+
if (patch.enabled !== undefined)
|
|
124
|
+
a.enabled = !!patch.enabled;
|
|
125
|
+
if (patch.sessionLimitPercent !== undefined) {
|
|
126
|
+
a.sessionLimitPercent = clampPercent(patch.sessionLimitPercent);
|
|
127
|
+
}
|
|
128
|
+
if (patch.weeklyLimitPercent !== undefined) {
|
|
129
|
+
a.weeklyLimitPercent = clampPercent(patch.weeklyLimitPercent);
|
|
130
|
+
}
|
|
131
|
+
return a;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Append a new account built from a persisted AccountRecord.
|
|
135
|
+
* Rejects duplicates by id — callers should pre-check with findById().
|
|
136
|
+
*/
|
|
137
|
+
addAccount(record) {
|
|
138
|
+
if (this.findById(record.id)) {
|
|
139
|
+
throw new Error(`Account "${record.id}" already exists`);
|
|
140
|
+
}
|
|
141
|
+
const account = {
|
|
142
|
+
id: record.id,
|
|
143
|
+
tokens: {
|
|
144
|
+
accessToken: record.accessToken,
|
|
145
|
+
refreshToken: record.refreshToken,
|
|
146
|
+
expiresAt: record.expiresAt,
|
|
147
|
+
scopes: record.scopes ?? ["user:inference", "user:profile"],
|
|
148
|
+
},
|
|
149
|
+
healthy: true,
|
|
150
|
+
busy: false,
|
|
151
|
+
requestCount: 0,
|
|
152
|
+
errorCount: 0,
|
|
153
|
+
lastUsed: 0,
|
|
154
|
+
lastRefresh: 0,
|
|
155
|
+
consecutiveErrors: 0,
|
|
156
|
+
rateLimits: { ...DEFAULT_RATE_LIMITS },
|
|
157
|
+
enabled: record.enabled !== false,
|
|
158
|
+
sessionLimitPercent: record.sessionLimitPercent !== undefined
|
|
159
|
+
? clampPercent(record.sessionLimitPercent)
|
|
160
|
+
: ACCOUNT_USER_DEFAULTS.sessionLimitPercent,
|
|
161
|
+
weeklyLimitPercent: record.weeklyLimitPercent !== undefined
|
|
162
|
+
? clampPercent(record.weeklyLimitPercent)
|
|
163
|
+
: ACCOUNT_USER_DEFAULTS.weeklyLimitPercent,
|
|
164
|
+
};
|
|
165
|
+
this.accounts.push(account);
|
|
166
|
+
return account;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Remove an account by id. Returns true if something was removed.
|
|
170
|
+
*
|
|
171
|
+
* CRITICAL: mutates `this.accounts` IN PLACE via splice() rather than
|
|
172
|
+
* reassigning it. The server passes the same array reference to
|
|
173
|
+
* `startRefreshLoop()` at startup; reassigning would desynchronize the
|
|
174
|
+
* refresh loop from the pool, and the loop's `saveAccounts(accounts)` call
|
|
175
|
+
* would later resurrect the deleted account on disk.
|
|
176
|
+
*/
|
|
177
|
+
removeAccount(id) {
|
|
178
|
+
const idx = this.accounts.findIndex(a => a.id === id);
|
|
179
|
+
if (idx === -1)
|
|
180
|
+
return false;
|
|
181
|
+
this.accounts.splice(idx, 1);
|
|
182
|
+
if (this.accounts.length > 0) {
|
|
183
|
+
this.currentIndex = this.currentIndex % this.accounts.length;
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
this.currentIndex = 0;
|
|
187
|
+
}
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
59
190
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { writeAccountsAtomic } from "../config/manager.js";
|
|
1
|
+
import { writeAccountsAtomic, serialize } from "../config/manager.js";
|
|
2
2
|
import { logRefresh } from "./logger.js";
|
|
3
3
|
import { stats } from "./stats.js";
|
|
4
4
|
/**
|
|
@@ -86,14 +86,7 @@ async function _doRefresh(account) {
|
|
|
86
86
|
* Must be called after every successful refresh since refresh_token ROTATES.
|
|
87
87
|
*/
|
|
88
88
|
export function saveAccounts(accounts) {
|
|
89
|
-
|
|
90
|
-
id: a.id,
|
|
91
|
-
accessToken: a.tokens.accessToken,
|
|
92
|
-
refreshToken: a.tokens.refreshToken,
|
|
93
|
-
expiresAt: a.tokens.expiresAt,
|
|
94
|
-
scopes: a.tokens.scopes,
|
|
95
|
-
}));
|
|
96
|
-
writeAccountsAtomic(records);
|
|
89
|
+
writeAccountsAtomic(serialize(accounts));
|
|
97
90
|
}
|
|
98
91
|
/**
|
|
99
92
|
* Background refresh loop: checks every 5 minutes and refreshes any
|
package/dist/proxy/types.js
CHANGED
|
@@ -9,3 +9,21 @@ export const DEFAULT_RATE_LIMITS = {
|
|
|
9
9
|
requestsLimit: 0,
|
|
10
10
|
lastUpdated: 0,
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* Single source of truth for the default values of user-controllable
|
|
14
|
+
* account fields. Used by deserialize(), TokenPool.addAccount(),
|
|
15
|
+
* setupSingleAccount(), and the PATCH validation path.
|
|
16
|
+
*/
|
|
17
|
+
export const ACCOUNT_USER_DEFAULTS = {
|
|
18
|
+
enabled: true,
|
|
19
|
+
sessionLimitPercent: 100,
|
|
20
|
+
weeklyLimitPercent: 100,
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Coerce any unknown value into a valid percent in [0, 100].
|
|
24
|
+
* Non-numbers, NaN, and out-of-range values collapse to the fallback (100).
|
|
25
|
+
*/
|
|
26
|
+
export function clampPercent(n) {
|
|
27
|
+
const v = typeof n === "number" && Number.isFinite(n) ? n : 100;
|
|
28
|
+
return Math.max(0, Math.min(100, Math.round(v)));
|
|
29
|
+
}
|
package/dist/ui/Dashboard.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useEffect } from "react";
|
|
2
|
+
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
3
3
|
import { Box, Text, useInput, useApp } from "ink";
|
|
4
|
+
import { createAccountsApi } from "./accountsApi.js";
|
|
4
5
|
const POLL_INTERVAL_MS = 2_000;
|
|
5
6
|
const LOG_VISIBLE = 20;
|
|
6
7
|
const EMPTY_RL = {
|
|
@@ -8,21 +9,24 @@ const EMPTY_RL = {
|
|
|
8
9
|
sevenDayUtil: 0, sevenDayReset: 0, claim: "", plan: "",
|
|
9
10
|
requestsLimit: 0, lastUpdated: 0,
|
|
10
11
|
};
|
|
11
|
-
export function Dashboard({ port, baseUrl, authToken }) {
|
|
12
|
+
export function Dashboard({ port, baseUrl, authToken, onIntent }) {
|
|
12
13
|
const { exit } = useApp();
|
|
13
14
|
const [data, setData] = useState(null);
|
|
14
15
|
const [connectError, setConnectError] = useState(null);
|
|
15
16
|
const [lastUpdate, setLastUpdate] = useState(0);
|
|
16
17
|
const [retryCount, setRetryCount] = useState(0);
|
|
18
|
+
const resolvedBase = baseUrl
|
|
19
|
+
? baseUrl.replace(/\/+$/, "")
|
|
20
|
+
: `http://localhost:${port}`;
|
|
21
|
+
const api = React.useMemo(() => createAccountsApi(resolvedBase, authToken), [resolvedBase, authToken]);
|
|
22
|
+
// Only q to quit when no live data yet (no mode to cancel)
|
|
17
23
|
useInput((input, key) => {
|
|
18
|
-
if (input === "q" || key.escape)
|
|
24
|
+
if (!data && (input === "q" || key.escape))
|
|
19
25
|
exit();
|
|
20
26
|
});
|
|
21
27
|
useEffect(() => {
|
|
22
28
|
let cancelled = false;
|
|
23
|
-
const healthUrl =
|
|
24
|
-
? `${baseUrl.replace(/\/+$/, "")}/cc-router/health`
|
|
25
|
-
: `http://localhost:${port}/cc-router/health`;
|
|
29
|
+
const healthUrl = `${resolvedBase}/cc-router/health`;
|
|
26
30
|
const headers = authToken
|
|
27
31
|
? { authorization: `Bearer ${authToken}` }
|
|
28
32
|
: {};
|
|
@@ -47,77 +51,264 @@ export function Dashboard({ port, baseUrl, authToken }) {
|
|
|
47
51
|
catch {
|
|
48
52
|
if (cancelled)
|
|
49
53
|
return;
|
|
50
|
-
setConnectError(`Cannot connect to
|
|
54
|
+
setConnectError(`Cannot connect to ${resolvedBase}`);
|
|
51
55
|
setRetryCount(n => n + 1);
|
|
52
56
|
}
|
|
53
57
|
};
|
|
54
58
|
poll();
|
|
55
59
|
const timer = setInterval(poll, POLL_INTERVAL_MS);
|
|
56
60
|
return () => { cancelled = true; clearInterval(timer); };
|
|
57
|
-
}, [
|
|
61
|
+
}, [resolvedBase, authToken]);
|
|
58
62
|
if (connectError) {
|
|
59
63
|
return _jsx(ErrorScreen, { error: connectError, port: port, retries: retryCount });
|
|
60
64
|
}
|
|
61
65
|
if (!data) {
|
|
62
|
-
return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u280B Connecting to
|
|
66
|
+
return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u280B Connecting to ", resolvedBase, "..."] }) }));
|
|
63
67
|
}
|
|
64
|
-
return _jsx(LiveDashboard, { data: data, port: port, lastUpdate: lastUpdate });
|
|
68
|
+
return (_jsx(LiveDashboard, { data: data, port: port, lastUpdate: lastUpdate, api: api, onIntent: onIntent }));
|
|
65
69
|
}
|
|
66
70
|
// ─── Error screen ─────────────────────────────────────────────────────────────
|
|
67
71
|
function ErrorScreen({ error, port, retries }) {
|
|
68
72
|
return (_jsxs(Box, { flexDirection: "column", marginY: 1, marginX: 2, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: "Is the proxy running? Start it with:" }), _jsx(Text, { color: "cyan", children: " cc-router start" })] }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "gray", children: ["Retrying every ", POLL_INTERVAL_MS / 1000, "s"] }), retries > 0 && _jsxs(Text, { color: "gray", children: [" (attempt ", retries, ")"] }), _jsx(Text, { color: "gray", children: " \u00B7 [q] quit" })] })] }));
|
|
69
73
|
}
|
|
70
74
|
// ─── Live dashboard ───────────────────────────────────────────────────────────
|
|
71
|
-
function LiveDashboard({ data, port, lastUpdate }) {
|
|
75
|
+
function LiveDashboard({ data, port, lastUpdate, api, onIntent, }) {
|
|
76
|
+
const { exit } = useApp();
|
|
72
77
|
const healthyCount = data.accounts.filter(a => a.healthy).length;
|
|
73
78
|
const updatedAgo = Math.round((Date.now() - lastUpdate) / 1000);
|
|
74
79
|
const logs = data.recentLogs;
|
|
75
|
-
//
|
|
80
|
+
// ── Focus / mode ──────────────────────────────────────────────────────────
|
|
81
|
+
const [focus, setFocus] = useState("logs");
|
|
82
|
+
const [mode, setMode] = useState("view");
|
|
83
|
+
// Selected log by timestamp (existing)
|
|
76
84
|
const [selectedTs, setSelectedTs] = useState(null);
|
|
77
|
-
|
|
78
|
-
const selectedIndex = selectedTs !== null
|
|
85
|
+
const selectedLogIndex = selectedTs !== null
|
|
79
86
|
? Math.max(0, logs.findIndex(l => l.ts === selectedTs))
|
|
80
87
|
: 0;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
// Selected account by id
|
|
89
|
+
const [selectedAccountId, setSelectedAccountId] = useState(null);
|
|
90
|
+
const selectedAccountIndex = selectedAccountId !== null
|
|
91
|
+
? Math.max(0, data.accounts.findIndex(a => a.id === selectedAccountId))
|
|
92
|
+
: 0;
|
|
93
|
+
const selectedAccount = data.accounts[selectedAccountIndex] ?? null;
|
|
94
|
+
// Inline text input state (for w / s keys)
|
|
95
|
+
const [editBuffer, setEditBuffer] = useState("");
|
|
96
|
+
// Transient banner (error or success, cleared after 4s).
|
|
97
|
+
// The timer handle is stored in a ref so new banners cancel the previous
|
|
98
|
+
// timeout and component unmount also clears it — otherwise a deferred
|
|
99
|
+
// setBanner can fire on an unmounted component after `n` exits Ink.
|
|
100
|
+
const [banner, setBanner] = useState(null);
|
|
101
|
+
const bannerTimerRef = useRef(null);
|
|
102
|
+
const showBanner = useCallback((text, color) => {
|
|
103
|
+
if (bannerTimerRef.current)
|
|
104
|
+
clearTimeout(bannerTimerRef.current);
|
|
105
|
+
setBanner({ text, color });
|
|
106
|
+
bannerTimerRef.current = setTimeout(() => {
|
|
107
|
+
setBanner(null);
|
|
108
|
+
bannerTimerRef.current = null;
|
|
109
|
+
}, 4_000);
|
|
110
|
+
}, []);
|
|
111
|
+
useEffect(() => () => {
|
|
112
|
+
if (bannerTimerRef.current)
|
|
113
|
+
clearTimeout(bannerTimerRef.current);
|
|
114
|
+
}, []);
|
|
115
|
+
// Normalize any thrown value to a displayable string — rejections from
|
|
116
|
+
// fetch/AbortSignal can be DOMException without .message, strings, or
|
|
117
|
+
// even undefined.
|
|
118
|
+
const errMsg = (err) => {
|
|
119
|
+
if (err instanceof Error && err.message)
|
|
120
|
+
return err.message;
|
|
121
|
+
const s = String(err ?? "");
|
|
122
|
+
return s || "unknown error";
|
|
123
|
+
};
|
|
124
|
+
// ── Async helpers (fire-and-forget with error → banner) ──────────────────
|
|
125
|
+
const doToggleEnabled = useCallback(async () => {
|
|
126
|
+
if (!selectedAccount)
|
|
127
|
+
return;
|
|
128
|
+
const newValue = !(selectedAccount.enabled !== false);
|
|
129
|
+
try {
|
|
130
|
+
await api.patch(selectedAccount.id, { enabled: newValue });
|
|
131
|
+
showBanner(`${selectedAccount.id} → ${newValue ? "enabled" : "disabled"}`, newValue ? "green" : "yellow");
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
showBanner(`Error: ${errMsg(err)}`, "red");
|
|
135
|
+
}
|
|
136
|
+
}, [selectedAccount, api, showBanner]);
|
|
137
|
+
const doSetLimit = useCallback(async (field, value) => {
|
|
138
|
+
if (!selectedAccount)
|
|
139
|
+
return;
|
|
140
|
+
try {
|
|
141
|
+
await api.patch(selectedAccount.id, { [field]: value });
|
|
142
|
+
const label = field === "sessionLimitPercent" ? "5h cap" : "7d cap";
|
|
143
|
+
showBanner(`${selectedAccount.id} → ${label} = ${value}%`, "green");
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
showBanner(`Error: ${errMsg(err)}`, "red");
|
|
147
|
+
}
|
|
148
|
+
}, [selectedAccount, api, showBanner]);
|
|
149
|
+
const doDelete = useCallback(async () => {
|
|
150
|
+
if (!selectedAccount)
|
|
151
|
+
return;
|
|
152
|
+
try {
|
|
153
|
+
await api.remove(selectedAccount.id);
|
|
154
|
+
showBanner(`Removed ${selectedAccount.id}`, "yellow");
|
|
155
|
+
setSelectedAccountId(null);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
showBanner(`Error: ${errMsg(err)}`, "red");
|
|
159
|
+
}
|
|
160
|
+
}, [selectedAccount, api, showBanner]);
|
|
161
|
+
// ── Keyboard handler ──────────────────────────────────────────────────────
|
|
162
|
+
useInput((input, key) => {
|
|
163
|
+
// ── Text editing mode (w / s) ───────────────────────────────────────
|
|
164
|
+
if (mode === "editSession" || mode === "editWeekly") {
|
|
165
|
+
if (key.escape) {
|
|
166
|
+
setMode("view");
|
|
167
|
+
setEditBuffer("");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (key.return) {
|
|
171
|
+
const parsed = parseInt(editBuffer, 10);
|
|
172
|
+
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 100) {
|
|
173
|
+
const field = mode === "editSession" ? "sessionLimitPercent" : "weeklyLimitPercent";
|
|
174
|
+
void doSetLimit(field, parsed);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
showBanner("Invalid: enter a number 0–100", "red");
|
|
178
|
+
}
|
|
179
|
+
setMode("view");
|
|
180
|
+
setEditBuffer("");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (key.backspace || key.delete) {
|
|
184
|
+
setEditBuffer(b => b.slice(0, -1));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (/^[0-9]$/.test(input) && editBuffer.length < 3) {
|
|
188
|
+
setEditBuffer(b => b + input);
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
85
191
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
192
|
+
// ── Confirm delete (y/n) ────────────────────────────────────────────
|
|
193
|
+
if (mode === "confirmDelete") {
|
|
194
|
+
if (input === "y" || input === "Y") {
|
|
195
|
+
void doDelete();
|
|
196
|
+
setMode("view");
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
setMode("view");
|
|
200
|
+
showBanner("Delete cancelled", "gray");
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// ── Normal view mode ────────────────────────────────────────────────
|
|
205
|
+
if (input === "q") {
|
|
206
|
+
onIntent ? onIntent("quit") : exit();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (key.escape) {
|
|
210
|
+
if (focus === "accounts") {
|
|
211
|
+
setFocus("logs");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
onIntent ? onIntent("quit") : exit();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (key.tab) {
|
|
218
|
+
setFocus(f => f === "logs" ? "accounts" : "logs");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
// Navigation: ↑↓ move within the focused panel
|
|
222
|
+
if (focus === "logs") {
|
|
223
|
+
if (key.upArrow) {
|
|
224
|
+
const next = Math.max(0, selectedLogIndex - 1);
|
|
225
|
+
setSelectedTs(logs[next]?.ts ?? null);
|
|
226
|
+
}
|
|
227
|
+
if (key.downArrow) {
|
|
228
|
+
const next = Math.min(logs.length - 1, selectedLogIndex + 1);
|
|
229
|
+
setSelectedTs(logs[next]?.ts ?? null);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (focus === "accounts") {
|
|
233
|
+
if (key.upArrow) {
|
|
234
|
+
const next = Math.max(0, selectedAccountIndex - 1);
|
|
235
|
+
setSelectedAccountId(data.accounts[next]?.id ?? null);
|
|
236
|
+
}
|
|
237
|
+
if (key.downArrow) {
|
|
238
|
+
const next = Math.min(data.accounts.length - 1, selectedAccountIndex + 1);
|
|
239
|
+
setSelectedAccountId(data.accounts[next]?.id ?? null);
|
|
240
|
+
}
|
|
241
|
+
// Account actions (only when focus = accounts)
|
|
242
|
+
if (input === "e") {
|
|
243
|
+
void doToggleEnabled();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (input === "w") {
|
|
247
|
+
setMode("editWeekly");
|
|
248
|
+
setEditBuffer("");
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (input === "s") {
|
|
252
|
+
setMode("editSession");
|
|
253
|
+
setEditBuffer("");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (input === "d") {
|
|
257
|
+
setMode("confirmDelete");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// n = add account — works regardless of focus
|
|
262
|
+
if (input === "n") {
|
|
263
|
+
if (onIntent) {
|
|
264
|
+
onIntent("addAccount");
|
|
265
|
+
exit();
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
89
268
|
}
|
|
90
269
|
});
|
|
91
|
-
const selectedLog = logs[
|
|
270
|
+
const selectedLog = logs[selectedLogIndex] ?? null;
|
|
92
271
|
const visibleLogs = logs.slice(0, LOG_VISIBLE);
|
|
93
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsxs(Text, { color: "gray", children: [" \u2192 ", data.target, " \u00B7 "] }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsxs(Text, { color: "gray", children: [" \u00B7 updated ", updatedAgo, "s ago \u00B7 [
|
|
272
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsxs(Text, { color: "gray", children: [" \u2192 ", data.target, " \u00B7 "] }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsxs(Text, { color: "gray", children: [" \u00B7 updated ", updatedAgo, "s ago \u00B7 [q] quit"] })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === data.accounts.length ? "green" : "yellow", children: [healthyCount, "/", data.accounts.length, " healthy"] })] }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: focus === "accounts" ? "white" : "gray", children: "[Tab] focus [e] toggle [w] 7d cap [s] 5h cap [n] add [d] delete" })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: data.accounts.map((a, i) => (_jsx(AccountRow, { account: a, selected: focus === "accounts" && i === selectedAccountIndex }, a.id))) })] }), mode === "editWeekly" && selectedAccount && (_jsxs(Box, { marginTop: 1, paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 7d cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "editSession" && selectedAccount && (_jsxs(Box, { marginTop: 1, paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 5h cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "confirmDelete" && selectedAccount && (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: "red", bold: true, children: ["Delete \"", selectedAccount.id, "\"? [y] yes [n/Esc] cancel"] }) })), banner && (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: banner.color, children: [" ", banner.text] }) })), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: " TOTALS " }), _jsx(Text, { children: "requests " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "errors " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "refreshes " }), _jsx(Text, { color: "yellow", children: data.totalRefreshes }), _jsx(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens })] }), _jsx(TokenSummary, { cacheRead: data.totalCacheReadTokens, cacheCreated: data.totalCacheCreationTokens, uncached: data.totalInputTokens, output: data.totalOutputTokens ?? 0 })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: visibleLogs.length === 0
|
|
94
273
|
? _jsx(Text, { color: "gray", children: " No activity yet" })
|
|
95
|
-
: visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: i ===
|
|
274
|
+
: visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: focus === "logs" && i === selectedLogIndex }, `${log.ts}-${i}`))) })] }), focus === "logs" && selectedLog && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] }))] }));
|
|
96
275
|
}
|
|
97
276
|
// ─── Account row (two-line: status + utilization bars) ───────────────────────
|
|
98
|
-
function AccountRow({ account: a }) {
|
|
277
|
+
function AccountRow({ account: a, selected }) {
|
|
99
278
|
const rl = a.rateLimits ?? EMPTY_RL;
|
|
100
279
|
const isLimited = rl.status === "rate_limited";
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
const
|
|
280
|
+
const isDisabled = a.enabled === false;
|
|
281
|
+
const dot = isDisabled ? "⊘" : isLimited ? "⊘" : a.busy ? "◌" : a.healthy ? "●" : "●";
|
|
282
|
+
const dotColor = isDisabled ? "gray" : isLimited ? "red" : a.busy ? "yellow" : a.healthy ? "green" : "red";
|
|
283
|
+
const statusLabel = isDisabled ? "OFF " : isLimited ? "LIMITED" : a.busy ? "busy " : a.healthy ? "ok " : "ERROR ";
|
|
284
|
+
const statusColor = isDisabled ? "gray" : isLimited ? "red" : a.busy ? "yellow" : a.healthy ? "green" : "red";
|
|
105
285
|
const expiryLabel = a.expiresInMs > 0 ? formatMs(a.expiresInMs) : "EXPIRED";
|
|
106
286
|
const expiryColor = a.expiresInMs < 10 * 60 * 1000 ? "red"
|
|
107
287
|
: a.expiresInMs < 30 * 60 * 1000 ? "yellow"
|
|
108
288
|
: "white";
|
|
109
289
|
const planTag = rl.plan ? ` [${rl.plan}]` : "";
|
|
110
|
-
|
|
290
|
+
// User-defined caps hint
|
|
291
|
+
const s5 = a.sessionLimitPercent ?? 100;
|
|
292
|
+
const w7 = a.weeklyLimitPercent ?? 100;
|
|
293
|
+
const hasCaps = s5 < 100 || w7 < 100;
|
|
294
|
+
const capsHint = hasCaps
|
|
295
|
+
? ` cap${s5 < 100 ? ` 5h≤${s5}%` : ""}${w7 < 100 ? ` 7d≤${w7}%` : ""}`
|
|
296
|
+
: "";
|
|
297
|
+
const pointer = selected ? "▶" : " ";
|
|
298
|
+
const nameColor = isDisabled ? "gray" : undefined;
|
|
299
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : undefined, children: pointer }), _jsxs(Text, { color: dotColor, children: [" ", dot, " "] }), _jsx(Text, { color: nameColor, dimColor: isDisabled, children: a.id.slice(0, 20).padEnd(20) }), _jsx(Text, { color: statusColor, children: statusLabel }), planTag && _jsx(Text, { color: "magenta", children: planTag.padEnd(10) }), !planTag && _jsx(Text, { children: "".padEnd(10) }), _jsx(Text, { color: "gray", children: " req " }), _jsx(Text, { color: "white", children: String(a.requestCount).padStart(5) }), _jsx(Text, { color: "gray", children: " err " }), _jsx(Text, { color: a.errorCount > 0 ? "red" : "gray", children: String(a.errorCount).padStart(3) }), _jsx(Text, { color: "gray", children: " tok " }), _jsx(Text, { color: expiryColor, children: expiryLabel.padEnd(8) }), _jsx(Text, { color: "gray", children: " last " }), _jsx(Text, { color: "gray", children: formatAgo(a.lastUsedMs) }), capsHint && _jsx(Text, { color: "yellow", children: capsHint })] }), rl.lastUpdated > 0 && (_jsxs(Box, { paddingLeft: 4, children: [_jsx(UtilBar, { label: "5h", util: rl.fiveHourUtil, resetTs: rl.fiveHourReset, isActive: rl.claim === "five_hour", cap: s5 }), _jsx(Text, { children: " " }), _jsx(UtilBar, { label: "7d", util: rl.sevenDayUtil, resetTs: rl.sevenDayReset, isActive: rl.claim === "seven_day", cap: w7 })] }))] }));
|
|
111
300
|
}
|
|
112
301
|
// ─── Utilization bar ─────────────────────────────────────────────────────────
|
|
113
|
-
function UtilBar({ label, util, resetTs, isActive }) {
|
|
302
|
+
function UtilBar({ label, util, resetTs, isActive, cap }) {
|
|
114
303
|
const pct = Math.round(util * 100);
|
|
115
304
|
const BAR_W = 12;
|
|
116
305
|
const filled = Math.round(util * BAR_W);
|
|
306
|
+
const capPos = Math.round((cap / 100) * BAR_W);
|
|
117
307
|
const bar = "█".repeat(Math.min(filled, BAR_W)) + "░".repeat(Math.max(BAR_W - filled, 0));
|
|
118
|
-
const color = pct >= 90 ? "red" : pct >= 70 ? "yellow" : "green";
|
|
308
|
+
const color = pct >= cap ? "red" : pct >= 90 ? "red" : pct >= 70 ? "yellow" : "green";
|
|
119
309
|
const resetLabel = resetTs > 0 ? formatResetIn(resetTs) : "";
|
|
120
|
-
|
|
310
|
+
const capLabel = cap < 100 ? ` cap ${cap}%` : "";
|
|
311
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: isActive ? "white" : "gray", bold: isActive, children: [label, " "] }), _jsx(Text, { color: color, children: bar }), _jsxs(Text, { color: color, children: [String(pct).padStart(4), "%"] }), capLabel && _jsx(Text, { color: "yellow", children: capLabel }), resetLabel && _jsxs(Text, { color: "gray", children: [" \u21BB", resetLabel] })] }));
|
|
121
312
|
}
|
|
122
313
|
function formatResetIn(unixSeconds) {
|
|
123
314
|
const diff = unixSeconds - Date.now() / 1000;
|
|
@@ -212,7 +403,6 @@ function TokenSummary({ cacheRead, cacheCreated, uncached, output }) {
|
|
|
212
403
|
const totalAll = totalInput + output;
|
|
213
404
|
if (totalAll === 0)
|
|
214
405
|
return null;
|
|
215
|
-
const hitPct = totalInput > 0 ? (cacheRead / totalInput) * 100 : 0;
|
|
216
406
|
return (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "gray", children: "input " }), _jsx(Text, { color: "white", children: fmtTok(totalInput) }), _jsx(Text, { color: "gray", children: " (cached " }), _jsx(Text, { color: "green", children: fmtTok(cacheRead) }), _jsx(Text, { color: "gray", children: " + new " }), _jsx(Text, { color: "yellow", children: fmtTok(cacheCreated) }), _jsx(Text, { color: "gray", children: " + uncached " }), _jsx(Text, { color: "white", children: fmtTok(uncached) }), _jsx(Text, { color: "gray", children: ") \u00B7 output " }), _jsx(Text, { color: "white", children: fmtTok(output) }), _jsx(Text, { color: "gray", children: " \u00B7 total " }), _jsx(Text, { color: "cyan", bold: true, children: fmtTok(totalAll) })] }));
|
|
217
407
|
}
|
|
218
408
|
function fmtTok(n) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny authenticated HTTP client for /cc-router/accounts.
|
|
3
|
+
*
|
|
4
|
+
* Used by the Ink dashboard to mutate account settings (enable/disable,
|
|
5
|
+
* set per-account caps, delete) without exiting the TUI. The `addAccount`
|
|
6
|
+
* flow is NOT in here — that runs inquirer and must exit Ink first; see
|
|
7
|
+
* src/cli/cmd-status.ts `runAddAccountFlow`.
|
|
8
|
+
*/
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 3_000;
|
|
10
|
+
export function createAccountsApi(baseUrl, authToken) {
|
|
11
|
+
const base = baseUrl.replace(/\/+$/, "") + "/cc-router/accounts";
|
|
12
|
+
const authHeaders = authToken
|
|
13
|
+
? { authorization: `Bearer ${authToken}` }
|
|
14
|
+
: {};
|
|
15
|
+
async function send(method, path, body) {
|
|
16
|
+
const res = await fetch(base + path, {
|
|
17
|
+
method,
|
|
18
|
+
headers: {
|
|
19
|
+
...authHeaders,
|
|
20
|
+
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
21
|
+
},
|
|
22
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
23
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
// Try to surface the server's error message if we can read one
|
|
27
|
+
let detail = "";
|
|
28
|
+
try {
|
|
29
|
+
const data = await res.json();
|
|
30
|
+
if (data?.error)
|
|
31
|
+
detail = `: ${data.error}`;
|
|
32
|
+
}
|
|
33
|
+
catch { /* best effort */ }
|
|
34
|
+
throw new Error(`HTTP ${res.status}${detail}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
patch(id, patch) {
|
|
39
|
+
return send("PATCH", `/${encodeURIComponent(id)}`, patch);
|
|
40
|
+
},
|
|
41
|
+
remove(id) {
|
|
42
|
+
return send("DELETE", `/${encodeURIComponent(id)}`);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|