@omnicross/daemon 0.1.0 → 0.1.2
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.cjs +1713 -625
- package/dist/cli.js +1707 -615
- package/dist/index.cjs +1647 -593
- package/dist/index.d.cts +415 -26
- package/dist/index.d.ts +415 -26
- package/dist/index.js +1637 -578
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -157,23 +157,23 @@ function decodeEnvKey(raw) {
|
|
|
157
157
|
}
|
|
158
158
|
return buf;
|
|
159
159
|
}
|
|
160
|
-
function readKeyFile(
|
|
161
|
-
const raw = readFileSync(
|
|
160
|
+
function readKeyFile(path2) {
|
|
161
|
+
const raw = readFileSync(path2);
|
|
162
162
|
if (raw.length === KEY_BYTES2) return raw;
|
|
163
163
|
const text = raw.toString("utf8").trim();
|
|
164
164
|
if (/^[0-9a-fA-F]{64}$/.test(text)) return Buffer.from(text, "hex");
|
|
165
165
|
const b64 = Buffer.from(text, "base64");
|
|
166
166
|
if (b64.length === KEY_BYTES2) return b64;
|
|
167
167
|
throw new Error(
|
|
168
|
-
`master key file '${
|
|
168
|
+
`master key file '${path2}' is invalid: expected 32 raw bytes, 64 hex chars, or 32-byte base64`
|
|
169
169
|
);
|
|
170
170
|
}
|
|
171
|
-
function generateKeyFile(
|
|
171
|
+
function generateKeyFile(path2) {
|
|
172
172
|
const key = randomBytes2(KEY_BYTES2);
|
|
173
|
-
mkdirSync(dirname(
|
|
174
|
-
writeFileSync(
|
|
173
|
+
mkdirSync(dirname(path2), { recursive: true });
|
|
174
|
+
writeFileSync(path2, key, { mode: 384 });
|
|
175
175
|
try {
|
|
176
|
-
chmodSync(
|
|
176
|
+
chmodSync(path2, 384);
|
|
177
177
|
} catch {
|
|
178
178
|
}
|
|
179
179
|
return key;
|
|
@@ -552,25 +552,25 @@ var secretBox = null;
|
|
|
552
552
|
function setSecretBox(box) {
|
|
553
553
|
secretBox = box;
|
|
554
554
|
}
|
|
555
|
-
function loadConfig(
|
|
555
|
+
function loadConfig(path2) {
|
|
556
556
|
let raw;
|
|
557
557
|
try {
|
|
558
|
-
raw = readFileSync2(
|
|
558
|
+
raw = readFileSync2(path2, "utf8");
|
|
559
559
|
} catch {
|
|
560
|
-
throw new Error(`config: cannot read file at '${
|
|
560
|
+
throw new Error(`config: cannot read file at '${path2}'`);
|
|
561
561
|
}
|
|
562
562
|
let parsed;
|
|
563
563
|
try {
|
|
564
564
|
parsed = JSON.parse(raw);
|
|
565
565
|
} catch {
|
|
566
|
-
throw new Error(`config: '${
|
|
566
|
+
throw new Error(`config: '${path2}' is not valid JSON`);
|
|
567
567
|
}
|
|
568
568
|
const validated = validateConfig(parsed);
|
|
569
569
|
return secretBox ? decryptConfigSecrets(validated, secretBox) : validated;
|
|
570
570
|
}
|
|
571
|
-
function saveConfig(
|
|
571
|
+
function saveConfig(path2, cfg) {
|
|
572
572
|
const toWrite = secretBox ? encryptConfigSecrets(cfg, secretBox) : cfg;
|
|
573
|
-
writeFileSync2(
|
|
573
|
+
writeFileSync2(path2, JSON.stringify(toWrite, null, 2) + "\n", "utf8");
|
|
574
574
|
}
|
|
575
575
|
|
|
576
576
|
// src/commands/paths.ts
|
|
@@ -581,6 +581,12 @@ function defaultKeysPath(configPath) {
|
|
|
581
581
|
function defaultTokensPath(configPath) {
|
|
582
582
|
return join2(dirname2(configPath), "tokens.json");
|
|
583
583
|
}
|
|
584
|
+
function defaultPricingPath(configPath) {
|
|
585
|
+
return join2(dirname2(configPath), "pricing.json");
|
|
586
|
+
}
|
|
587
|
+
function defaultUsageEventsPath(configPath) {
|
|
588
|
+
return join2(dirname2(configPath), "usage-events.jsonl");
|
|
589
|
+
}
|
|
584
590
|
function resolveSecretBox(masterKeyFilePath) {
|
|
585
591
|
return new SecretBox(() => resolveMasterKey({ keyFilePath: masterKeyFilePath }));
|
|
586
592
|
}
|
|
@@ -758,15 +764,15 @@ async function keysRevoke(db, id) {
|
|
|
758
764
|
}
|
|
759
765
|
|
|
760
766
|
// src/commands/launch.ts
|
|
761
|
-
import { spawn } from "child_process";
|
|
762
|
-
import { existsSync as
|
|
763
|
-
import { delimiter, join as
|
|
767
|
+
import { spawn as spawn2 } from "child_process";
|
|
768
|
+
import { existsSync as existsSync10 } from "fs";
|
|
769
|
+
import { delimiter as delimiter2, join as join5 } from "path";
|
|
764
770
|
import { parseArgs as parseArgs3 } from "util";
|
|
765
771
|
import {
|
|
766
|
-
buildChatCliLaunchConfig,
|
|
767
|
-
buildClaudeCliLaunchConfig,
|
|
768
|
-
buildCodexLaunchConfig,
|
|
769
|
-
buildGeminiCliLaunchConfig
|
|
772
|
+
buildChatCliLaunchConfig as buildChatCliLaunchConfig2,
|
|
773
|
+
buildClaudeCliLaunchConfig as buildClaudeCliLaunchConfig2,
|
|
774
|
+
buildCodexLaunchConfig as buildCodexLaunchConfig2,
|
|
775
|
+
buildGeminiCliLaunchConfig as buildGeminiCliLaunchConfig2
|
|
770
776
|
} from "@omnicross/cli-launcher";
|
|
771
777
|
|
|
772
778
|
// src/bootstrap.ts
|
|
@@ -782,6 +788,7 @@ import {
|
|
|
782
788
|
__resetProviderProxyForTests,
|
|
783
789
|
getProviderProxy
|
|
784
790
|
} from "@omnicross/core/provider-proxy";
|
|
791
|
+
import { PricingEngine, UsageRecorder } from "@omnicross/core/usage";
|
|
785
792
|
import {
|
|
786
793
|
setSubscriptionAccountService,
|
|
787
794
|
setSubscriptionProviderRegistry,
|
|
@@ -1156,7 +1163,8 @@ async function handleOAuthComplete(providerId, body, deps) {
|
|
|
1156
1163
|
const reason = exchangeError instanceof Error ? exchangeError.message : "token exchange failed";
|
|
1157
1164
|
return err2(502, `oauth token exchange failed for '${providerId}': ${reason}`);
|
|
1158
1165
|
}
|
|
1159
|
-
|
|
1166
|
+
const label = typeof body["label"] === "string" && body["label"].trim() ? body["label"].trim() : void 0;
|
|
1167
|
+
await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block, label);
|
|
1160
1168
|
const status = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
1161
1169
|
return { status: 200, body: status ? { account: status } : { ok: true } };
|
|
1162
1170
|
}
|
|
@@ -1189,8 +1197,207 @@ async function exchangeGemini(code, codeVerifier, exchangeFetch) {
|
|
|
1189
1197
|
};
|
|
1190
1198
|
}
|
|
1191
1199
|
|
|
1192
|
-
// src/
|
|
1200
|
+
// src/admin/cliLaunch.ts
|
|
1201
|
+
import { exec, spawn } from "child_process";
|
|
1193
1202
|
import { randomUUID } from "crypto";
|
|
1203
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1204
|
+
import { delimiter, join as join3 } from "path";
|
|
1205
|
+
import {
|
|
1206
|
+
buildChatCliLaunchConfig,
|
|
1207
|
+
buildClaudeCliLaunchConfig,
|
|
1208
|
+
buildCodexLaunchConfig,
|
|
1209
|
+
buildGeminiCliLaunchConfig
|
|
1210
|
+
} from "@omnicross/cli-launcher";
|
|
1211
|
+
var LAUNCHABLE_CLIS = [
|
|
1212
|
+
{ id: "claude", displayName: "Claude Code", command: "claude" },
|
|
1213
|
+
{ id: "codex", displayName: "Codex CLI", command: "codex" },
|
|
1214
|
+
{ id: "gemini", displayName: "Gemini CLI", command: "gemini" },
|
|
1215
|
+
{ id: "qwen", displayName: "Qwen Code", command: "qwen" },
|
|
1216
|
+
{ id: "copilot", displayName: "GitHub Copilot CLI", command: "copilot" },
|
|
1217
|
+
{ id: "opencode", displayName: "OpenCode", command: "opencode" }
|
|
1218
|
+
];
|
|
1219
|
+
var INSTALL_COMMANDS = {
|
|
1220
|
+
claude: "npm install -g @anthropic-ai/claude-code",
|
|
1221
|
+
codex: "npm install -g @openai/codex",
|
|
1222
|
+
gemini: "npm install -g @google/gemini-cli",
|
|
1223
|
+
qwen: "npm install -g @qwen-code/qwen-code",
|
|
1224
|
+
copilot: "npm install -g @github/copilot",
|
|
1225
|
+
opencode: "npm install -g opencode-ai"
|
|
1226
|
+
};
|
|
1227
|
+
var LAUNCHABLE_IDS = new Set(LAUNCHABLE_CLIS.map((c) => c.id));
|
|
1228
|
+
function isLaunchCliId(id) {
|
|
1229
|
+
return id !== void 0 && LAUNCHABLE_IDS.has(id);
|
|
1230
|
+
}
|
|
1231
|
+
function probeDefault(candidate) {
|
|
1232
|
+
const segments = (process.env["PATH"] ?? "").split(delimiter).filter(Boolean);
|
|
1233
|
+
for (const seg of segments) {
|
|
1234
|
+
const full = join3(seg, candidate);
|
|
1235
|
+
if (existsSync3(full)) return full;
|
|
1236
|
+
}
|
|
1237
|
+
return null;
|
|
1238
|
+
}
|
|
1239
|
+
function isCliInstalled(command, platform = process.platform, probe = probeDefault) {
|
|
1240
|
+
if (platform === "win32") {
|
|
1241
|
+
return Boolean(probe(`${command}.exe`) || probe(`${command}.cmd`) || probe(`${command}.bat`));
|
|
1242
|
+
}
|
|
1243
|
+
return Boolean(probe(command));
|
|
1244
|
+
}
|
|
1245
|
+
function detectClis(platform = process.platform, probe = probeDefault) {
|
|
1246
|
+
return LAUNCHABLE_CLIS.map((c) => ({
|
|
1247
|
+
id: c.id,
|
|
1248
|
+
displayName: c.displayName,
|
|
1249
|
+
command: c.command,
|
|
1250
|
+
installed: isCliInstalled(c.command, platform, probe),
|
|
1251
|
+
installable: Boolean(INSTALL_COMMANDS[c.id])
|
|
1252
|
+
}));
|
|
1253
|
+
}
|
|
1254
|
+
function resolveLaunchTarget(providers, requested) {
|
|
1255
|
+
const pick = (requested?.providerId ? providers.find((p) => p.id === requested.providerId) : void 0) ?? providers.find((p) => p.enabled !== false && firstModel(p)) ?? providers.find((p) => firstModel(p));
|
|
1256
|
+
if (!pick) {
|
|
1257
|
+
throw new Error("no provider with a model is configured \u2014 add one on the Providers page first");
|
|
1258
|
+
}
|
|
1259
|
+
const model = requested?.model || firstModel(pick);
|
|
1260
|
+
if (!model) {
|
|
1261
|
+
throw new Error(`provider "${pick.id}" has no models \u2014 add a model on the Providers page first`);
|
|
1262
|
+
}
|
|
1263
|
+
return { providerId: pick.id, model };
|
|
1264
|
+
}
|
|
1265
|
+
function firstModel(p) {
|
|
1266
|
+
return p.models?.[0] ?? p.modelConfigs?.[0]?.id;
|
|
1267
|
+
}
|
|
1268
|
+
async function buildLaunchEnv(cli, llmConfig, target) {
|
|
1269
|
+
const common = {
|
|
1270
|
+
llmConfig,
|
|
1271
|
+
providerId: target.providerId,
|
|
1272
|
+
model: target.model,
|
|
1273
|
+
sessionId: `dashboard:${cli}`
|
|
1274
|
+
};
|
|
1275
|
+
switch (cli) {
|
|
1276
|
+
case "claude":
|
|
1277
|
+
return buildClaudeCliLaunchConfig(common);
|
|
1278
|
+
case "codex":
|
|
1279
|
+
return buildCodexLaunchConfig(common);
|
|
1280
|
+
case "gemini":
|
|
1281
|
+
return buildGeminiCliLaunchConfig(common);
|
|
1282
|
+
case "qwen":
|
|
1283
|
+
case "copilot":
|
|
1284
|
+
case "opencode":
|
|
1285
|
+
return buildChatCliLaunchConfig({ backendId: cli, ...common });
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
function shq(s) {
|
|
1289
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1290
|
+
}
|
|
1291
|
+
var defaultTerminalOpener = ({ cli, command, extraArgs, env, cwd, platform }) => {
|
|
1292
|
+
const childEnv = { ...process.env, ...env };
|
|
1293
|
+
if (platform === "win32") {
|
|
1294
|
+
const args = ["/c", "start", `"omnicross ${cli}"`];
|
|
1295
|
+
if (cwd) args.push("/D", `"${cwd}"`);
|
|
1296
|
+
args.push("cmd", "/k", command, ...extraArgs);
|
|
1297
|
+
spawn(process.env["ComSpec"] || "cmd.exe", args, {
|
|
1298
|
+
env: childEnv,
|
|
1299
|
+
windowsVerbatimArguments: true,
|
|
1300
|
+
detached: true,
|
|
1301
|
+
stdio: "ignore"
|
|
1302
|
+
}).unref();
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
const exportLine = Object.entries(env).map(([k, v]) => `export ${k}=${shq(v)}`).join("; ");
|
|
1306
|
+
const runLine = [command, ...extraArgs].map(shq).join(" ");
|
|
1307
|
+
const script = `${exportLine}; ${cwd ? `cd ${shq(cwd)}; ` : ""}${runLine}`;
|
|
1308
|
+
if (platform === "darwin") {
|
|
1309
|
+
const osa = `tell application "Terminal" to do script ${JSON.stringify(script)}`;
|
|
1310
|
+
spawn("osascript", ["-e", osa], { detached: true, stdio: "ignore" }).unref();
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
spawn("x-terminal-emulator", ["-e", "bash", "-lc", `${script}; exec bash`], {
|
|
1314
|
+
detached: true,
|
|
1315
|
+
stdio: "ignore"
|
|
1316
|
+
}).unref();
|
|
1317
|
+
};
|
|
1318
|
+
var sessions = /* @__PURE__ */ new Map();
|
|
1319
|
+
function errBody(message) {
|
|
1320
|
+
return { error: { type: "admin_api_error", message } };
|
|
1321
|
+
}
|
|
1322
|
+
var defaultCommandRunner = (command) => new Promise((resolve) => {
|
|
1323
|
+
exec(command, { timeout: 18e4 }, (err5, _stdout, stderr) => {
|
|
1324
|
+
if (err5) resolve({ ok: false, error: stderr.trim() || err5.message });
|
|
1325
|
+
else resolve({ ok: true });
|
|
1326
|
+
});
|
|
1327
|
+
});
|
|
1328
|
+
async function handleCliInstall(cli, runner = defaultCommandRunner) {
|
|
1329
|
+
const cmd = INSTALL_COMMANDS[cli];
|
|
1330
|
+
if (!cmd) {
|
|
1331
|
+
return { status: 400, body: errBody(`no install command for cli '${cli}' (manual install only)`) };
|
|
1332
|
+
}
|
|
1333
|
+
const result = await runner(cmd);
|
|
1334
|
+
if (!result.ok) {
|
|
1335
|
+
return { status: 500, body: errBody(result.error || "install failed") };
|
|
1336
|
+
}
|
|
1337
|
+
return { status: 200, body: { ok: true } };
|
|
1338
|
+
}
|
|
1339
|
+
function handleCliList(platform = process.platform, probe = probeDefault) {
|
|
1340
|
+
return { status: 200, body: { clis: detectClis(platform, probe) } };
|
|
1341
|
+
}
|
|
1342
|
+
function handleCliSessions() {
|
|
1343
|
+
const list = [...sessions.values()].map(({ onSessionEnd: _drop, ...rest }) => rest);
|
|
1344
|
+
return { status: 200, body: { sessions: list } };
|
|
1345
|
+
}
|
|
1346
|
+
function handleCliStop(id) {
|
|
1347
|
+
const s = sessions.get(id);
|
|
1348
|
+
if (!s) return { status: 404, body: errBody(`session '${id}' not found`) };
|
|
1349
|
+
try {
|
|
1350
|
+
s.onSessionEnd();
|
|
1351
|
+
} catch {
|
|
1352
|
+
}
|
|
1353
|
+
sessions.delete(id);
|
|
1354
|
+
return { status: 200, body: { ok: true } };
|
|
1355
|
+
}
|
|
1356
|
+
async function handleCliLaunch(cli, body, ctx) {
|
|
1357
|
+
const platform = ctx.platform ?? process.platform;
|
|
1358
|
+
const probe = ctx.probe ?? probeDefault;
|
|
1359
|
+
const meta = LAUNCHABLE_CLIS.find((c) => c.id === cli);
|
|
1360
|
+
if (!meta) return { status: 404, body: errBody(`unknown cli '${cli}'`) };
|
|
1361
|
+
if (!isCliInstalled(meta.command, platform, probe)) {
|
|
1362
|
+
return { status: 400, body: errBody(`"${meta.command}" is not installed (not found on PATH)`) };
|
|
1363
|
+
}
|
|
1364
|
+
let target;
|
|
1365
|
+
try {
|
|
1366
|
+
target = resolveLaunchTarget(ctx.providers, {
|
|
1367
|
+
providerId: typeof body["providerId"] === "string" ? body["providerId"] : void 0,
|
|
1368
|
+
model: typeof body["model"] === "string" ? body["model"] : void 0
|
|
1369
|
+
});
|
|
1370
|
+
} catch (err5) {
|
|
1371
|
+
return { status: 400, body: errBody(err5 instanceof Error ? err5.message : "no launch target") };
|
|
1372
|
+
}
|
|
1373
|
+
let launch;
|
|
1374
|
+
try {
|
|
1375
|
+
launch = await buildLaunchEnv(cli, ctx.llmConfig, target);
|
|
1376
|
+
} catch (err5) {
|
|
1377
|
+
return { status: 400, body: errBody(err5 instanceof Error ? err5.message : "failed to build launch env") };
|
|
1378
|
+
}
|
|
1379
|
+
const cwd = typeof body["cwd"] === "string" && body["cwd"].trim() ? body["cwd"].trim() : void 0;
|
|
1380
|
+
const opener = ctx.opener ?? defaultTerminalOpener;
|
|
1381
|
+
try {
|
|
1382
|
+
opener({ cli, command: meta.command, extraArgs: launch.extraArgs ?? [], env: launch.env, cwd, platform });
|
|
1383
|
+
} catch (err5) {
|
|
1384
|
+
launch.onSessionEnd();
|
|
1385
|
+
return { status: 500, body: errBody(err5 instanceof Error ? err5.message : "failed to open terminal") };
|
|
1386
|
+
}
|
|
1387
|
+
const id = randomUUID();
|
|
1388
|
+
sessions.set(id, {
|
|
1389
|
+
id,
|
|
1390
|
+
cli,
|
|
1391
|
+
providerId: target.providerId,
|
|
1392
|
+
model: target.model,
|
|
1393
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1394
|
+
onSessionEnd: launch.onSessionEnd
|
|
1395
|
+
});
|
|
1396
|
+
return { status: 200, body: { sessionId: id, providerId: target.providerId, model: target.model } };
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// src/ports/account-multi.ts
|
|
1400
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1194
1401
|
var PROVIDER_KEYS = {
|
|
1195
1402
|
claude: { block: "claude", accounts: "claudeAccounts", active: "activeClaudeAccountId" },
|
|
1196
1403
|
codex: { block: "codex", accounts: "codexAccounts", active: "activeCodexAccountId" },
|
|
@@ -1256,7 +1463,7 @@ function migrateLazily(config) {
|
|
|
1256
1463
|
}
|
|
1257
1464
|
function addAccount(config, p, tokens, label) {
|
|
1258
1465
|
const accounts = [...getAccounts(config, p)];
|
|
1259
|
-
const id =
|
|
1466
|
+
const id = randomUUID2();
|
|
1260
1467
|
accounts.push({
|
|
1261
1468
|
id,
|
|
1262
1469
|
label: label ?? `Account ${accounts.length + 1}`,
|
|
@@ -1293,6 +1500,13 @@ function setActiveAccount(config, p, id) {
|
|
|
1293
1500
|
deriveMirror(config, p);
|
|
1294
1501
|
return { ok: true };
|
|
1295
1502
|
}
|
|
1503
|
+
function listAccounts(config, p) {
|
|
1504
|
+
return getAccounts(config, p);
|
|
1505
|
+
}
|
|
1506
|
+
function getAccountById(config, p, id) {
|
|
1507
|
+
const account = getAccounts(config, p).find((a) => a.id === id);
|
|
1508
|
+
return account ? { id: account.id, tokens: account.tokens } : void 0;
|
|
1509
|
+
}
|
|
1296
1510
|
function getActiveAccount(config, p) {
|
|
1297
1511
|
const active = getAccounts(config, p).find((a) => a.id === getActiveId(config, p));
|
|
1298
1512
|
return active ? { id: active.id, tokens: active.tokens } : void 0;
|
|
@@ -1326,12 +1540,27 @@ function sanitizeAccounts(config, p) {
|
|
|
1326
1540
|
id: a.id,
|
|
1327
1541
|
label: a.label,
|
|
1328
1542
|
status: t.status ?? "unconfigured",
|
|
1543
|
+
authMethod: t.authMethod,
|
|
1544
|
+
subscriptionLevel: t.subscriptionLevel,
|
|
1329
1545
|
expiresAt: t.expiresAt,
|
|
1546
|
+
lastRefreshedAt: t.lastRefreshedAt,
|
|
1547
|
+
isSetupToken: t.isSetupToken,
|
|
1330
1548
|
hasAccessToken: !!(t.accessToken || t.apiKey),
|
|
1331
|
-
isActive: a.id === activeId
|
|
1549
|
+
isActive: a.id === activeId,
|
|
1550
|
+
syncWarning: t.syncWarning
|
|
1332
1551
|
};
|
|
1333
1552
|
});
|
|
1334
1553
|
}
|
|
1554
|
+
function renameAccount(config, p, id, label) {
|
|
1555
|
+
const accounts = getAccounts(config, p);
|
|
1556
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
1557
|
+
setAccounts(
|
|
1558
|
+
config,
|
|
1559
|
+
p,
|
|
1560
|
+
accounts.map((a) => a.id === id ? { ...a, label } : a)
|
|
1561
|
+
);
|
|
1562
|
+
return { ok: true };
|
|
1563
|
+
}
|
|
1335
1564
|
function clearProvider(config, p) {
|
|
1336
1565
|
setBlock(config, p, void 0);
|
|
1337
1566
|
setAccounts(config, p, void 0);
|
|
@@ -1578,6 +1807,169 @@ async function handleImport(body, deps) {
|
|
|
1578
1807
|
}
|
|
1579
1808
|
}
|
|
1580
1809
|
|
|
1810
|
+
// src/admin/usagePricing.ts
|
|
1811
|
+
var err4 = (status, message) => ({
|
|
1812
|
+
status,
|
|
1813
|
+
body: { error: { type: "admin_api_error", message } }
|
|
1814
|
+
});
|
|
1815
|
+
function parseFiniteInt(raw) {
|
|
1816
|
+
if (raw === null || raw.trim() === "") return null;
|
|
1817
|
+
const n = Number(raw);
|
|
1818
|
+
return Number.isFinite(n) && Number.isInteger(n) ? n : null;
|
|
1819
|
+
}
|
|
1820
|
+
function parseRange(query) {
|
|
1821
|
+
const startTs = parseFiniteInt(query.get("startTs"));
|
|
1822
|
+
const endTs = parseFiniteInt(query.get("endTs"));
|
|
1823
|
+
if (startTs === null || endTs === null) {
|
|
1824
|
+
return err4(400, "startTs and endTs are required finite-integer unix-millis query params");
|
|
1825
|
+
}
|
|
1826
|
+
return { startTs, endTs };
|
|
1827
|
+
}
|
|
1828
|
+
var isRange = (v) => v.startTs !== void 0 && !("status" in v);
|
|
1829
|
+
async function handleUsageGet(view, query, deps) {
|
|
1830
|
+
const range = parseRange(query);
|
|
1831
|
+
if (!isRange(range)) return range;
|
|
1832
|
+
switch (view) {
|
|
1833
|
+
case "totals":
|
|
1834
|
+
return { status: 200, body: await deps.usageRecorder.getTotals(range) };
|
|
1835
|
+
case "by-model":
|
|
1836
|
+
return { status: 200, body: await deps.usageRecorder.getByModel(range) };
|
|
1837
|
+
case "by-api-key": {
|
|
1838
|
+
const rows = await deps.usageRecorder.getByApiKey(range);
|
|
1839
|
+
const labels = poolKeyLabels(loadConfig(deps.configPath));
|
|
1840
|
+
return {
|
|
1841
|
+
status: 200,
|
|
1842
|
+
body: rows.map((r) => {
|
|
1843
|
+
if (r.apiKeyId === null) {
|
|
1844
|
+
return { ...r, label: "unattributed", providerId: null };
|
|
1845
|
+
}
|
|
1846
|
+
const known = labels.get(r.apiKeyId);
|
|
1847
|
+
return known ? { ...r, label: known.label, providerId: known.providerId } : { ...r, label: r.apiKeyId };
|
|
1848
|
+
})
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
default:
|
|
1852
|
+
return err4(404, `unknown usage view '${view ?? ""}'`);
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
function poolKeyLabels(cfg) {
|
|
1856
|
+
const out = /* @__PURE__ */ new Map();
|
|
1857
|
+
for (const provider of cfg.providers) {
|
|
1858
|
+
for (const key of provider.apiKeys ?? []) {
|
|
1859
|
+
out.set(key.id, {
|
|
1860
|
+
label: key.label && key.label.length > 0 ? key.label : key.id,
|
|
1861
|
+
providerId: provider.id
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
return out;
|
|
1866
|
+
}
|
|
1867
|
+
var INVALID_PRICE = /* @__PURE__ */ Symbol("invalid-price");
|
|
1868
|
+
function parseOptionalPrice(b, key) {
|
|
1869
|
+
if (!(key in b) || b[key] === null || b[key] === void 0) return null;
|
|
1870
|
+
const v = b[key];
|
|
1871
|
+
return typeof v === "number" && Number.isFinite(v) ? v : INVALID_PRICE;
|
|
1872
|
+
}
|
|
1873
|
+
function parsePricingEntryInput(raw) {
|
|
1874
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
1875
|
+
const b = raw;
|
|
1876
|
+
const providerId = typeof b["providerId"] === "string" && b["providerId"].trim() ? b["providerId"].trim() : "";
|
|
1877
|
+
const modelId = typeof b["modelId"] === "string" && b["modelId"].trim() ? b["modelId"].trim() : "";
|
|
1878
|
+
const inputPrice = b["inputPricePer1m"];
|
|
1879
|
+
const outputPrice = b["outputPricePer1m"];
|
|
1880
|
+
if (!providerId || !modelId) return null;
|
|
1881
|
+
if (typeof inputPrice !== "number" || !Number.isFinite(inputPrice)) return null;
|
|
1882
|
+
if (typeof outputPrice !== "number" || !Number.isFinite(outputPrice)) return null;
|
|
1883
|
+
const cacheRead = parseOptionalPrice(b, "cacheReadPricePer1m");
|
|
1884
|
+
const cacheWrite = parseOptionalPrice(b, "cacheWritePricePer1m");
|
|
1885
|
+
if (cacheRead === INVALID_PRICE || cacheWrite === INVALID_PRICE) return null;
|
|
1886
|
+
return {
|
|
1887
|
+
providerId,
|
|
1888
|
+
modelId,
|
|
1889
|
+
inputPricePer1m: inputPrice,
|
|
1890
|
+
outputPricePer1m: outputPrice,
|
|
1891
|
+
cacheReadPricePer1m: cacheRead,
|
|
1892
|
+
cacheWritePricePer1m: cacheWrite
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
async function handlePricingList(deps) {
|
|
1896
|
+
return { status: 200, body: { entries: await deps.pricingEngine.getAll() } };
|
|
1897
|
+
}
|
|
1898
|
+
async function handlePricingUpsert(body, deps) {
|
|
1899
|
+
const input = parsePricingEntryInput(body);
|
|
1900
|
+
if (!input) {
|
|
1901
|
+
return err4(400, "invalid pricing entry (providerId, modelId, finite numeric inputPricePer1m/outputPricePer1m required)");
|
|
1902
|
+
}
|
|
1903
|
+
const entry = await deps.pricingEngine.upsertManual(input);
|
|
1904
|
+
return { status: 200, body: { entry } };
|
|
1905
|
+
}
|
|
1906
|
+
async function handlePricingDelete(query, deps) {
|
|
1907
|
+
const providerId = query.get("providerId")?.trim() ?? "";
|
|
1908
|
+
const modelId = query.get("modelId")?.trim() ?? "";
|
|
1909
|
+
if (!providerId || !modelId) {
|
|
1910
|
+
return err4(400, "delete requires providerId and modelId query params");
|
|
1911
|
+
}
|
|
1912
|
+
const deleted = await deps.pricingStore.delete(providerId, modelId);
|
|
1913
|
+
if (deleted) await deps.pricingEngine.invalidateCache();
|
|
1914
|
+
return { status: 200, body: { deleted } };
|
|
1915
|
+
}
|
|
1916
|
+
async function handlePricingFetchLatest(deps) {
|
|
1917
|
+
try {
|
|
1918
|
+
const result = await deps.pricingEngine.fetchLatestFromSource();
|
|
1919
|
+
return {
|
|
1920
|
+
status: 200,
|
|
1921
|
+
body: {
|
|
1922
|
+
appliedCount: result.applied.length,
|
|
1923
|
+
conflicts: result.conflicts,
|
|
1924
|
+
fetchedAt: result.fetchedAt,
|
|
1925
|
+
sourceUrl: result.sourceUrl
|
|
1926
|
+
}
|
|
1927
|
+
};
|
|
1928
|
+
} catch (e) {
|
|
1929
|
+
return err4(502, `pricing-source fetch failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
async function handlePricingResolveConflicts(body, deps) {
|
|
1933
|
+
const raw = body["resolutions"];
|
|
1934
|
+
if (!Array.isArray(raw)) {
|
|
1935
|
+
return err4(400, "resolve-conflicts requires { resolutions: [...] }");
|
|
1936
|
+
}
|
|
1937
|
+
const currentRows = await deps.pricingStore.getAll();
|
|
1938
|
+
const userEditedKeys = new Set(
|
|
1939
|
+
currentRows.filter((r) => r.userEdited).map((r) => `${r.providerId}::${r.modelId}`)
|
|
1940
|
+
);
|
|
1941
|
+
const decisions = [];
|
|
1942
|
+
const pendingIncoming = /* @__PURE__ */ new Map();
|
|
1943
|
+
let staleCount = 0;
|
|
1944
|
+
for (const item of raw) {
|
|
1945
|
+
if (!item || typeof item !== "object") return err4(400, "invalid resolution entry");
|
|
1946
|
+
const r = item;
|
|
1947
|
+
const action = r["action"];
|
|
1948
|
+
if (action !== "overwrite" && action !== "skip") {
|
|
1949
|
+
return err4(400, "resolution action must be 'overwrite' or 'skip'");
|
|
1950
|
+
}
|
|
1951
|
+
const providerId = typeof r["providerId"] === "string" && r["providerId"].trim() ? r["providerId"].trim() : "";
|
|
1952
|
+
const modelId = typeof r["modelId"] === "string" && r["modelId"].trim() ? r["modelId"].trim() : "";
|
|
1953
|
+
if (!providerId || !modelId) {
|
|
1954
|
+
return err4(400, "each resolution requires top-level providerId and modelId");
|
|
1955
|
+
}
|
|
1956
|
+
const incoming = parsePricingEntryInput(r["incoming"]);
|
|
1957
|
+
if (!incoming) return err4(400, "each resolution must echo a valid incoming pricing entry");
|
|
1958
|
+
if (incoming.providerId !== providerId || incoming.modelId !== modelId) {
|
|
1959
|
+
return err4(400, "resolution providerId/modelId must match the echoed incoming entry");
|
|
1960
|
+
}
|
|
1961
|
+
const key = `${providerId}::${modelId}`;
|
|
1962
|
+
if (action === "overwrite" && !userEditedKeys.has(key)) {
|
|
1963
|
+
staleCount += 1;
|
|
1964
|
+
continue;
|
|
1965
|
+
}
|
|
1966
|
+
decisions.push({ providerId, modelId, action });
|
|
1967
|
+
pendingIncoming.set(key, incoming);
|
|
1968
|
+
}
|
|
1969
|
+
const resolution = await deps.pricingEngine.resolveConflicts(decisions, pendingIncoming);
|
|
1970
|
+
return { status: 200, body: { ...resolution, staleCount } };
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1581
1973
|
// src/admin/adminApi.ts
|
|
1582
1974
|
function readBody(req) {
|
|
1583
1975
|
return new Promise((resolve, reject) => {
|
|
@@ -1668,9 +2060,9 @@ function toProviderView(row) {
|
|
|
1668
2060
|
selectedApiModeId: row.selectedApiModeId
|
|
1669
2061
|
};
|
|
1670
2062
|
}
|
|
1671
|
-
async function handleAdminApi(req, res,
|
|
2063
|
+
async function handleAdminApi(req, res, path2, deps) {
|
|
1672
2064
|
const method = (req.method ?? "GET").toUpperCase();
|
|
1673
|
-
const sub =
|
|
2065
|
+
const sub = path2.slice("/admin/api/".length);
|
|
1674
2066
|
const [resource, ...rest] = sub.split("/").filter((s) => s.length > 0);
|
|
1675
2067
|
try {
|
|
1676
2068
|
switch (resource) {
|
|
@@ -1684,6 +2076,8 @@ async function handleAdminApi(req, res, path, deps) {
|
|
|
1684
2076
|
return await handleServer(req, res, method, deps);
|
|
1685
2077
|
case "accounts":
|
|
1686
2078
|
return await handleAccounts(req, res, method, rest, deps);
|
|
2079
|
+
case "cli":
|
|
2080
|
+
return await handleCli(req, res, method, rest, deps);
|
|
1687
2081
|
case "status":
|
|
1688
2082
|
return await handleStatus(res, method, deps);
|
|
1689
2083
|
case "playground":
|
|
@@ -1692,12 +2086,47 @@ async function handleAdminApi(req, res, path, deps) {
|
|
|
1692
2086
|
return await handleMigrationExport(req, res, method, deps);
|
|
1693
2087
|
case "import":
|
|
1694
2088
|
return await handleMigrationImport(req, res, method, deps);
|
|
2089
|
+
case "usage":
|
|
2090
|
+
return await handleUsage(req, res, method, rest, deps);
|
|
2091
|
+
case "pricing":
|
|
2092
|
+
return await handlePricing(req, res, method, rest, deps);
|
|
1695
2093
|
default:
|
|
1696
2094
|
return writeJsonError(res, 404, `unknown admin resource '${resource}'`);
|
|
1697
2095
|
}
|
|
1698
|
-
} catch (
|
|
1699
|
-
writeJsonError(res, 500,
|
|
2096
|
+
} catch (err5) {
|
|
2097
|
+
writeJsonError(res, 500, err5 instanceof Error ? err5.message : String(err5));
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
function requestQuery(req) {
|
|
2101
|
+
const raw = req.url ?? "";
|
|
2102
|
+
const qIdx = raw.indexOf("?");
|
|
2103
|
+
return new URLSearchParams(qIdx >= 0 ? raw.slice(qIdx + 1) : "");
|
|
2104
|
+
}
|
|
2105
|
+
function writeResult(res, result) {
|
|
2106
|
+
writeJson(res, result.status, result.body);
|
|
2107
|
+
}
|
|
2108
|
+
async function handleUsage(req, res, method, rest, deps) {
|
|
2109
|
+
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on usage`);
|
|
2110
|
+
return writeResult(res, await handleUsageGet(rest[0], requestQuery(req), deps));
|
|
2111
|
+
}
|
|
2112
|
+
async function handlePricing(req, res, method, rest, deps) {
|
|
2113
|
+
if (rest.length === 0) {
|
|
2114
|
+
if (method === "GET") return writeResult(res, await handlePricingList(deps));
|
|
2115
|
+
if (method === "PUT") {
|
|
2116
|
+
return writeResult(res, await handlePricingUpsert(await readJsonBody(req), deps));
|
|
2117
|
+
}
|
|
2118
|
+
if (method === "DELETE") {
|
|
2119
|
+
return writeResult(res, await handlePricingDelete(requestQuery(req), deps));
|
|
2120
|
+
}
|
|
2121
|
+
return writeJsonError(res, 405, `method ${method} not allowed on pricing`);
|
|
2122
|
+
}
|
|
2123
|
+
if (method === "POST" && rest.length === 1 && rest[0] === "fetch-latest") {
|
|
2124
|
+
return writeResult(res, await handlePricingFetchLatest(deps));
|
|
1700
2125
|
}
|
|
2126
|
+
if (method === "POST" && rest.length === 1 && rest[0] === "resolve-conflicts") {
|
|
2127
|
+
return writeResult(res, await handlePricingResolveConflicts(await readJsonBody(req), deps));
|
|
2128
|
+
}
|
|
2129
|
+
return writeJsonError(res, 404, `unknown pricing route '${rest.join("/")}'`);
|
|
1701
2130
|
}
|
|
1702
2131
|
function migrationDeps(deps) {
|
|
1703
2132
|
return {
|
|
@@ -1745,6 +2174,11 @@ async function handleProviders(req, res, method, rest, deps) {
|
|
|
1745
2174
|
if (method === "POST" && rest.length === 2 && rest[1] === "test") {
|
|
1746
2175
|
return await handleTestModel(req, res, rest[0], cfg);
|
|
1747
2176
|
}
|
|
2177
|
+
if (method === "GET" && rest.length === 2 && rest[1] === "reveal-key") {
|
|
2178
|
+
const row = cfg.providers.find((p) => p.id === rest[0]);
|
|
2179
|
+
if (!row) return writeJsonError(res, 404, `provider '${rest[0]}' not found`);
|
|
2180
|
+
return writeJson(res, 200, { apiKey: row.apiKey ?? "" });
|
|
2181
|
+
}
|
|
1748
2182
|
if (method === "GET") {
|
|
1749
2183
|
return writeJson(res, 200, { providers: cfg.providers.map(toProviderView) });
|
|
1750
2184
|
}
|
|
@@ -1840,8 +2274,8 @@ async function handleDiscoverModels(res, id, cfg) {
|
|
|
1840
2274
|
const data = await response.json();
|
|
1841
2275
|
const models = Array.isArray(data?.data) ? data.data.map((m) => typeof m?.id === "string" ? m.id : "").filter((m) => m.length > 0) : [];
|
|
1842
2276
|
return writeJson(res, 200, { models });
|
|
1843
|
-
} catch (
|
|
1844
|
-
const message =
|
|
2277
|
+
} catch (err5) {
|
|
2278
|
+
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
1845
2279
|
return writeJson(res, 200, { models: [], error: `discovery failed: ${message}` });
|
|
1846
2280
|
}
|
|
1847
2281
|
}
|
|
@@ -1900,8 +2334,8 @@ async function handleTestModel(req, res, id, cfg) {
|
|
|
1900
2334
|
latencyMs,
|
|
1901
2335
|
sample: extractSampleText(text, row.apiFormat)
|
|
1902
2336
|
});
|
|
1903
|
-
} catch (
|
|
1904
|
-
const message =
|
|
2337
|
+
} catch (err5) {
|
|
2338
|
+
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
1905
2339
|
return writeJson(res, 200, { ok: false, latencyMs: Date.now() - startedAt, message });
|
|
1906
2340
|
}
|
|
1907
2341
|
}
|
|
@@ -2250,7 +2684,8 @@ async function handleAccounts(req, res, method, rest, deps) {
|
|
|
2250
2684
|
if (method === "GET" && rest.length === 0) {
|
|
2251
2685
|
const accounts = await deps.subscriptionAccounts.listAll();
|
|
2252
2686
|
const providerAccounts = await deps.subscriptionTokenWriter.listSanitizedAccounts();
|
|
2253
|
-
|
|
2687
|
+
const externalCli = await deps.subscriptionTokenWriter.listExternalCliAvailability();
|
|
2688
|
+
return writeJson(res, 200, { accounts, providerAccounts, externalCli });
|
|
2254
2689
|
}
|
|
2255
2690
|
if (method === "GET" && rest[0] === "codex" && rest[1] === "oauth" && rest[3] === "status") {
|
|
2256
2691
|
const result = handleCodexOAuthStatus(rest[2], deps);
|
|
@@ -2270,6 +2705,47 @@ async function handleAccounts(req, res, method, rest, deps) {
|
|
|
2270
2705
|
const result = await handleOAuthComplete(providerId, body2, deps);
|
|
2271
2706
|
return writeJson(res, result.status, result.body);
|
|
2272
2707
|
}
|
|
2708
|
+
if (method === "POST" && rest[1] === "accounts") {
|
|
2709
|
+
const body2 = await readJsonBody(req);
|
|
2710
|
+
const block = validateTokenBody(providerId, body2);
|
|
2711
|
+
if (!block) {
|
|
2712
|
+
return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
|
|
2713
|
+
}
|
|
2714
|
+
const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
|
|
2715
|
+
await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block, label);
|
|
2716
|
+
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2717
|
+
return writeJson(res, 200, status2 ? { account: status2 } : { ok: true });
|
|
2718
|
+
}
|
|
2719
|
+
if (method === "POST" && rest[1] === "import-external") {
|
|
2720
|
+
if (providerId !== "claude" && providerId !== "codex") {
|
|
2721
|
+
return writeJsonError(res, 400, `provider '${providerId}' has no external CLI store`);
|
|
2722
|
+
}
|
|
2723
|
+
const body2 = await readJsonBody(req);
|
|
2724
|
+
const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
|
|
2725
|
+
const result = await deps.subscriptionTokenWriter.importExternalCliAccount(providerId, label);
|
|
2726
|
+
if (!result.ok) {
|
|
2727
|
+
return writeJsonError(res, 409, `no usable external ${providerId} CLI credential found`);
|
|
2728
|
+
}
|
|
2729
|
+
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2730
|
+
return writeJson(res, 200, { ok: true, account: status2 ?? void 0 });
|
|
2731
|
+
}
|
|
2732
|
+
if (method === "POST" && rest[1] === "refresh") {
|
|
2733
|
+
if (providerId === "opencodego") {
|
|
2734
|
+
return writeJsonError(res, 400, "opencodego credentials are not refreshable");
|
|
2735
|
+
}
|
|
2736
|
+
const writer = deps.subscriptionTokenWriter;
|
|
2737
|
+
const ok = providerId === "claude" ? await writer.refreshClaudeToken() : providerId === "codex" ? await writer.refreshCodexToken() : await writer.refreshGeminiToken();
|
|
2738
|
+
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2739
|
+
return writeJson(res, 200, { ok, account: status2 ?? void 0 });
|
|
2740
|
+
}
|
|
2741
|
+
if (method === "POST" && rest[2] === "label") {
|
|
2742
|
+
const accountId = rest[1];
|
|
2743
|
+
const body2 = await readJsonBody(req);
|
|
2744
|
+
const label = typeof body2["label"] === "string" ? body2["label"].trim() : "";
|
|
2745
|
+
const result = await deps.subscriptionTokenWriter.renameAccount(providerId, accountId, label);
|
|
2746
|
+
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
2747
|
+
return writeJson(res, 200, { ok: true });
|
|
2748
|
+
}
|
|
2273
2749
|
if (method === "PUT" && rest[1] === "active") {
|
|
2274
2750
|
const body2 = await readJsonBody(req);
|
|
2275
2751
|
const id = typeof body2["id"] === "string" ? body2["id"] : "";
|
|
@@ -2299,6 +2775,44 @@ async function handleAccounts(req, res, method, rest, deps) {
|
|
|
2299
2775
|
}
|
|
2300
2776
|
return writeJsonError(res, 405, `method ${method} not allowed on accounts`);
|
|
2301
2777
|
}
|
|
2778
|
+
async function handleCli(req, res, method, rest, deps) {
|
|
2779
|
+
if (method === "GET" && rest.length === 0) {
|
|
2780
|
+
const result = handleCliList(process.platform, deps.cliPathProbe);
|
|
2781
|
+
return writeJson(res, result.status, result.body);
|
|
2782
|
+
}
|
|
2783
|
+
if (method === "GET" && rest[0] === "sessions") {
|
|
2784
|
+
const result = handleCliSessions();
|
|
2785
|
+
return writeJson(res, result.status, result.body);
|
|
2786
|
+
}
|
|
2787
|
+
if (method === "DELETE" && rest[0] === "sessions" && rest[1]) {
|
|
2788
|
+
const result = handleCliStop(rest[1]);
|
|
2789
|
+
return writeJson(res, result.status, result.body);
|
|
2790
|
+
}
|
|
2791
|
+
if (method === "POST" && rest[1] === "install") {
|
|
2792
|
+
const cli = rest[0];
|
|
2793
|
+
if (!isLaunchCliId(cli)) {
|
|
2794
|
+
return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
|
|
2795
|
+
}
|
|
2796
|
+
const result = await handleCliInstall(cli, deps.cliCommandRunner);
|
|
2797
|
+
return writeJson(res, result.status, result.body);
|
|
2798
|
+
}
|
|
2799
|
+
if (method === "POST" && rest[1] === "launch") {
|
|
2800
|
+
const cli = rest[0];
|
|
2801
|
+
if (!isLaunchCliId(cli)) {
|
|
2802
|
+
return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
|
|
2803
|
+
}
|
|
2804
|
+
const body = await readJsonBody(req);
|
|
2805
|
+
const providers = loadConfig(deps.configPath).providers ?? [];
|
|
2806
|
+
const result = await handleCliLaunch(cli, body, {
|
|
2807
|
+
llmConfig: deps.llmConfig,
|
|
2808
|
+
providers,
|
|
2809
|
+
opener: deps.cliTerminalOpener,
|
|
2810
|
+
probe: deps.cliPathProbe
|
|
2811
|
+
});
|
|
2812
|
+
return writeJson(res, result.status, result.body);
|
|
2813
|
+
}
|
|
2814
|
+
return writeJsonError(res, 405, `method ${method} not allowed on cli`);
|
|
2815
|
+
}
|
|
2302
2816
|
async function handleStatus(res, method, deps) {
|
|
2303
2817
|
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on status`);
|
|
2304
2818
|
const status = deps.outboundApiServer.getStatus();
|
|
@@ -2334,21 +2848,21 @@ async function handlePlayground(req, res, method, deps) {
|
|
|
2334
2848
|
const payload = body["body"];
|
|
2335
2849
|
const status = deps.outboundApiServer.getStatus();
|
|
2336
2850
|
if (!status.running || !status.port) return writeJsonError(res, 503, "outbound server not running");
|
|
2337
|
-
const
|
|
2338
|
-
if (!
|
|
2851
|
+
const path2 = resolvePlaygroundPath(endpoint, isRecord(payload) ? payload : {});
|
|
2852
|
+
if (!path2) return writeJsonError(res, 400, `unknown endpoint '${endpoint}'`);
|
|
2339
2853
|
const upstreamBody = typeof payload === "string" ? payload : JSON.stringify(payload ?? {});
|
|
2340
|
-
await proxyToOutbound(res, status.port,
|
|
2854
|
+
await proxyToOutbound(res, status.port, path2, key, upstreamBody);
|
|
2341
2855
|
}
|
|
2342
2856
|
function isRecord(v) {
|
|
2343
2857
|
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
2344
2858
|
}
|
|
2345
|
-
function proxyToOutbound(res, outboundPort,
|
|
2859
|
+
function proxyToOutbound(res, outboundPort, path2, key, body) {
|
|
2346
2860
|
return new Promise((resolve) => {
|
|
2347
2861
|
const upstream = http.request(
|
|
2348
2862
|
{
|
|
2349
2863
|
host: "127.0.0.1",
|
|
2350
2864
|
port: outboundPort,
|
|
2351
|
-
path,
|
|
2865
|
+
path: path2,
|
|
2352
2866
|
method: "POST",
|
|
2353
2867
|
headers: {
|
|
2354
2868
|
"Content-Type": "application/json",
|
|
@@ -2368,8 +2882,8 @@ function proxyToOutbound(res, outboundPort, path, key, body) {
|
|
|
2368
2882
|
});
|
|
2369
2883
|
}
|
|
2370
2884
|
);
|
|
2371
|
-
upstream.on("error", (
|
|
2372
|
-
if (!res.headersSent) writeJsonError(res, 502, `playground proxy failed: ${
|
|
2885
|
+
upstream.on("error", (err5) => {
|
|
2886
|
+
if (!res.headersSent) writeJsonError(res, 502, `playground proxy failed: ${err5.message}`);
|
|
2373
2887
|
else res.end();
|
|
2374
2888
|
resolve();
|
|
2375
2889
|
});
|
|
@@ -2378,463 +2892,105 @@ function proxyToOutbound(res, outboundPort, path, key, body) {
|
|
|
2378
2892
|
});
|
|
2379
2893
|
}
|
|
2380
2894
|
|
|
2381
|
-
// src/admin/
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); }
|
|
2408
|
-
function td(text) { var c = document.createElement('td'); c.textContent = text == null ? '' : String(text); return c; }
|
|
2409
|
-
function btn(label, cls, fn) { var b = document.createElement('button'); b.textContent = label; if (cls) b.className = cls; b.onclick = fn; return b; }
|
|
2410
|
-
|
|
2411
|
-
// ── Status ────────────────────────────────────────────────────────────────
|
|
2412
|
-
async function loadStatus() {
|
|
2413
|
-
var r = await api('GET', 'status');
|
|
2414
|
-
var s = r.json || {};
|
|
2415
|
-
$('statusBadge').textContent = s.running ? ('running :' + s.port) : 'stopped';
|
|
2416
|
-
var html = '';
|
|
2417
|
-
if (s.running) {
|
|
2418
|
-
html += 'Outbound server <span class="pill ok">running</span> on port ' + s.port + '<br/>';
|
|
2419
|
-
if (s.formats) {
|
|
2420
|
-
html += '<div class="mono muted">' +
|
|
2421
|
-
'chat: ' + s.formats.chat + '<br/>responses: ' + s.formats.responses +
|
|
2422
|
-
'<br/>messages: ' + s.formats.messages + '<br/>gemini: ' + s.formats.gemini + '</div>';
|
|
2423
|
-
}
|
|
2424
|
-
} else {
|
|
2425
|
-
html += 'Outbound server <span class="pill bad">stopped</span>';
|
|
2426
|
-
}
|
|
2427
|
-
$('statusBody').innerHTML = html;
|
|
2428
|
-
}
|
|
2429
|
-
|
|
2430
|
-
// ── Providers ───────────────────────────────────────────────────────────────
|
|
2431
|
-
// Curated presets loaded from GET /admin/api/presets. Selecting one prefills
|
|
2432
|
-
// the add-form (format/base/models); the WRITE still goes through the existing
|
|
2433
|
-
// POST/PUT /admin/api/providers path (no new write endpoint).
|
|
2434
|
-
var presetsById = {};
|
|
2435
|
-
var presetModels = []; // models staged by the last preset prefill
|
|
2436
|
-
|
|
2437
|
-
async function loadPresets() {
|
|
2438
|
-
var r = await api('GET', 'presets');
|
|
2439
|
-
var sel = $('pPreset');
|
|
2440
|
-
// Keep the placeholder; drop any previously appended options.
|
|
2441
|
-
while (sel.options.length > 1) sel.remove(1);
|
|
2442
|
-
presetsById = {};
|
|
2443
|
-
(r.json && r.json.presets || []).forEach(function (p) {
|
|
2444
|
-
presetsById[p.id] = p;
|
|
2445
|
-
var opt = document.createElement('option');
|
|
2446
|
-
opt.value = p.id;
|
|
2447
|
-
opt.textContent = p.name + ' (' + p.apiFormat + ')';
|
|
2448
|
-
sel.appendChild(opt);
|
|
2449
|
-
});
|
|
2450
|
-
}
|
|
2451
|
-
|
|
2452
|
-
function onPresetChange() {
|
|
2453
|
-
var p = presetsById[$('pPreset').value];
|
|
2454
|
-
if (!p) { presetModels = []; return; }
|
|
2455
|
-
if (!$('pId').value.trim()) $('pId').value = p.id;
|
|
2456
|
-
$('pFormat').value = p.apiFormat;
|
|
2457
|
-
$('pBase').value = p.baseUrl;
|
|
2458
|
-
presetModels = Array.isArray(p.models) ? p.models.slice() : [];
|
|
2459
|
-
}
|
|
2460
|
-
|
|
2461
|
-
async function loadProviders() {
|
|
2462
|
-
var r = await api('GET', 'providers');
|
|
2463
|
-
var body = $('providersTable').querySelector('tbody');
|
|
2464
|
-
clear(body);
|
|
2465
|
-
(r.json && r.json.providers || []).forEach(function (p) {
|
|
2466
|
-
var tr = document.createElement('tr');
|
|
2467
|
-
tr.appendChild(td(p.id));
|
|
2468
|
-
tr.appendChild(td(p.apiFormat));
|
|
2469
|
-
tr.appendChild(td(p.baseUrl));
|
|
2470
|
-
tr.appendChild(td(p.hasApiKey ? p.apiKeyMasked : '(none)'));
|
|
2471
|
-
var act = document.createElement('td');
|
|
2472
|
-
act.appendChild(btn('Edit', 'secondary', function () {
|
|
2473
|
-
$('pId').value = p.id; $('pFormat').value = p.apiFormat; $('pBase').value = p.baseUrl; $('pKey').value = '';
|
|
2474
|
-
}));
|
|
2475
|
-
act.appendChild(btn('Pool', 'secondary', function () { loadProviderKeys(p.id); }));
|
|
2476
|
-
act.appendChild(btn('Delete', 'danger', async function () {
|
|
2477
|
-
if (window.confirm('Delete provider ' + p.id + '?')) { await api('DELETE', 'providers/' + encodeURIComponent(p.id)); loadProviders(); }
|
|
2478
|
-
}));
|
|
2479
|
-
tr.appendChild(act);
|
|
2480
|
-
body.appendChild(tr);
|
|
2481
|
-
});
|
|
2482
|
-
}
|
|
2483
|
-
|
|
2484
|
-
// ── Pool health (read-only; key-pool change) ──────────────────────────────
|
|
2485
|
-
// GET /admin/api/providers/:id/keys → masked pool view. Multi-key is
|
|
2486
|
-
// cold-standby + observable in v1 (no outbound failover yet — see panel note).
|
|
2487
|
-
async function loadProviderKeys(providerId) {
|
|
2488
|
-
var r = await api('GET', 'providers/' + encodeURIComponent(providerId) + '/keys');
|
|
2489
|
-
var panel = $('poolPanel');
|
|
2490
|
-
var body = $('poolTable').querySelector('tbody');
|
|
2491
|
-
clear(body);
|
|
2492
|
-
$('poolTitle').textContent = 'API key pool — ' + providerId;
|
|
2493
|
-
(r.json && r.json.keys || []).forEach(function (k) {
|
|
2494
|
-
var tr = document.createElement('tr');
|
|
2495
|
-
tr.appendChild(td(k.id));
|
|
2496
|
-
tr.appendChild(td(k.label));
|
|
2497
|
-
tr.appendChild(td(k.apiKeyMasked));
|
|
2498
|
-
tr.appendChild(td(k.enabled ? 'yes' : 'no'));
|
|
2499
|
-
tr.appendChild(td(k.weight));
|
|
2500
|
-
var h = '';
|
|
2501
|
-
if (k.health && k.health.autoDisabled) h += 'auto-disabled (' + k.health.autoDisabled.status + ') ';
|
|
2502
|
-
if (k.health && k.health.cooldown) h += 'cooldown until ' + new Date(k.health.cooldown.until).toLocaleTimeString();
|
|
2503
|
-
tr.appendChild(td(h || 'ok'));
|
|
2504
|
-
body.appendChild(tr);
|
|
2505
|
-
});
|
|
2506
|
-
panel.style.display = 'block';
|
|
2507
|
-
}
|
|
2508
|
-
|
|
2509
|
-
async function saveProvider() {
|
|
2510
|
-
$('pErr').textContent = '';
|
|
2511
|
-
var id = $('pId').value.trim();
|
|
2512
|
-
if (!id) { $('pErr').textContent = 'id required'; return; }
|
|
2513
|
-
var payload = { id: id, apiFormat: $('pFormat').value, baseUrl: $('pBase').value.trim(), apiKey: $('pKey').value };
|
|
2514
|
-
// Carry the preset-prefilled models (existing parseProviderInput accepts them).
|
|
2515
|
-
if (presetModels.length) payload.models = presetModels;
|
|
2516
|
-
// Try PUT first (edit, blank key keeps existing); fall back to POST (create).
|
|
2517
|
-
var r = await api('PUT', 'providers/' + encodeURIComponent(id), payload);
|
|
2518
|
-
if (r.status === 404) r = await api('POST', 'providers', payload);
|
|
2519
|
-
if (r.status >= 400) { $('pErr').textContent = (r.json && r.json.error && r.json.error.message) || ('error ' + r.status); return; }
|
|
2520
|
-
$('pId').value = ''; $('pBase').value = ''; $('pKey').value = '';
|
|
2521
|
-
$('pPreset').value = ''; presetModels = []; // reset the picker after a write
|
|
2522
|
-
loadProviders();
|
|
2523
|
-
}
|
|
2524
|
-
|
|
2525
|
-
// ── Keys ────────────────────────────────────────────────────────────────────
|
|
2526
|
-
async function loadKeys() {
|
|
2527
|
-
var r = await api('GET', 'keys');
|
|
2528
|
-
var body = $('keysTable').querySelector('tbody');
|
|
2529
|
-
clear(body);
|
|
2530
|
-
(r.json && r.json.keys || []).forEach(function (k) {
|
|
2531
|
-
var tr = document.createElement('tr');
|
|
2532
|
-
tr.appendChild(td(k.name));
|
|
2533
|
-
tr.appendChild(td(k.keyPrefix));
|
|
2534
|
-
tr.appendChild(td(k.enabled ? 'yes' : 'no'));
|
|
2535
|
-
tr.appendChild(td(k.revoked ? 'yes' : 'no'));
|
|
2536
|
-
var act = document.createElement('td');
|
|
2537
|
-
if (!k.revoked) {
|
|
2538
|
-
act.appendChild(btn(k.enabled ? 'Disable' : 'Enable', 'secondary', async function () {
|
|
2539
|
-
await api('POST', 'keys/' + encodeURIComponent(k.id) + '/enabled', { enabled: !k.enabled }); loadKeys();
|
|
2540
|
-
}));
|
|
2541
|
-
act.appendChild(btn('Revoke', 'danger', async function () {
|
|
2542
|
-
if (window.confirm('Revoke ' + k.name + '?')) { await api('POST', 'keys/' + encodeURIComponent(k.id) + '/revoke'); loadKeys(); }
|
|
2543
|
-
}));
|
|
2544
|
-
}
|
|
2545
|
-
tr.appendChild(act);
|
|
2546
|
-
body.appendChild(tr);
|
|
2547
|
-
});
|
|
2548
|
-
}
|
|
2549
|
-
|
|
2550
|
-
function showKeyModal(plaintext) {
|
|
2551
|
-
$('keyPlaintext').textContent = plaintext;
|
|
2552
|
-
$('keyModalBg').classList.add('show');
|
|
2553
|
-
$('keyCopy').onclick = function () { navigator.clipboard && navigator.clipboard.writeText(plaintext); };
|
|
2554
|
-
$('keyClose').onclick = function () {
|
|
2555
|
-
$('keyModalBg').classList.remove('show');
|
|
2556
|
-
$('keyPlaintext').textContent = ''; // never persist the plaintext
|
|
2557
|
-
};
|
|
2558
|
-
}
|
|
2559
|
-
|
|
2560
|
-
async function createKey() {
|
|
2561
|
-
var name = $('kName').value.trim() || 'key';
|
|
2562
|
-
var r = await api('POST', 'keys', { name: name });
|
|
2563
|
-
if (r.json && r.json.plaintextOnce) { showKeyModal(r.json.plaintextOnce); $('kName').value = ''; loadKeys(); }
|
|
2564
|
-
}
|
|
2565
|
-
|
|
2566
|
-
// ── Server config ───────────────────────────────────────────────────────────
|
|
2567
|
-
async function loadServer() {
|
|
2568
|
-
var r = await api('GET', 'server');
|
|
2569
|
-
var s = (r.json && r.json.server) || {};
|
|
2570
|
-
$('sEnabled').checked = !!s.enabled;
|
|
2571
|
-
$('sLan').checked = !!s.networkBinding;
|
|
2572
|
-
$('sPort').value = s.port || '';
|
|
2573
|
-
var body = $('endpointsTable').querySelector('tbody');
|
|
2574
|
-
clear(body);
|
|
2575
|
-
(s.endpoints || []).forEach(function (e) {
|
|
2576
|
-
var tr = document.createElement('tr');
|
|
2577
|
-
tr.appendChild(td(e.endpoint));
|
|
2578
|
-
tr.appendChild(td(e.defaultModel));
|
|
2579
|
-
tr.appendChild(td(e.useSubscription ? 'yes' : 'no'));
|
|
2580
|
-
body.appendChild(tr);
|
|
2581
|
-
});
|
|
2895
|
+
// src/admin/uiStatic.ts
|
|
2896
|
+
import { existsSync as existsSync4, statSync } from "fs";
|
|
2897
|
+
import { readFile } from "fs/promises";
|
|
2898
|
+
import { createRequire } from "module";
|
|
2899
|
+
import path from "path";
|
|
2900
|
+
var CONTENT_TYPES = {
|
|
2901
|
+
".html": "text/html; charset=utf-8",
|
|
2902
|
+
".js": "text/javascript; charset=utf-8",
|
|
2903
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
2904
|
+
".css": "text/css; charset=utf-8",
|
|
2905
|
+
".json": "application/json; charset=utf-8",
|
|
2906
|
+
".svg": "image/svg+xml",
|
|
2907
|
+
".png": "image/png",
|
|
2908
|
+
".ico": "image/x-icon",
|
|
2909
|
+
".webp": "image/webp",
|
|
2910
|
+
".woff": "font/woff",
|
|
2911
|
+
".woff2": "font/woff2",
|
|
2912
|
+
".ttf": "font/ttf",
|
|
2913
|
+
".map": "application/json; charset=utf-8",
|
|
2914
|
+
".txt": "text/plain; charset=utf-8"
|
|
2915
|
+
};
|
|
2916
|
+
function resolveUiDist() {
|
|
2917
|
+
const fromEnv = process.env["OMNICROSS_UI_DIST"];
|
|
2918
|
+
if (fromEnv) {
|
|
2919
|
+
return existsSync4(path.join(fromEnv, "index.html")) ? path.resolve(fromEnv) : null;
|
|
2582
2920
|
}
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2921
|
+
try {
|
|
2922
|
+
const req = createRequire(typeof __filename !== "undefined" ? __filename : import.meta.url);
|
|
2923
|
+
const pkgJson = req.resolve("@omnicross/ui/package.json");
|
|
2924
|
+
const dist = path.join(path.dirname(pkgJson), "dist");
|
|
2925
|
+
return existsSync4(path.join(dist, "index.html")) ? dist : null;
|
|
2926
|
+
} catch {
|
|
2927
|
+
return null;
|
|
2590
2928
|
}
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
clear(body);
|
|
2599
|
-
(r.json && r.json.accounts || []).forEach(function (a) {
|
|
2600
|
-
var tr = document.createElement('tr');
|
|
2601
|
-
tr.appendChild(td(a.displayName || a.providerId));
|
|
2602
|
-
tr.appendChild(td(a.kind));
|
|
2603
|
-
var st = document.createElement('td');
|
|
2604
|
-
var ok = a.credentialStatus && a.credentialStatus.ok;
|
|
2605
|
-
var pill = document.createElement('span');
|
|
2606
|
-
pill.className = 'pill ' + (ok ? 'ok' : 'bad');
|
|
2607
|
-
pill.textContent = ok ? 'ok' : ((a.credentialStatus && a.credentialStatus.reason) || 'no credential');
|
|
2608
|
-
st.appendChild(pill);
|
|
2609
|
-
tr.appendChild(st);
|
|
2610
|
-
var act = document.createElement('td');
|
|
2611
|
-
act.appendChild(btn('Clear', 'danger', async function () {
|
|
2612
|
-
if (window.confirm('Clear ' + a.providerId + ' token?')) {
|
|
2613
|
-
await api('DELETE', 'accounts/' + encodeURIComponent(a.providerId));
|
|
2614
|
-
loadAccounts();
|
|
2615
|
-
}
|
|
2616
|
-
}));
|
|
2617
|
-
tr.appendChild(act);
|
|
2618
|
-
body.appendChild(tr);
|
|
2619
|
-
});
|
|
2620
|
-
renderProviderAccounts(r.json && r.json.providerAccounts || {});
|
|
2929
|
+
}
|
|
2930
|
+
async function handleUiStatic(req, res, urlPath, uiDist) {
|
|
2931
|
+
if (urlPath !== "/ui" && !urlPath.startsWith("/ui/")) return false;
|
|
2932
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2933
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
2934
|
+
res.end(JSON.stringify({ error: { type: "method_not_allowed", message: "GET/HEAD only" } }));
|
|
2935
|
+
return true;
|
|
2621
2936
|
}
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
(byProvider[provider] || []).forEach(function (acc) {
|
|
2630
|
-
var tr = document.createElement('tr');
|
|
2631
|
-
tr.appendChild(td(provider));
|
|
2632
|
-
tr.appendChild(td(acc.label || acc.id));
|
|
2633
|
-
tr.appendChild(td(acc.status));
|
|
2634
|
-
tr.appendChild(td(acc.isActive ? 'yes' : ''));
|
|
2635
|
-
var act = document.createElement('td');
|
|
2636
|
-
if (!acc.isActive) {
|
|
2637
|
-
act.appendChild(btn('Set active', '', async function () {
|
|
2638
|
-
await api('PUT', 'accounts/' + encodeURIComponent(provider) + '/active', { id: acc.id });
|
|
2639
|
-
loadAccounts();
|
|
2640
|
-
}));
|
|
2937
|
+
if (!uiDist) {
|
|
2938
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2939
|
+
res.end(
|
|
2940
|
+
JSON.stringify({
|
|
2941
|
+
error: {
|
|
2942
|
+
type: "ui_not_installed",
|
|
2943
|
+
message: "Control Panel UI not installed (@omnicross/ui has no built dist). Install/build @omnicross/ui or set OMNICROSS_UI_DIST."
|
|
2641
2944
|
}
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
loadAccounts();
|
|
2646
|
-
}
|
|
2647
|
-
}));
|
|
2648
|
-
tr.appendChild(act);
|
|
2649
|
-
body.appendChild(tr);
|
|
2650
|
-
});
|
|
2651
|
-
});
|
|
2652
|
-
}
|
|
2653
|
-
|
|
2654
|
-
async function saveAccount() {
|
|
2655
|
-
$('acErr').textContent = '';
|
|
2656
|
-
var provider = $('acProvider').value;
|
|
2657
|
-
var raw = $('acBody').value.trim();
|
|
2658
|
-
if (!raw) { $('acErr').textContent = 'paste a token JSON'; return; }
|
|
2659
|
-
var payload; try { payload = JSON.parse(raw); } catch (e) { $('acErr').textContent = 'invalid JSON'; return; }
|
|
2660
|
-
var r = await api('PUT', 'accounts/' + encodeURIComponent(provider), payload);
|
|
2661
|
-
if (r.status >= 400) { $('acErr').textContent = (r.json && r.json.error && r.json.error.message) || ('error ' + r.status); return; }
|
|
2662
|
-
$('acBody').value = ''; // never persist/echo the just-saved token
|
|
2663
|
-
loadAccounts();
|
|
2664
|
-
}
|
|
2665
|
-
|
|
2666
|
-
// ── Playground ──────────────────────────────────────────────────────────────
|
|
2667
|
-
async function sendPlayground() {
|
|
2668
|
-
var pre = $('plResponse');
|
|
2669
|
-
pre.classList.remove('muted');
|
|
2670
|
-
pre.textContent = 'sending…';
|
|
2671
|
-
var bodyText = $('plBody').value;
|
|
2672
|
-
var parsed; try { parsed = JSON.parse(bodyText); } catch (e) { parsed = bodyText; }
|
|
2673
|
-
var r = await fetch('/admin/api/playground', {
|
|
2674
|
-
method: 'POST',
|
|
2675
|
-
headers: headers({ 'Content-Type': 'application/json' }),
|
|
2676
|
-
body: JSON.stringify({ endpoint: $('plEndpoint').value, key: $('plKey').value, body: parsed }),
|
|
2677
|
-
});
|
|
2678
|
-
var text = await r.text();
|
|
2679
|
-
pre.textContent = '[' + r.status + ']\n' + text;
|
|
2945
|
+
})
|
|
2946
|
+
);
|
|
2947
|
+
return true;
|
|
2680
2948
|
}
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
$('kCreate').onclick = createKey;
|
|
2686
|
-
$('sSave').onclick = saveServer;
|
|
2687
|
-
$('acSave').onclick = saveAccount;
|
|
2688
|
-
$('plSend').onclick = sendPlayground;
|
|
2689
|
-
refresh();
|
|
2949
|
+
if (urlPath === "/ui") {
|
|
2950
|
+
res.writeHead(302, { Location: "/ui/" });
|
|
2951
|
+
res.end();
|
|
2952
|
+
return true;
|
|
2690
2953
|
}
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2954
|
+
let rel;
|
|
2955
|
+
try {
|
|
2956
|
+
rel = decodeURIComponent(urlPath.slice("/ui/".length));
|
|
2957
|
+
} catch {
|
|
2958
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2959
|
+
res.end(JSON.stringify({ error: { type: "bad_request", message: "malformed path" } }));
|
|
2960
|
+
return true;
|
|
2961
|
+
}
|
|
2962
|
+
if (rel.includes("\\") || rel.includes("\0")) {
|
|
2963
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2964
|
+
res.end(JSON.stringify({ error: { type: "bad_request", message: "invalid path" } }));
|
|
2965
|
+
return true;
|
|
2966
|
+
}
|
|
2967
|
+
const filePath = path.resolve(uiDist, rel === "" ? "index.html" : rel);
|
|
2968
|
+
if (filePath !== uiDist && !filePath.startsWith(uiDist + path.sep)) {
|
|
2969
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2970
|
+
res.end(JSON.stringify({ error: { type: "forbidden", message: "path outside ui root" } }));
|
|
2971
|
+
return true;
|
|
2972
|
+
}
|
|
2973
|
+
let target = filePath;
|
|
2974
|
+
if (!existsSync4(target) || statSync(target).isDirectory()) {
|
|
2975
|
+
if (path.extname(rel) === "") {
|
|
2976
|
+
target = path.join(uiDist, "index.html");
|
|
2977
|
+
} else {
|
|
2978
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2979
|
+
res.end(JSON.stringify({ error: { type: "not_found", message: "no such ui asset" } }));
|
|
2980
|
+
return true;
|
|
2981
|
+
}
|
|
2694
2982
|
}
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
// src/admin/html.ts
|
|
2702
|
-
var STYLE = `
|
|
2703
|
-
:root { --bg:#0f1115; --panel:#171a21; --line:#272b35; --fg:#e6e8ec; --muted:#8b91a0; --accent:#5b8cff; --danger:#ff5b6e; --ok:#3ecf8e; }
|
|
2704
|
-
* { box-sizing: border-box; }
|
|
2705
|
-
body { margin:0; font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--fg); }
|
|
2706
|
-
header { padding:14px 20px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:12px; }
|
|
2707
|
-
header h1 { font-size:16px; margin:0; font-weight:600; }
|
|
2708
|
-
header .badge { font-size:12px; color:var(--muted); }
|
|
2709
|
-
main { padding:20px; display:grid; gap:20px; max-width:980px; margin:0 auto; }
|
|
2710
|
-
section { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:16px; }
|
|
2711
|
-
section h2 { font-size:14px; margin:0 0 12px; font-weight:600; }
|
|
2712
|
-
table { width:100%; border-collapse:collapse; font-size:13px; }
|
|
2713
|
-
th, td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); }
|
|
2714
|
-
th { color:var(--muted); font-weight:500; }
|
|
2715
|
-
input, select, textarea { background:var(--bg); color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:6px 8px; font:inherit; }
|
|
2716
|
-
textarea { width:100%; min-height:90px; resize:vertical; font-family:ui-monospace,Menlo,monospace; }
|
|
2717
|
-
button { background:var(--accent); color:#fff; border:0; border-radius:6px; padding:6px 12px; cursor:pointer; font:inherit; }
|
|
2718
|
-
button.secondary { background:#2a2f3a; }
|
|
2719
|
-
button.danger { background:var(--danger); }
|
|
2720
|
-
.row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:8px; }
|
|
2721
|
-
.muted { color:var(--muted); }
|
|
2722
|
-
.mono { font-family:ui-monospace,Menlo,monospace; }
|
|
2723
|
-
.pill { padding:1px 8px; border-radius:999px; font-size:12px; }
|
|
2724
|
-
.pill.ok { background:rgba(62,207,142,.15); color:var(--ok); }
|
|
2725
|
-
.pill.bad { background:rgba(255,91,110,.15); color:var(--danger); }
|
|
2726
|
-
pre { background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:10px; overflow:auto; max-height:320px; white-space:pre-wrap; }
|
|
2727
|
-
.modal-bg { position:fixed; inset:0; background:rgba(0,0,0,.6); display:none; align-items:center; justify-content:center; }
|
|
2728
|
-
.modal-bg.show { display:flex; }
|
|
2729
|
-
.modal { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:20px; max-width:520px; width:90%; }
|
|
2730
|
-
.warn { color:var(--danger); font-size:13px; margin:8px 0; }
|
|
2731
|
-
.err { color:var(--danger); font-size:13px; }
|
|
2732
|
-
`;
|
|
2733
|
-
var BODY = `
|
|
2734
|
-
<header>
|
|
2735
|
-
<h1>omnicross daemon dashboard</h1>
|
|
2736
|
-
<span class="badge" id="statusBadge">connecting\u2026</span>
|
|
2737
|
-
</header>
|
|
2738
|
-
<main>
|
|
2739
|
-
<section id="statusSection">
|
|
2740
|
-
<h2>Runtime status</h2>
|
|
2741
|
-
<div id="statusBody" class="muted">loading\u2026</div>
|
|
2742
|
-
</section>
|
|
2743
|
-
|
|
2744
|
-
<section>
|
|
2745
|
-
<h2>Providers</h2>
|
|
2746
|
-
<table id="providersTable"><thead><tr><th>id</th><th>format</th><th>base URL</th><th>key</th><th></th></tr></thead><tbody></tbody></table>
|
|
2747
|
-
<div class="row">
|
|
2748
|
-
<select id="pPreset"><option value="">-- \u9009\u62E9\u9884\u7F6E --</option></select>
|
|
2749
|
-
<input id="pId" placeholder="id" size="10" />
|
|
2750
|
-
<select id="pFormat"><option value="openai">openai</option><option value="anthropic">anthropic</option><option value="gemini">gemini</option></select>
|
|
2751
|
-
<input id="pBase" placeholder="base URL" size="26" />
|
|
2752
|
-
<input id="pKey" placeholder="apiKey (blank = keep on edit)" size="22" />
|
|
2753
|
-
<button id="pSave">Save provider</button>
|
|
2754
|
-
</div>
|
|
2755
|
-
<div class="err" id="pErr"></div>
|
|
2756
|
-
<div id="poolPanel" style="display:none; margin-top:12px;">
|
|
2757
|
-
<h2 id="poolTitle" style="font-size:13px;">API key pool</h2>
|
|
2758
|
-
<p class="muted" style="margin:0 0 8px;">Read-only. Multi-key is <b>cold-standby + observable</b> in v1: outbound failover does not yet rotate keys (null-session boundary \u2014 pending the core seam). Keys are masked; edit the pool via the provider's <span class="mono">apiKeys</span>.</p>
|
|
2759
|
-
<table id="poolTable"><thead><tr><th>id</th><th>label</th><th>key</th><th>enabled</th><th>weight</th><th>health</th></tr></thead><tbody></tbody></table>
|
|
2760
|
-
</div>
|
|
2761
|
-
</section>
|
|
2762
|
-
|
|
2763
|
-
<section>
|
|
2764
|
-
<h2>Named keys</h2>
|
|
2765
|
-
<table id="keysTable"><thead><tr><th>name</th><th>prefix</th><th>enabled</th><th>revoked</th><th></th></tr></thead><tbody></tbody></table>
|
|
2766
|
-
<div class="row">
|
|
2767
|
-
<input id="kName" placeholder="key name" size="16" />
|
|
2768
|
-
<button id="kCreate">Create key</button>
|
|
2769
|
-
</div>
|
|
2770
|
-
</section>
|
|
2771
|
-
|
|
2772
|
-
<section>
|
|
2773
|
-
<h2>Server config</h2>
|
|
2774
|
-
<div class="row">
|
|
2775
|
-
<label><input type="checkbox" id="sEnabled" /> enabled</label>
|
|
2776
|
-
<label><input type="checkbox" id="sLan" /> networkBinding (LAN)</label>
|
|
2777
|
-
<label>port <input id="sPort" size="6" /></label>
|
|
2778
|
-
<button id="sSave">Apply server config</button>
|
|
2779
|
-
</div>
|
|
2780
|
-
<table id="endpointsTable"><thead><tr><th>endpoint</th><th>defaultModel</th><th>subscription</th></tr></thead><tbody></tbody></table>
|
|
2781
|
-
</section>
|
|
2782
|
-
|
|
2783
|
-
<section>
|
|
2784
|
-
<h2>Accounts <span class="muted">(subscription tokens)</span></h2>
|
|
2785
|
-
<table id="accountsTable"><thead><tr><th>provider</th><th>kind</th><th>status</th><th></th></tr></thead><tbody></tbody></table>
|
|
2786
|
-
<h3>Per-provider accounts <span class="muted">(multi-account \u2014 sanitized, no tokens)</span></h3>
|
|
2787
|
-
<table id="providerAccountsTable"><thead><tr><th>provider</th><th>label</th><th>status</th><th>active</th><th></th></tr></thead><tbody></tbody></table>
|
|
2788
|
-
<div class="row">
|
|
2789
|
-
<select id="acProvider"><option value="claude">claude</option><option value="codex">codex</option><option value="gemini">gemini</option><option value="opencodego">opencodego</option></select>
|
|
2790
|
-
<button id="acSave">Save token</button>
|
|
2791
|
-
</div>
|
|
2792
|
-
<textarea id="acBody" placeholder='{"authMethod":"oauth","status":"authorized","accessToken":"\u2026","refreshToken":"\u2026"}'></textarea>
|
|
2793
|
-
<p class="muted">Write-only: paste a token JSON to authorize this provider. The token is shown only on entry \u2014 it is never read back or displayed. Stored as plain JSON in <span class="mono">tokens.json</span>.</p>
|
|
2794
|
-
<div class="err" id="acErr"></div>
|
|
2795
|
-
</section>
|
|
2796
|
-
|
|
2797
|
-
<section>
|
|
2798
|
-
<h2>Playground</h2>
|
|
2799
|
-
<div class="row">
|
|
2800
|
-
<select id="plEndpoint"><option value="chat">chat</option><option value="responses">responses</option><option value="messages">messages</option><option value="gemini">gemini</option></select>
|
|
2801
|
-
<input id="plKey" placeholder="named key (sk-omnicross-\u2026)" size="30" />
|
|
2802
|
-
<button id="plSend">Send</button>
|
|
2803
|
-
</div>
|
|
2804
|
-
<textarea id="plBody">{"model":"","messages":[{"role":"user","content":"ping"}]}</textarea>
|
|
2805
|
-
<pre id="plResponse" class="muted">response will appear here</pre>
|
|
2806
|
-
</section>
|
|
2807
|
-
</main>
|
|
2808
|
-
|
|
2809
|
-
<div class="modal-bg" id="keyModalBg">
|
|
2810
|
-
<div class="modal">
|
|
2811
|
-
<h2>Key created</h2>
|
|
2812
|
-
<p class="warn">This secret is shown ONCE. Copy it now \u2014 it cannot be retrieved again.</p>
|
|
2813
|
-
<pre id="keyPlaintext" class="mono"></pre>
|
|
2814
|
-
<div class="row">
|
|
2815
|
-
<button id="keyCopy">Copy</button>
|
|
2816
|
-
<button class="secondary" id="keyClose">Close</button>
|
|
2817
|
-
</div>
|
|
2818
|
-
</div>
|
|
2819
|
-
</div>
|
|
2820
|
-
`;
|
|
2821
|
-
var DASHBOARD_HTML = `<!doctype html>
|
|
2822
|
-
<html lang="en">
|
|
2823
|
-
<head>
|
|
2824
|
-
<meta charset="utf-8" />
|
|
2825
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2826
|
-
<title>omnicross daemon dashboard</title>
|
|
2827
|
-
<style>${STYLE}</style>
|
|
2828
|
-
</head>
|
|
2829
|
-
<body>
|
|
2830
|
-
${BODY}
|
|
2831
|
-
<script>${DASHBOARD_JS}</script>
|
|
2832
|
-
</body>
|
|
2833
|
-
</html>`;
|
|
2983
|
+
const body = await readFile(target);
|
|
2984
|
+
const type = CONTENT_TYPES[path.extname(target).toLowerCase()] ?? "application/octet-stream";
|
|
2985
|
+
res.writeHead(200, { "Content-Type": type, "Content-Length": body.length });
|
|
2986
|
+
res.end(req.method === "HEAD" ? void 0 : body);
|
|
2987
|
+
return true;
|
|
2988
|
+
}
|
|
2834
2989
|
|
|
2835
2990
|
// src/admin/AdminServer.ts
|
|
2836
2991
|
var LOOPBACK_ADDR = "127.0.0.1";
|
|
2837
2992
|
var LAN_ADDR = "0.0.0.0";
|
|
2993
|
+
var DAEMON_VERSION = true ? "0.1.2" : "0.0.0-dev";
|
|
2838
2994
|
var AdminServer = class {
|
|
2839
2995
|
constructor(deps) {
|
|
2840
2996
|
this.deps = deps;
|
|
@@ -2843,6 +2999,8 @@ var AdminServer = class {
|
|
|
2843
2999
|
server = null;
|
|
2844
3000
|
boundPort = 0;
|
|
2845
3001
|
boundAddr = LOOPBACK_ADDR;
|
|
3002
|
+
/** Control Panel dist dir (resolved once at first request; null = no UI). */
|
|
3003
|
+
uiDist;
|
|
2846
3004
|
/**
|
|
2847
3005
|
* Start the admin listener honoring the resolved admin config. Returns the
|
|
2848
3006
|
* actual bound port, or `0` when it refuses/declines to bind (disabled or the
|
|
@@ -2871,13 +3029,13 @@ var AdminServer = class {
|
|
|
2871
3029
|
const server = http2.createServer((req, res) => {
|
|
2872
3030
|
this.onRequest(req, res);
|
|
2873
3031
|
});
|
|
2874
|
-
const onError = (
|
|
2875
|
-
if (
|
|
3032
|
+
const onError = (err5) => {
|
|
3033
|
+
if (err5.code === "EADDRINUSE" && port !== 0) {
|
|
2876
3034
|
server.removeListener("error", onError);
|
|
2877
3035
|
this.listen(bindAddr, 0).then(resolve, reject);
|
|
2878
3036
|
return;
|
|
2879
3037
|
}
|
|
2880
|
-
reject(
|
|
3038
|
+
reject(err5);
|
|
2881
3039
|
};
|
|
2882
3040
|
server.on("error", onError);
|
|
2883
3041
|
server.listen(port, bindAddr, () => {
|
|
@@ -2895,8 +3053,8 @@ var AdminServer = class {
|
|
|
2895
3053
|
}
|
|
2896
3054
|
/** Per-request handler: auth gate (when a token is set) → routing. */
|
|
2897
3055
|
onRequest(req, res) {
|
|
2898
|
-
void this.dispatch(req, res).catch((
|
|
2899
|
-
const message =
|
|
3056
|
+
void this.dispatch(req, res).catch((err5) => {
|
|
3057
|
+
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
2900
3058
|
console.error("[AdminServer] unhandled error:", message);
|
|
2901
3059
|
if (!res.headersSent) {
|
|
2902
3060
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
@@ -2906,22 +3064,26 @@ var AdminServer = class {
|
|
|
2906
3064
|
}
|
|
2907
3065
|
async dispatch(req, res) {
|
|
2908
3066
|
const cfg = this.deps.getAdminConfig();
|
|
3067
|
+
res.setHeader("x-omnicross-daemon", DAEMON_VERSION);
|
|
3068
|
+
res.setHeader("x-omnicross-pid", String(process.pid));
|
|
2909
3069
|
if (cfg.token && !this.isAuthorized(req, cfg.token)) {
|
|
2910
3070
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
2911
3071
|
res.end(JSON.stringify({ error: { type: "unauthorized", message: "admin token required" } }));
|
|
2912
3072
|
return;
|
|
2913
3073
|
}
|
|
2914
3074
|
const url = req.url ?? "/";
|
|
2915
|
-
const
|
|
2916
|
-
if ((req.method === "GET" || req.method === "HEAD") && (
|
|
2917
|
-
res.writeHead(
|
|
2918
|
-
res.end(
|
|
3075
|
+
const path2 = url.split("?")[0];
|
|
3076
|
+
if ((req.method === "GET" || req.method === "HEAD") && (path2 === "/" || path2 === "/admin")) {
|
|
3077
|
+
res.writeHead(302, { Location: "/ui/" });
|
|
3078
|
+
res.end();
|
|
2919
3079
|
return;
|
|
2920
3080
|
}
|
|
2921
|
-
if (
|
|
2922
|
-
await handleAdminApi(req, res,
|
|
3081
|
+
if (path2.startsWith("/admin/api/")) {
|
|
3082
|
+
await handleAdminApi(req, res, path2, this.deps);
|
|
2923
3083
|
return;
|
|
2924
3084
|
}
|
|
3085
|
+
if (this.uiDist === void 0) this.uiDist = resolveUiDist();
|
|
3086
|
+
if (await handleUiStatic(req, res, path2, this.uiDist)) return;
|
|
2925
3087
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2926
3088
|
res.end(JSON.stringify({ error: { type: "not_found", message: "no such admin route" } }));
|
|
2927
3089
|
}
|
|
@@ -3044,18 +3206,18 @@ function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
3044
3206
|
res.end(pageHtml("Login complete."));
|
|
3045
3207
|
finish(server, () => resolve(code));
|
|
3046
3208
|
});
|
|
3047
|
-
server.on("error", (
|
|
3209
|
+
server.on("error", (err5) => {
|
|
3048
3210
|
if (settled) return;
|
|
3049
3211
|
settled = true;
|
|
3050
3212
|
clearTimeout(timer);
|
|
3051
|
-
if (
|
|
3213
|
+
if (err5.code === "EADDRINUSE") {
|
|
3052
3214
|
reject(
|
|
3053
3215
|
new Error(
|
|
3054
3216
|
`login: cannot bind ${LOOPBACK_HOST}:${LOOPBACK_PORT} (address in use) \u2014 another codex login or process is holding the port`
|
|
3055
3217
|
)
|
|
3056
3218
|
);
|
|
3057
3219
|
} else {
|
|
3058
|
-
reject(
|
|
3220
|
+
reject(err5);
|
|
3059
3221
|
}
|
|
3060
3222
|
});
|
|
3061
3223
|
const timer = setTimeout(() => {
|
|
@@ -3342,14 +3504,560 @@ var JsonApiServerSettingsStore = class {
|
|
|
3342
3504
|
}
|
|
3343
3505
|
};
|
|
3344
3506
|
|
|
3507
|
+
// src/ports/JsonlUsageEventStore.ts
|
|
3508
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
3509
|
+
import { appendFileSync, existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
|
|
3510
|
+
var JsonlUsageEventStore = class {
|
|
3511
|
+
constructor(eventsPath, isPriced) {
|
|
3512
|
+
this.eventsPath = eventsPath;
|
|
3513
|
+
this.isPriced = isPriced;
|
|
3514
|
+
}
|
|
3515
|
+
eventsPath;
|
|
3516
|
+
isPriced;
|
|
3517
|
+
/** Persist one event: assign `id`, stamp `ts` when absent, append ONE line. */
|
|
3518
|
+
async insert(input) {
|
|
3519
|
+
const row = {
|
|
3520
|
+
...input,
|
|
3521
|
+
id: randomUUID3(),
|
|
3522
|
+
ts: input.ts ?? Date.now()
|
|
3523
|
+
};
|
|
3524
|
+
appendFileSync(this.eventsPath, JSON.stringify(row) + "\n", "utf8");
|
|
3525
|
+
return row.id;
|
|
3526
|
+
}
|
|
3527
|
+
async getTotals(range) {
|
|
3528
|
+
const totals = {
|
|
3529
|
+
inputTokens: 0,
|
|
3530
|
+
outputTokens: 0,
|
|
3531
|
+
cacheReadTokens: 0,
|
|
3532
|
+
cacheCreationTokens: 0,
|
|
3533
|
+
reasoningTokens: 0,
|
|
3534
|
+
costUsd: 0,
|
|
3535
|
+
costSavedByCacheUsd: 0,
|
|
3536
|
+
eventCount: 0
|
|
3537
|
+
};
|
|
3538
|
+
for (const row of this.readRows(range)) {
|
|
3539
|
+
totals.inputTokens += row.inputTokens;
|
|
3540
|
+
totals.outputTokens += row.outputTokens;
|
|
3541
|
+
totals.cacheReadTokens += row.cacheReadTokens;
|
|
3542
|
+
totals.cacheCreationTokens += row.cacheCreationTokens;
|
|
3543
|
+
totals.reasoningTokens += row.reasoningTokens;
|
|
3544
|
+
totals.costUsd += row.costUsd;
|
|
3545
|
+
totals.costSavedByCacheUsd += row.costSavedByCacheUsd;
|
|
3546
|
+
totals.eventCount += 1;
|
|
3547
|
+
}
|
|
3548
|
+
return totals;
|
|
3549
|
+
}
|
|
3550
|
+
async getByModel(range) {
|
|
3551
|
+
const groups = /* @__PURE__ */ new Map();
|
|
3552
|
+
for (const row of this.readRows(range)) {
|
|
3553
|
+
const key = `${row.providerId}::${row.model}`;
|
|
3554
|
+
let g = groups.get(key);
|
|
3555
|
+
if (!g) {
|
|
3556
|
+
g = {
|
|
3557
|
+
providerId: row.providerId,
|
|
3558
|
+
model: row.model,
|
|
3559
|
+
eventCount: 0,
|
|
3560
|
+
inputTokens: 0,
|
|
3561
|
+
outputTokens: 0,
|
|
3562
|
+
cacheReadTokens: 0,
|
|
3563
|
+
cacheCreationTokens: 0,
|
|
3564
|
+
costUsd: 0,
|
|
3565
|
+
costSavedByCacheUsd: 0,
|
|
3566
|
+
unpriced: false
|
|
3567
|
+
};
|
|
3568
|
+
groups.set(key, g);
|
|
3569
|
+
}
|
|
3570
|
+
g.eventCount += 1;
|
|
3571
|
+
g.inputTokens += row.inputTokens;
|
|
3572
|
+
g.outputTokens += row.outputTokens;
|
|
3573
|
+
g.cacheReadTokens += row.cacheReadTokens;
|
|
3574
|
+
g.cacheCreationTokens += row.cacheCreationTokens;
|
|
3575
|
+
g.costUsd += row.costUsd;
|
|
3576
|
+
g.costSavedByCacheUsd += row.costSavedByCacheUsd;
|
|
3577
|
+
}
|
|
3578
|
+
const rows = Array.from(groups.values());
|
|
3579
|
+
for (const g of rows) {
|
|
3580
|
+
g.unpriced = !await this.isPriced(g.providerId, g.model);
|
|
3581
|
+
}
|
|
3582
|
+
return rows;
|
|
3583
|
+
}
|
|
3584
|
+
/**
|
|
3585
|
+
* Group by RAW apiKeyId (null forms the unattributed sentinel group). Label
|
|
3586
|
+
* here is the raw id fallback — the admin handler resolves display labels
|
|
3587
|
+
* against the configured pool keys (the store stays config-schema-free).
|
|
3588
|
+
*/
|
|
3589
|
+
async getByApiKey(range) {
|
|
3590
|
+
const groups = /* @__PURE__ */ new Map();
|
|
3591
|
+
for (const row of this.readRows(range)) {
|
|
3592
|
+
const key = row.apiKeyId;
|
|
3593
|
+
let g = groups.get(key);
|
|
3594
|
+
if (!g) {
|
|
3595
|
+
g = {
|
|
3596
|
+
apiKeyId: key,
|
|
3597
|
+
label: key ?? "unattributed",
|
|
3598
|
+
providerId: key === null ? null : row.providerId,
|
|
3599
|
+
eventCount: 0,
|
|
3600
|
+
inputTokens: 0,
|
|
3601
|
+
outputTokens: 0,
|
|
3602
|
+
costUsd: 0
|
|
3603
|
+
};
|
|
3604
|
+
groups.set(key, g);
|
|
3605
|
+
}
|
|
3606
|
+
g.eventCount += 1;
|
|
3607
|
+
g.inputTokens += row.inputTokens;
|
|
3608
|
+
g.outputTokens += row.outputTokens;
|
|
3609
|
+
g.costUsd += row.costUsd;
|
|
3610
|
+
}
|
|
3611
|
+
return Array.from(groups.values());
|
|
3612
|
+
}
|
|
3613
|
+
async getMessagesForSession(sessionId) {
|
|
3614
|
+
return this.readAllRows().filter((r) => r.sessionId === sessionId).sort((a, b) => a.ts - b.ts).map((r) => ({
|
|
3615
|
+
id: r.id,
|
|
3616
|
+
ts: r.ts,
|
|
3617
|
+
messageId: r.messageId,
|
|
3618
|
+
parentMessageId: r.parentMessageId,
|
|
3619
|
+
sessionId: r.sessionId,
|
|
3620
|
+
providerId: r.providerId,
|
|
3621
|
+
model: r.model,
|
|
3622
|
+
apiKeyId: r.apiKeyId,
|
|
3623
|
+
engineOrigin: r.engineOrigin,
|
|
3624
|
+
inputTokens: r.inputTokens,
|
|
3625
|
+
outputTokens: r.outputTokens,
|
|
3626
|
+
cacheReadTokens: r.cacheReadTokens,
|
|
3627
|
+
cacheCreationTokens: r.cacheCreationTokens,
|
|
3628
|
+
reasoningTokens: r.reasoningTokens,
|
|
3629
|
+
costUsd: r.costUsd,
|
|
3630
|
+
costSavedByCacheUsd: r.costSavedByCacheUsd
|
|
3631
|
+
}));
|
|
3632
|
+
}
|
|
3633
|
+
async getSessionCacheStats(sessionId) {
|
|
3634
|
+
const stats = {
|
|
3635
|
+
sessionId,
|
|
3636
|
+
inputTokens: 0,
|
|
3637
|
+
cacheReadTokens: 0,
|
|
3638
|
+
cacheCreationTokens: 0,
|
|
3639
|
+
outputTokens: 0,
|
|
3640
|
+
eventCount: 0,
|
|
3641
|
+
hitRate: 0
|
|
3642
|
+
};
|
|
3643
|
+
for (const r of this.readAllRows()) {
|
|
3644
|
+
if (r.sessionId !== sessionId) continue;
|
|
3645
|
+
stats.inputTokens += r.inputTokens;
|
|
3646
|
+
stats.cacheReadTokens += r.cacheReadTokens;
|
|
3647
|
+
stats.cacheCreationTokens += r.cacheCreationTokens;
|
|
3648
|
+
stats.outputTokens += r.outputTokens;
|
|
3649
|
+
stats.eventCount += 1;
|
|
3650
|
+
}
|
|
3651
|
+
const promptSide = stats.inputTokens + stats.cacheReadTokens + stats.cacheCreationTokens;
|
|
3652
|
+
stats.hitRate = promptSide > 0 ? stats.cacheReadTokens / promptSide : 0;
|
|
3653
|
+
return stats;
|
|
3654
|
+
}
|
|
3655
|
+
/** Rows inside `startTs <= ts < endTs` (endTs EXCLUSIVE). */
|
|
3656
|
+
readRows(range) {
|
|
3657
|
+
return this.readAllRows().filter((r) => r.ts >= range.startTs && r.ts < range.endTs);
|
|
3658
|
+
}
|
|
3659
|
+
/** Parse every line, skipping malformed/torn lines defensively. */
|
|
3660
|
+
readAllRows() {
|
|
3661
|
+
if (!existsSync5(this.eventsPath)) return [];
|
|
3662
|
+
let raw;
|
|
3663
|
+
try {
|
|
3664
|
+
raw = readFileSync6(this.eventsPath, "utf8");
|
|
3665
|
+
} catch {
|
|
3666
|
+
return [];
|
|
3667
|
+
}
|
|
3668
|
+
const rows = [];
|
|
3669
|
+
for (const line of raw.split("\n")) {
|
|
3670
|
+
const trimmed = line.trim();
|
|
3671
|
+
if (!trimmed) continue;
|
|
3672
|
+
try {
|
|
3673
|
+
const parsed = JSON.parse(trimmed);
|
|
3674
|
+
if (isUsageEventRecord(parsed)) rows.push(parsed);
|
|
3675
|
+
} catch {
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
return rows;
|
|
3679
|
+
}
|
|
3680
|
+
};
|
|
3681
|
+
var NUMERIC_FIELDS = [
|
|
3682
|
+
"ts",
|
|
3683
|
+
"inputTokens",
|
|
3684
|
+
"outputTokens",
|
|
3685
|
+
"cacheReadTokens",
|
|
3686
|
+
"cacheCreationTokens",
|
|
3687
|
+
"reasoningTokens",
|
|
3688
|
+
"costUsd",
|
|
3689
|
+
"costSavedByCacheUsd"
|
|
3690
|
+
];
|
|
3691
|
+
var NULLABLE_STRING_FIELDS = ["messageId", "parentMessageId", "sessionId", "apiKeyId"];
|
|
3692
|
+
var isStringOrNull = (v) => v === null || typeof v === "string";
|
|
3693
|
+
function isUsageEventRecord(parsed) {
|
|
3694
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
|
|
3695
|
+
const r = parsed;
|
|
3696
|
+
if (typeof r["id"] !== "string") return false;
|
|
3697
|
+
if (typeof r["providerId"] !== "string") return false;
|
|
3698
|
+
if (typeof r["model"] !== "string") return false;
|
|
3699
|
+
if (typeof r["engineOrigin"] !== "string") return false;
|
|
3700
|
+
for (const f of NULLABLE_STRING_FIELDS) {
|
|
3701
|
+
if (!isStringOrNull(r[f])) return false;
|
|
3702
|
+
}
|
|
3703
|
+
for (const f of NUMERIC_FIELDS) {
|
|
3704
|
+
const v = r[f];
|
|
3705
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return false;
|
|
3706
|
+
}
|
|
3707
|
+
return true;
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3710
|
+
// src/ports/JsonPricingStore.ts
|
|
3711
|
+
import { existsSync as existsSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
|
|
3712
|
+
var JsonPricingStore = class {
|
|
3713
|
+
constructor(pricingPath) {
|
|
3714
|
+
this.pricingPath = pricingPath;
|
|
3715
|
+
}
|
|
3716
|
+
pricingPath;
|
|
3717
|
+
async getAll() {
|
|
3718
|
+
return this.readRows();
|
|
3719
|
+
}
|
|
3720
|
+
/**
|
|
3721
|
+
* Insert or update one row keyed (providerId, modelId). `asUserEdit` stamps
|
|
3722
|
+
* user provenance (source 'user', userEdited, editedAt now) so the row is
|
|
3723
|
+
* protected from auto-overwrite during source refreshes; a non-user upsert
|
|
3724
|
+
* stamps source 'litellm' and clears nothing it should not (a plain source
|
|
3725
|
+
* upsert through this method overwrites the row wholesale).
|
|
3726
|
+
*/
|
|
3727
|
+
async upsert(input, asUserEdit) {
|
|
3728
|
+
const rows = this.readRows();
|
|
3729
|
+
const entry = this.applyUpsert(rows, input, asUserEdit);
|
|
3730
|
+
this.writeRows(rows);
|
|
3731
|
+
return entry;
|
|
3732
|
+
}
|
|
3733
|
+
/**
|
|
3734
|
+
* Apply a batch fetched from a pricing source. Rows whose local copy is
|
|
3735
|
+
* user-edited are NOT applied — they come back as `{ current, incoming }`
|
|
3736
|
+
* conflicts; everything else is upserted (source 'litellm'). ONE file write
|
|
3737
|
+
* for the whole batch.
|
|
3738
|
+
*/
|
|
3739
|
+
async bulkApplyFromSource(entries) {
|
|
3740
|
+
const rows = this.readRows();
|
|
3741
|
+
const applied = [];
|
|
3742
|
+
const conflicts = [];
|
|
3743
|
+
for (const incoming of entries) {
|
|
3744
|
+
const current = rows.find(
|
|
3745
|
+
(r) => r.providerId === incoming.providerId && r.modelId === incoming.modelId
|
|
3746
|
+
);
|
|
3747
|
+
if (current && current.userEdited) {
|
|
3748
|
+
conflicts.push({ current, incoming });
|
|
3749
|
+
continue;
|
|
3750
|
+
}
|
|
3751
|
+
applied.push(this.applyUpsert(
|
|
3752
|
+
rows,
|
|
3753
|
+
incoming,
|
|
3754
|
+
/* asUserEdit */
|
|
3755
|
+
false
|
|
3756
|
+
));
|
|
3757
|
+
}
|
|
3758
|
+
if (applied.length > 0) this.writeRows(rows);
|
|
3759
|
+
return { applied, conflicts };
|
|
3760
|
+
}
|
|
3761
|
+
/**
|
|
3762
|
+
* Apply per-row conflict decisions: 'overwrite' replaces the local row with
|
|
3763
|
+
* the incoming values (clearing the user-edited mark), 'skip' counts only.
|
|
3764
|
+
*/
|
|
3765
|
+
async applyResolutions(resolutions) {
|
|
3766
|
+
const rows = this.readRows();
|
|
3767
|
+
let overwrittenCount = 0;
|
|
3768
|
+
let skippedCount = 0;
|
|
3769
|
+
for (const r of resolutions) {
|
|
3770
|
+
if (r.action === "skip") {
|
|
3771
|
+
skippedCount += 1;
|
|
3772
|
+
continue;
|
|
3773
|
+
}
|
|
3774
|
+
this.applyUpsert(
|
|
3775
|
+
rows,
|
|
3776
|
+
r.incoming,
|
|
3777
|
+
/* asUserEdit */
|
|
3778
|
+
false
|
|
3779
|
+
);
|
|
3780
|
+
overwrittenCount += 1;
|
|
3781
|
+
}
|
|
3782
|
+
if (overwrittenCount > 0) this.writeRows(rows);
|
|
3783
|
+
return { overwrittenCount, skippedCount };
|
|
3784
|
+
}
|
|
3785
|
+
/**
|
|
3786
|
+
* STORE-LOCAL (not on the core port): remove one row. Returns whether a row
|
|
3787
|
+
* was actually removed. The admin DELETE handler calls this then invalidates
|
|
3788
|
+
* the engine cache.
|
|
3789
|
+
*/
|
|
3790
|
+
async delete(providerId, modelId) {
|
|
3791
|
+
const rows = this.readRows();
|
|
3792
|
+
const idx = rows.findIndex((r) => r.providerId === providerId && r.modelId === modelId);
|
|
3793
|
+
if (idx < 0) return false;
|
|
3794
|
+
rows.splice(idx, 1);
|
|
3795
|
+
this.writeRows(rows);
|
|
3796
|
+
return true;
|
|
3797
|
+
}
|
|
3798
|
+
/** Upsert into `rows` IN PLACE (no write) and return the resulting entry. */
|
|
3799
|
+
applyUpsert(rows, input, asUserEdit) {
|
|
3800
|
+
const now = Date.now();
|
|
3801
|
+
const entry = {
|
|
3802
|
+
providerId: input.providerId,
|
|
3803
|
+
modelId: input.modelId,
|
|
3804
|
+
inputPricePer1m: input.inputPricePer1m,
|
|
3805
|
+
outputPricePer1m: input.outputPricePer1m,
|
|
3806
|
+
cacheReadPricePer1m: input.cacheReadPricePer1m ?? null,
|
|
3807
|
+
cacheWritePricePer1m: input.cacheWritePricePer1m ?? null,
|
|
3808
|
+
source: asUserEdit ? "user" : "litellm",
|
|
3809
|
+
userEdited: asUserEdit,
|
|
3810
|
+
editedAt: asUserEdit ? now : null,
|
|
3811
|
+
updatedAt: now
|
|
3812
|
+
};
|
|
3813
|
+
const idx = rows.findIndex(
|
|
3814
|
+
(r) => r.providerId === input.providerId && r.modelId === input.modelId
|
|
3815
|
+
);
|
|
3816
|
+
if (idx >= 0) rows[idx] = entry;
|
|
3817
|
+
else rows.push(entry);
|
|
3818
|
+
return entry;
|
|
3819
|
+
}
|
|
3820
|
+
/** Read the pricing rows, tolerating a missing/corrupt file (→ empty list). */
|
|
3821
|
+
readRows() {
|
|
3822
|
+
if (!existsSync6(this.pricingPath)) return [];
|
|
3823
|
+
try {
|
|
3824
|
+
const parsed = JSON.parse(readFileSync7(this.pricingPath, "utf8"));
|
|
3825
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
3826
|
+
} catch {
|
|
3827
|
+
return [];
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
writeRows(rows) {
|
|
3831
|
+
writeFileSync5(this.pricingPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
|
|
3832
|
+
}
|
|
3833
|
+
};
|
|
3834
|
+
|
|
3345
3835
|
// src/ports/JsonSubscriptionCredentialStore.ts
|
|
3346
|
-
import { existsSync as
|
|
3347
|
-
import { dirname as
|
|
3836
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "fs";
|
|
3837
|
+
import { dirname as dirname4 } from "path";
|
|
3348
3838
|
import {
|
|
3349
3839
|
claudeOAuth as claudeOAuth2,
|
|
3350
3840
|
codexOAuth as codexOAuth2,
|
|
3351
3841
|
geminiOAuth as geminiOAuth2
|
|
3352
3842
|
} from "@omnicross/subscriptions";
|
|
3843
|
+
|
|
3844
|
+
// src/ports/account-sync.ts
|
|
3845
|
+
var IMPORT_EXPIRY_MARGIN_MS = 6e4;
|
|
3846
|
+
function viewOf(tokens) {
|
|
3847
|
+
return tokens;
|
|
3848
|
+
}
|
|
3849
|
+
function decideExternalImport(captured, external, now = Date.now()) {
|
|
3850
|
+
if (!external?.accessToken) return "no-credential";
|
|
3851
|
+
const capturedRt = viewOf(captured).refreshToken;
|
|
3852
|
+
const hasNewRefresh = Boolean(external.refreshToken && external.refreshToken !== capturedRt);
|
|
3853
|
+
const accessStillValid = external.expiresAt ? Date.parse(external.expiresAt) > now + IMPORT_EXPIRY_MARGIN_MS : true;
|
|
3854
|
+
return hasNewRefresh || accessStillValid ? "import" : "not-rotated";
|
|
3855
|
+
}
|
|
3856
|
+
function buildImportedTokens(captured, external) {
|
|
3857
|
+
const imported = {
|
|
3858
|
+
...captured,
|
|
3859
|
+
accessToken: external.accessToken,
|
|
3860
|
+
status: "authorized",
|
|
3861
|
+
errorMessage: void 0,
|
|
3862
|
+
syncWarning: void 0,
|
|
3863
|
+
lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3864
|
+
};
|
|
3865
|
+
if (external.refreshToken) imported.refreshToken = external.refreshToken;
|
|
3866
|
+
if (external.expiresAt) imported.expiresAt = external.expiresAt;
|
|
3867
|
+
else delete imported.expiresAt;
|
|
3868
|
+
if (external.idToken) imported.idToken = external.idToken;
|
|
3869
|
+
if (external.scopes) imported.scopes = external.scopes;
|
|
3870
|
+
return imported;
|
|
3871
|
+
}
|
|
3872
|
+
function buildTokensFromExternal(provider, external) {
|
|
3873
|
+
const base = {
|
|
3874
|
+
authMethod: "oauth",
|
|
3875
|
+
status: "authorized",
|
|
3876
|
+
accessToken: external.accessToken,
|
|
3877
|
+
lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3878
|
+
};
|
|
3879
|
+
if (provider === "claude") {
|
|
3880
|
+
const tokens2 = { ...base };
|
|
3881
|
+
if (external.refreshToken) tokens2.refreshToken = external.refreshToken;
|
|
3882
|
+
if (external.expiresAt) tokens2.expiresAt = external.expiresAt;
|
|
3883
|
+
if (external.scopes) tokens2.scopes = external.scopes;
|
|
3884
|
+
return tokens2;
|
|
3885
|
+
}
|
|
3886
|
+
const tokens = { ...base };
|
|
3887
|
+
if (external.refreshToken) tokens.refreshToken = external.refreshToken;
|
|
3888
|
+
if (external.expiresAt) tokens.expiresAt = external.expiresAt;
|
|
3889
|
+
if (external.idToken) tokens.idToken = external.idToken;
|
|
3890
|
+
return tokens;
|
|
3891
|
+
}
|
|
3892
|
+
function isExternalDivergent(stored, external) {
|
|
3893
|
+
if (!external?.accessToken || !external.refreshToken) return false;
|
|
3894
|
+
const view = viewOf(stored);
|
|
3895
|
+
if (!view.refreshToken || external.refreshToken === view.refreshToken) return false;
|
|
3896
|
+
const storedExp = view.expiresAt ? Date.parse(view.expiresAt) : NaN;
|
|
3897
|
+
const externalExp = external.expiresAt ? Date.parse(external.expiresAt) : Infinity;
|
|
3898
|
+
return !Number.isFinite(storedExp) || externalExp > storedExp;
|
|
3899
|
+
}
|
|
3900
|
+
function findDuplicateCredentialIds(accounts) {
|
|
3901
|
+
const byCredential = /* @__PURE__ */ new Map();
|
|
3902
|
+
for (const account of accounts) {
|
|
3903
|
+
const view = viewOf(account.tokens);
|
|
3904
|
+
const credential = view.refreshToken ?? view.apiKey ?? view.accessToken;
|
|
3905
|
+
if (!credential) continue;
|
|
3906
|
+
const ids = byCredential.get(credential) ?? [];
|
|
3907
|
+
ids.push(account.id);
|
|
3908
|
+
byCredential.set(credential, ids);
|
|
3909
|
+
}
|
|
3910
|
+
const duplicates = /* @__PURE__ */ new Set();
|
|
3911
|
+
for (const ids of byCredential.values()) {
|
|
3912
|
+
if (ids.length > 1) for (const id of ids) duplicates.add(id);
|
|
3913
|
+
}
|
|
3914
|
+
return duplicates;
|
|
3915
|
+
}
|
|
3916
|
+
|
|
3917
|
+
// src/ports/external-cli-credentials.ts
|
|
3918
|
+
import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
|
|
3919
|
+
import { homedir as homedir2 } from "os";
|
|
3920
|
+
import { join as join4 } from "path";
|
|
3921
|
+
function externalStorePath(provider, home = homedir2()) {
|
|
3922
|
+
return provider === "claude" ? join4(home, ".claude", ".credentials.json") : join4(home, ".codex", "auth.json");
|
|
3923
|
+
}
|
|
3924
|
+
function decodeJwtExpiryMs(token) {
|
|
3925
|
+
try {
|
|
3926
|
+
const payload = token.split(".")[1];
|
|
3927
|
+
if (!payload) return void 0;
|
|
3928
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
3929
|
+
if (typeof decoded.exp === "number" && Number.isFinite(decoded.exp)) {
|
|
3930
|
+
return decoded.exp * 1e3;
|
|
3931
|
+
}
|
|
3932
|
+
} catch {
|
|
3933
|
+
}
|
|
3934
|
+
return void 0;
|
|
3935
|
+
}
|
|
3936
|
+
function parseClaudeOAuthEnvelope(raw) {
|
|
3937
|
+
const oauth = raw?.claudeAiOauth;
|
|
3938
|
+
if (!oauth || typeof oauth.accessToken !== "string" || !oauth.accessToken) return null;
|
|
3939
|
+
const parsed = { accessToken: oauth.accessToken };
|
|
3940
|
+
if (typeof oauth.refreshToken === "string" && oauth.refreshToken) {
|
|
3941
|
+
parsed.refreshToken = oauth.refreshToken;
|
|
3942
|
+
}
|
|
3943
|
+
if (typeof oauth.expiresAt === "number" && Number.isFinite(oauth.expiresAt)) {
|
|
3944
|
+
parsed.expiresAt = new Date(oauth.expiresAt).toISOString();
|
|
3945
|
+
}
|
|
3946
|
+
if (Array.isArray(oauth.scopes) && oauth.scopes.every((s) => typeof s === "string")) {
|
|
3947
|
+
parsed.scopes = oauth.scopes;
|
|
3948
|
+
}
|
|
3949
|
+
return parsed;
|
|
3950
|
+
}
|
|
3951
|
+
function parseCodexTokensEnvelope(raw) {
|
|
3952
|
+
const tokens = raw?.tokens;
|
|
3953
|
+
if (!tokens) return null;
|
|
3954
|
+
const accessToken = typeof tokens.access_token === "string" && tokens.access_token ? tokens.access_token : void 0;
|
|
3955
|
+
const idToken = typeof tokens.id_token === "string" && tokens.id_token ? tokens.id_token : void 0;
|
|
3956
|
+
if (!accessToken && !idToken) return null;
|
|
3957
|
+
const parsed = {};
|
|
3958
|
+
if (accessToken) {
|
|
3959
|
+
parsed.accessToken = accessToken;
|
|
3960
|
+
const expMs = decodeJwtExpiryMs(accessToken);
|
|
3961
|
+
if (expMs !== void 0) parsed.expiresAt = new Date(expMs).toISOString();
|
|
3962
|
+
}
|
|
3963
|
+
if (idToken) parsed.idToken = idToken;
|
|
3964
|
+
if (typeof tokens.refresh_token === "string" && tokens.refresh_token) {
|
|
3965
|
+
parsed.refreshToken = tokens.refresh_token;
|
|
3966
|
+
}
|
|
3967
|
+
return parsed;
|
|
3968
|
+
}
|
|
3969
|
+
function readExternalCliCredentials(provider, home = homedir2()) {
|
|
3970
|
+
const path2 = externalStorePath(provider, home);
|
|
3971
|
+
if (!existsSync7(path2)) return null;
|
|
3972
|
+
let raw;
|
|
3973
|
+
try {
|
|
3974
|
+
const parsed = JSON.parse(readFileSync8(path2, "utf8"));
|
|
3975
|
+
raw = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
|
|
3976
|
+
} catch {
|
|
3977
|
+
return null;
|
|
3978
|
+
}
|
|
3979
|
+
return provider === "claude" ? parseClaudeOAuthEnvelope(raw) : parseCodexTokensEnvelope(raw);
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
// src/ports/external-cli-store.ts
|
|
3983
|
+
import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync2, readFileSync as readFileSync9, renameSync, writeFileSync as writeFileSync6 } from "fs";
|
|
3984
|
+
import { homedir as homedir3 } from "os";
|
|
3985
|
+
import { dirname as dirname3 } from "path";
|
|
3986
|
+
function markerPath(provider, home) {
|
|
3987
|
+
return `${externalStorePath(provider, home)}.omnicross-managed`;
|
|
3988
|
+
}
|
|
3989
|
+
function backupPath(provider, home) {
|
|
3990
|
+
return `${externalStorePath(provider, home)}.omnicross-backup`;
|
|
3991
|
+
}
|
|
3992
|
+
function buildClaudeOAuthEnvelope(tokens) {
|
|
3993
|
+
if (!tokens.accessToken) return null;
|
|
3994
|
+
const envelope = { accessToken: tokens.accessToken };
|
|
3995
|
+
if (tokens.refreshToken) envelope.refreshToken = tokens.refreshToken;
|
|
3996
|
+
if (tokens.expiresAt) {
|
|
3997
|
+
const ms = Date.parse(tokens.expiresAt);
|
|
3998
|
+
if (Number.isFinite(ms)) envelope.expiresAt = ms;
|
|
3999
|
+
}
|
|
4000
|
+
if (tokens.scopes && tokens.scopes.length > 0) envelope.scopes = tokens.scopes;
|
|
4001
|
+
return envelope;
|
|
4002
|
+
}
|
|
4003
|
+
function buildCodexTokensEnvelope(tokens) {
|
|
4004
|
+
if (!tokens.accessToken && !tokens.idToken) return null;
|
|
4005
|
+
const envelope = { access_token: tokens.accessToken ?? "" };
|
|
4006
|
+
if (tokens.idToken) envelope.id_token = tokens.idToken;
|
|
4007
|
+
if (tokens.refreshToken) envelope.refresh_token = tokens.refreshToken;
|
|
4008
|
+
return envelope;
|
|
4009
|
+
}
|
|
4010
|
+
function readExistingObject(path2) {
|
|
4011
|
+
if (!existsSync8(path2)) return {};
|
|
4012
|
+
try {
|
|
4013
|
+
const parsed = JSON.parse(readFileSync9(path2, "utf8"));
|
|
4014
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
4015
|
+
} catch {
|
|
4016
|
+
return {};
|
|
4017
|
+
}
|
|
4018
|
+
}
|
|
4019
|
+
function writeAtomic(path2, content) {
|
|
4020
|
+
mkdirSync2(dirname3(path2), { recursive: true });
|
|
4021
|
+
const temp = `${path2}.omnicross-tmp`;
|
|
4022
|
+
writeFileSync6(temp, content, "utf8");
|
|
4023
|
+
renameSync(temp, path2);
|
|
4024
|
+
}
|
|
4025
|
+
function createExternalCliStore(home = homedir3()) {
|
|
4026
|
+
return {
|
|
4027
|
+
readMarkerAccountId(provider) {
|
|
4028
|
+
const path2 = markerPath(provider, home);
|
|
4029
|
+
if (!existsSync8(path2)) return void 0;
|
|
4030
|
+
try {
|
|
4031
|
+
const parsed = JSON.parse(readFileSync9(path2, "utf8"));
|
|
4032
|
+
return typeof parsed.accountId === "string" && parsed.accountId ? parsed.accountId : void 0;
|
|
4033
|
+
} catch {
|
|
4034
|
+
return void 0;
|
|
4035
|
+
}
|
|
4036
|
+
},
|
|
4037
|
+
writeMarker(provider, accountId) {
|
|
4038
|
+
writeAtomic(
|
|
4039
|
+
markerPath(provider, home),
|
|
4040
|
+
JSON.stringify({ accountId, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n"
|
|
4041
|
+
);
|
|
4042
|
+
},
|
|
4043
|
+
writeBack(provider, accountId, tokens) {
|
|
4044
|
+
const owner = this.readMarkerAccountId(provider);
|
|
4045
|
+
if (owner !== accountId) return false;
|
|
4046
|
+
const envelope = provider === "claude" ? buildClaudeOAuthEnvelope(tokens) : buildCodexTokensEnvelope(tokens);
|
|
4047
|
+
if (!envelope) return false;
|
|
4048
|
+
const storePath = externalStorePath(provider, home);
|
|
4049
|
+
if (existsSync8(storePath) && !existsSync8(backupPath(provider, home))) {
|
|
4050
|
+
copyFileSync(storePath, backupPath(provider, home));
|
|
4051
|
+
}
|
|
4052
|
+
const existing = readExistingObject(storePath);
|
|
4053
|
+
const merged = provider === "claude" ? { ...existing, claudeAiOauth: envelope } : { ...existing, tokens: envelope };
|
|
4054
|
+
writeAtomic(storePath, JSON.stringify(merged, null, 2) + "\n");
|
|
4055
|
+
return true;
|
|
4056
|
+
}
|
|
4057
|
+
};
|
|
4058
|
+
}
|
|
4059
|
+
|
|
4060
|
+
// src/ports/JsonSubscriptionCredentialStore.ts
|
|
3353
4061
|
var JsonSubscriptionCredentialStore = class {
|
|
3354
4062
|
/**
|
|
3355
4063
|
* @param tokensPath on-disk `tokens.json` location.
|
|
@@ -3359,14 +4067,33 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3359
4067
|
* is unchanged; tests inject a mock fetch. NOT used by any
|
|
3360
4068
|
* read/write path — only by `refresh*Token`.
|
|
3361
4069
|
*/
|
|
3362
|
-
constructor(tokensPath, box, fetchImpl = (url, init) => fetch(url, init)) {
|
|
4070
|
+
constructor(tokensPath, box, fetchImpl = (url, init) => fetch(url, init), externalCliReader = readExternalCliCredentials, externalCliStore = createExternalCliStore()) {
|
|
3363
4071
|
this.tokensPath = tokensPath;
|
|
3364
4072
|
this.box = box;
|
|
3365
4073
|
this.fetchImpl = fetchImpl;
|
|
4074
|
+
this.externalCliReader = externalCliReader;
|
|
4075
|
+
this.externalCliStore = externalCliStore;
|
|
3366
4076
|
}
|
|
3367
4077
|
tokensPath;
|
|
3368
4078
|
box;
|
|
3369
4079
|
fetchImpl;
|
|
4080
|
+
externalCliReader;
|
|
4081
|
+
externalCliStore;
|
|
4082
|
+
/**
|
|
4083
|
+
* In-flight refresh coalescing (external-cli-sync). OAuth refresh tokens are
|
|
4084
|
+
* SINGLE-USE: two concurrent refreshes of one account each spend the same
|
|
4085
|
+
* token and the loser bricks a healthy account. Every refresh entry point
|
|
4086
|
+
* (auth-strategy lazy refresh, 401 retry, background scheduler) funnels
|
|
4087
|
+
* through `coalesce`, so overlapping callers share ONE upstream round-trip.
|
|
4088
|
+
*/
|
|
4089
|
+
inFlightRefreshes = /* @__PURE__ */ new Map();
|
|
4090
|
+
coalesce(key, task) {
|
|
4091
|
+
const existing = this.inFlightRefreshes.get(key);
|
|
4092
|
+
if (existing) return existing;
|
|
4093
|
+
const run = task().finally(() => this.inFlightRefreshes.delete(key));
|
|
4094
|
+
this.inFlightRefreshes.set(key, run);
|
|
4095
|
+
return run;
|
|
4096
|
+
}
|
|
3370
4097
|
/** Full parsed account-tokens config (or a minimal `{ updatedAt }` when the
|
|
3371
4098
|
* file is absent/corrupt). This is the hot read — the codex / gemini auth
|
|
3372
4099
|
* strategies pull `accessToken` / `expiresAt` / `status` from it. */
|
|
@@ -3394,10 +4121,41 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3394
4121
|
const out = {};
|
|
3395
4122
|
for (const provider of ["claude", "codex", "gemini", "opencodego"]) {
|
|
3396
4123
|
const sanitized = sanitizeAccounts(config, provider);
|
|
3397
|
-
if (sanitized.length > 0) out[provider] = sanitized;
|
|
4124
|
+
if (sanitized.length > 0) out[provider] = this.attachSyncWarnings(config, provider, sanitized);
|
|
3398
4125
|
}
|
|
3399
4126
|
return out;
|
|
3400
4127
|
}
|
|
4128
|
+
/**
|
|
4129
|
+
* List-time credential-conflict warnings (external-cli-sync). Computed, not
|
|
4130
|
+
* persisted: (a) `duplicate-token` when two accounts of one provider share a
|
|
4131
|
+
* credential, (b) `external-divergent` when the external CLI native store has
|
|
4132
|
+
* rotated PAST the ACTIVE account (claude/codex only). A warning persisted by
|
|
4133
|
+
* a failed refresh (`external-not-rotated`) takes precedence — it is the most
|
|
4134
|
+
* actionable state.
|
|
4135
|
+
*/
|
|
4136
|
+
attachSyncWarnings(config, provider, sanitized) {
|
|
4137
|
+
const duplicates = findDuplicateCredentialIds(listAccounts(config, provider));
|
|
4138
|
+
let divergentId;
|
|
4139
|
+
if (provider === "claude" || provider === "codex") {
|
|
4140
|
+
const active = getActiveAccount(config, provider);
|
|
4141
|
+
if (active && isExternalDivergent(active.tokens, this.safeReadExternal(provider))) {
|
|
4142
|
+
divergentId = active.id;
|
|
4143
|
+
}
|
|
4144
|
+
}
|
|
4145
|
+
if (duplicates.size === 0 && !divergentId) return sanitized;
|
|
4146
|
+
return sanitized.map((account) => {
|
|
4147
|
+
const computed = account.id === divergentId ? "external-divergent" : duplicates.has(account.id) ? "duplicate-token" : void 0;
|
|
4148
|
+
return { ...account, syncWarning: account.syncWarning ?? computed };
|
|
4149
|
+
});
|
|
4150
|
+
}
|
|
4151
|
+
/** Read the external CLI store, never letting an fs/parse error escape. */
|
|
4152
|
+
safeReadExternal(provider) {
|
|
4153
|
+
try {
|
|
4154
|
+
return this.externalCliReader(provider);
|
|
4155
|
+
} catch {
|
|
4156
|
+
return null;
|
|
4157
|
+
}
|
|
4158
|
+
}
|
|
3401
4159
|
/**
|
|
3402
4160
|
* Refresh the Claude OAuth access token (oauth design D4). HONEST `false` when
|
|
3403
4161
|
* the block has no refresh_token (setup-token / manual) — no upstream call, the
|
|
@@ -3407,30 +4165,44 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3407
4165
|
* errorMessage → `false`.
|
|
3408
4166
|
*/
|
|
3409
4167
|
async refreshClaudeToken() {
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
4168
|
+
return this.coalesce("claude:active", async () => {
|
|
4169
|
+
const config = this.readConfig();
|
|
4170
|
+
const active = getActiveAccount(config, "claude");
|
|
4171
|
+
const claude = active?.tokens;
|
|
4172
|
+
if (!active || !claude?.refreshToken) return false;
|
|
4173
|
+
const capturedId = active.id;
|
|
4174
|
+
this.materializeMigration(config);
|
|
4175
|
+
try {
|
|
4176
|
+
const result = await claudeOAuth2.refreshAccessToken(claude.refreshToken, this.fetchImpl);
|
|
4177
|
+
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
4178
|
+
const next = {
|
|
4179
|
+
...claude,
|
|
4180
|
+
accessToken: result.accessToken,
|
|
4181
|
+
refreshToken: result.refreshToken,
|
|
4182
|
+
expiresAt,
|
|
4183
|
+
status: "authorized",
|
|
4184
|
+
lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4185
|
+
errorMessage: void 0,
|
|
4186
|
+
syncWarning: void 0
|
|
4187
|
+
};
|
|
4188
|
+
this.writeBackById("claude", capturedId, next);
|
|
4189
|
+
this.resyncExternal("claude", capturedId, next);
|
|
4190
|
+
return true;
|
|
4191
|
+
} catch (error) {
|
|
4192
|
+
if (await this.tryExternalImport("claude", capturedId, claude, async (rt) => {
|
|
4193
|
+
const r = await claudeOAuth2.refreshAccessToken(rt, this.fetchImpl);
|
|
4194
|
+
return {
|
|
4195
|
+
accessToken: r.accessToken,
|
|
4196
|
+
refreshToken: r.refreshToken,
|
|
4197
|
+
expiresAt: new Date(Date.now() + r.expiresIn * 1e3).toISOString()
|
|
4198
|
+
};
|
|
4199
|
+
})) {
|
|
4200
|
+
return true;
|
|
4201
|
+
}
|
|
4202
|
+
this.markExpiredById("claude", capturedId, claude, error);
|
|
4203
|
+
return false;
|
|
4204
|
+
}
|
|
4205
|
+
});
|
|
3434
4206
|
}
|
|
3435
4207
|
/**
|
|
3436
4208
|
* Refresh the Codex (ChatGPT) OAuth access token. Same shape
|
|
@@ -3438,31 +4210,46 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3438
4210
|
* HONEST `false` when no refresh_token.
|
|
3439
4211
|
*/
|
|
3440
4212
|
async refreshCodexToken() {
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
4213
|
+
return this.coalesce("codex:active", async () => {
|
|
4214
|
+
const config = this.readConfig();
|
|
4215
|
+
const active = getActiveAccount(config, "codex");
|
|
4216
|
+
const codex = active?.tokens;
|
|
4217
|
+
if (!active || !codex?.refreshToken) return false;
|
|
4218
|
+
const capturedId = active.id;
|
|
4219
|
+
this.materializeMigration(config);
|
|
4220
|
+
try {
|
|
4221
|
+
const result = await codexOAuth2.refreshAccessToken(codex.refreshToken, this.fetchImpl);
|
|
4222
|
+
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
4223
|
+
const next = {
|
|
4224
|
+
...codex,
|
|
4225
|
+
accessToken: result.accessToken,
|
|
4226
|
+
refreshToken: result.refreshToken,
|
|
4227
|
+
idToken: result.idToken,
|
|
4228
|
+
expiresAt,
|
|
4229
|
+
status: "authorized",
|
|
4230
|
+
lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4231
|
+
errorMessage: void 0,
|
|
4232
|
+
syncWarning: void 0
|
|
4233
|
+
};
|
|
4234
|
+
this.writeBackById("codex", capturedId, next);
|
|
4235
|
+
this.resyncExternal("codex", capturedId, next);
|
|
4236
|
+
return true;
|
|
4237
|
+
} catch (error) {
|
|
4238
|
+
if (await this.tryExternalImport("codex", capturedId, codex, async (rt) => {
|
|
4239
|
+
const r = await codexOAuth2.refreshAccessToken(rt, this.fetchImpl);
|
|
4240
|
+
return {
|
|
4241
|
+
accessToken: r.accessToken,
|
|
4242
|
+
refreshToken: r.refreshToken,
|
|
4243
|
+
idToken: r.idToken,
|
|
4244
|
+
expiresAt: new Date(Date.now() + r.expiresIn * 1e3).toISOString()
|
|
4245
|
+
};
|
|
4246
|
+
})) {
|
|
4247
|
+
return true;
|
|
4248
|
+
}
|
|
4249
|
+
this.markExpiredById("codex", capturedId, codex, error);
|
|
4250
|
+
return false;
|
|
4251
|
+
}
|
|
4252
|
+
});
|
|
3466
4253
|
}
|
|
3467
4254
|
/**
|
|
3468
4255
|
* Refresh the Gemini (Google) OAuth access token. The Google
|
|
@@ -3472,30 +4259,175 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3472
4259
|
* destroy the ability to refresh again). HONEST `false` when no refresh_token.
|
|
3473
4260
|
*/
|
|
3474
4261
|
async refreshGeminiToken() {
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
4262
|
+
return this.coalesce("gemini:active", async () => {
|
|
4263
|
+
const config = this.readConfig();
|
|
4264
|
+
const active = getActiveAccount(config, "gemini");
|
|
4265
|
+
const gemini = active?.tokens;
|
|
4266
|
+
if (!active || !gemini?.refreshToken) return false;
|
|
4267
|
+
const capturedId = active.id;
|
|
4268
|
+
this.materializeMigration(config);
|
|
4269
|
+
try {
|
|
4270
|
+
const result = await geminiOAuth2.refreshAccessToken(gemini.refreshToken, this.fetchImpl);
|
|
4271
|
+
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
4272
|
+
const next = {
|
|
4273
|
+
...gemini,
|
|
4274
|
+
// KEEP the existing refreshToken (response omits it).
|
|
4275
|
+
accessToken: result.accessToken,
|
|
4276
|
+
expiresAt,
|
|
4277
|
+
status: "authorized",
|
|
4278
|
+
lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4279
|
+
errorMessage: void 0
|
|
4280
|
+
};
|
|
4281
|
+
this.writeBackById("gemini", capturedId, next);
|
|
4282
|
+
return true;
|
|
4283
|
+
} catch (error) {
|
|
4284
|
+
this.markExpiredById("gemini", capturedId, gemini, error);
|
|
4285
|
+
return false;
|
|
4286
|
+
}
|
|
4287
|
+
});
|
|
4288
|
+
}
|
|
4289
|
+
/**
|
|
4290
|
+
* Refresh a SPECIFIC account by id (background scheduler sweep,
|
|
4291
|
+
* external-cli-sync). Unlike the active-account refreshers it does NOT
|
|
4292
|
+
* attempt the external-import fallback — the external CLI file's lineage can
|
|
4293
|
+
* only plausibly match the ACTIVE account. Coalesced per `provider:id`; on
|
|
4294
|
+
* failure flags ONLY that account `expired`.
|
|
4295
|
+
*/
|
|
4296
|
+
async refreshAccountById(provider, id) {
|
|
4297
|
+
return this.coalesce(`${provider}:${id}`, async () => {
|
|
4298
|
+
const config = this.readConfig();
|
|
4299
|
+
const account = getAccountById(config, provider, id);
|
|
4300
|
+
const captured = account?.tokens;
|
|
4301
|
+
if (!account || !captured?.refreshToken) return false;
|
|
4302
|
+
this.materializeMigration(config);
|
|
4303
|
+
try {
|
|
4304
|
+
const refreshed = await this.refreshUpstream(provider, captured.refreshToken);
|
|
4305
|
+
const next = {
|
|
4306
|
+
...captured,
|
|
4307
|
+
accessToken: refreshed.accessToken,
|
|
4308
|
+
// Gemini's refresh response omits a new refresh token — keep the captured.
|
|
4309
|
+
refreshToken: refreshed.refreshToken ?? captured.refreshToken,
|
|
4310
|
+
expiresAt: refreshed.expiresAt,
|
|
4311
|
+
status: "authorized",
|
|
4312
|
+
lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4313
|
+
errorMessage: void 0,
|
|
4314
|
+
syncWarning: void 0
|
|
4315
|
+
};
|
|
4316
|
+
if (refreshed.idToken) next.idToken = refreshed.idToken;
|
|
4317
|
+
this.writeBackById(provider, id, next);
|
|
4318
|
+
if (provider !== "gemini") this.resyncExternal(provider, id, next);
|
|
4319
|
+
return true;
|
|
4320
|
+
} catch (error) {
|
|
4321
|
+
this.markExpiredById(provider, id, captured, error);
|
|
4322
|
+
return false;
|
|
4323
|
+
}
|
|
4324
|
+
});
|
|
4325
|
+
}
|
|
4326
|
+
/** Dispatch one OAuth refresh round-trip to the provider's shared flow. */
|
|
4327
|
+
async refreshUpstream(provider, refreshToken) {
|
|
4328
|
+
const flow = provider === "claude" ? claudeOAuth2 : provider === "codex" ? codexOAuth2 : geminiOAuth2;
|
|
4329
|
+
const r = await flow.refreshAccessToken(refreshToken, this.fetchImpl);
|
|
4330
|
+
return {
|
|
4331
|
+
accessToken: r.accessToken,
|
|
4332
|
+
refreshToken: r.refreshToken,
|
|
4333
|
+
idToken: r.idToken,
|
|
4334
|
+
expiresAt: new Date(Date.now() + r.expiresIn * 1e3).toISOString()
|
|
4335
|
+
};
|
|
4336
|
+
}
|
|
4337
|
+
/**
|
|
4338
|
+
* External-import fallback for a FAILED active-account refresh
|
|
4339
|
+
* (external-cli-sync). Reads the CLI native store; imports when the external
|
|
4340
|
+
* lineage ROTATED (different refresh token) or its access token is still
|
|
4341
|
+
* valid. When the imported access token is already expired it refreshes once
|
|
4342
|
+
* with the rotated refresh token. A `not-rotated` outcome persists the
|
|
4343
|
+
* `external-not-rotated` warning on the (about-to-be-expired) account so the
|
|
4344
|
+
* UI can tell "genuine revocation" apart from a plain refresh failure.
|
|
4345
|
+
*/
|
|
4346
|
+
async tryExternalImport(provider, capturedId, captured, refreshWithToken) {
|
|
4347
|
+
const markerOwner = this.safeReadMarker(provider);
|
|
4348
|
+
if (markerOwner && markerOwner !== capturedId) return false;
|
|
4349
|
+
const external = this.safeReadExternal(provider);
|
|
4350
|
+
const decision = decideExternalImport(captured, external);
|
|
4351
|
+
if (decision === "not-rotated") {
|
|
4352
|
+
captured.syncWarning = "external-not-rotated";
|
|
3497
4353
|
return false;
|
|
3498
4354
|
}
|
|
4355
|
+
if (decision !== "import" || !external) return false;
|
|
4356
|
+
let imported = buildImportedTokens(
|
|
4357
|
+
captured,
|
|
4358
|
+
external
|
|
4359
|
+
);
|
|
4360
|
+
const accessStillValid = external.expiresAt ? Date.parse(external.expiresAt) > Date.now() + 6e4 : true;
|
|
4361
|
+
if (!accessStillValid) {
|
|
4362
|
+
try {
|
|
4363
|
+
const refreshed = await refreshWithToken(external.refreshToken);
|
|
4364
|
+
imported = {
|
|
4365
|
+
...imported,
|
|
4366
|
+
accessToken: refreshed.accessToken,
|
|
4367
|
+
refreshToken: refreshed.refreshToken ?? imported.refreshToken,
|
|
4368
|
+
expiresAt: refreshed.expiresAt,
|
|
4369
|
+
lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4370
|
+
};
|
|
4371
|
+
if (refreshed.idToken) imported.idToken = refreshed.idToken;
|
|
4372
|
+
} catch {
|
|
4373
|
+
return false;
|
|
4374
|
+
}
|
|
4375
|
+
}
|
|
4376
|
+
this.writeBackById(provider, capturedId, imported);
|
|
4377
|
+
this.resyncExternal(provider, capturedId, imported);
|
|
4378
|
+
return true;
|
|
4379
|
+
}
|
|
4380
|
+
/**
|
|
4381
|
+
* Marker-gated external write-back (external-cli-sync). After a successful
|
|
4382
|
+
* refresh of the account that OWNS the provider's native CLI store (imported
|
|
4383
|
+
* via `importExternalCliAccount`), push the rotated credential back into the
|
|
4384
|
+
* file — otherwise the daemon's refresh invalidates the single-use refresh
|
|
4385
|
+
* token and silently logs the bare CLI out. NON-FATAL: the internal store is
|
|
4386
|
+
* already persisted; a failed external write only leaves the file stale,
|
|
4387
|
+
* which the `external-divergent` warning surfaces.
|
|
4388
|
+
*/
|
|
4389
|
+
resyncExternal(provider, accountId, tokens) {
|
|
4390
|
+
try {
|
|
4391
|
+
this.externalCliStore.writeBack(provider, accountId, tokens);
|
|
4392
|
+
} catch {
|
|
4393
|
+
}
|
|
4394
|
+
}
|
|
4395
|
+
/** Read the marker's owning account id, never letting an fs error escape. */
|
|
4396
|
+
safeReadMarker(provider) {
|
|
4397
|
+
try {
|
|
4398
|
+
return this.externalCliStore.readMarkerAccountId(provider);
|
|
4399
|
+
} catch {
|
|
4400
|
+
return void 0;
|
|
4401
|
+
}
|
|
4402
|
+
}
|
|
4403
|
+
/**
|
|
4404
|
+
* DAEMON-ONLY (admin import button): which providers have a usable external
|
|
4405
|
+
* CLI credential on THIS machine. Pure detection — reads the native files,
|
|
4406
|
+
* never mutates anything, never returns a token.
|
|
4407
|
+
*/
|
|
4408
|
+
async listExternalCliAvailability() {
|
|
4409
|
+
return {
|
|
4410
|
+
claude: Boolean(this.safeReadExternal("claude")?.accessToken),
|
|
4411
|
+
codex: Boolean(this.safeReadExternal("codex")?.accessToken)
|
|
4412
|
+
};
|
|
4413
|
+
}
|
|
4414
|
+
/**
|
|
4415
|
+
* DAEMON-ONLY (admin import button): import the external CLI's current login
|
|
4416
|
+
* as a NEW account (+ activate), and take MANAGED ownership of the native
|
|
4417
|
+
* store (marker) so subsequent refreshes write back — keeping the bare CLI
|
|
4418
|
+
* and the daemon on the same live credential instead of silently killing one
|
|
4419
|
+
* side's single-use refresh token.
|
|
4420
|
+
*/
|
|
4421
|
+
async importExternalCliAccount(provider, label) {
|
|
4422
|
+
const external = this.safeReadExternal(provider);
|
|
4423
|
+
if (!external?.accessToken) return { ok: false, reason: "no-credential" };
|
|
4424
|
+
const tokens = buildTokensFromExternal(provider, external);
|
|
4425
|
+
const result = await this.appendProviderAccount(provider, tokens, label);
|
|
4426
|
+
try {
|
|
4427
|
+
this.externalCliStore.writeMarker(provider, result.id);
|
|
4428
|
+
} catch {
|
|
4429
|
+
}
|
|
4430
|
+
return { ok: true, id: result.id };
|
|
3499
4431
|
}
|
|
3500
4432
|
/**
|
|
3501
4433
|
* Materialize a lazily-synthesized account id to disk (design D3). On a legacy
|
|
@@ -3579,6 +4511,18 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3579
4511
|
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
3580
4512
|
return result;
|
|
3581
4513
|
}
|
|
4514
|
+
/**
|
|
4515
|
+
* DAEMON-ONLY per-account rename (NOT on the port). Update one account's label;
|
|
4516
|
+
* rejects an unknown id. Label-only — no token material is read or written
|
|
4517
|
+
* (the secret-free invariant holds).
|
|
4518
|
+
*/
|
|
4519
|
+
async renameAccount(providerId, id, label) {
|
|
4520
|
+
const current = this.readConfig();
|
|
4521
|
+
const result = renameAccount(current, providerId, id, label);
|
|
4522
|
+
if (!result.ok) return result;
|
|
4523
|
+
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
4524
|
+
return result;
|
|
4525
|
+
}
|
|
3582
4526
|
/**
|
|
3583
4527
|
* DAEMON-ONLY CLEAR (design D1/D3, NOT on the port). Remove a single provider's
|
|
3584
4528
|
* block from `tokens.json` and re-persist (the strategies already tolerate an
|
|
@@ -3595,9 +4539,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3595
4539
|
* → `enc:v1:`; already-`enc:`/`$ENV` untouched) before serializing, so any
|
|
3596
4540
|
* write — incl. child 4's future refresh writes — lands encrypted. */
|
|
3597
4541
|
persist(config) {
|
|
3598
|
-
|
|
4542
|
+
mkdirSync3(dirname4(this.tokensPath), { recursive: true });
|
|
3599
4543
|
const encrypted = encryptTokens(config, this.box);
|
|
3600
|
-
|
|
4544
|
+
writeFileSync7(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
|
|
3601
4545
|
}
|
|
3602
4546
|
/**
|
|
3603
4547
|
* Read + parse `tokens.json`, tolerating a missing/corrupt file, then DECRYPT
|
|
@@ -3613,10 +4557,10 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3613
4557
|
* `config.ts loadConfig`, which decrypts outside its parse try.
|
|
3614
4558
|
*/
|
|
3615
4559
|
readConfig() {
|
|
3616
|
-
if (!
|
|
4560
|
+
if (!existsSync9(this.tokensPath)) return { updatedAt: "" };
|
|
3617
4561
|
let parsed;
|
|
3618
4562
|
try {
|
|
3619
|
-
const raw = JSON.parse(
|
|
4563
|
+
const raw = JSON.parse(readFileSync10(this.tokensPath, "utf8"));
|
|
3620
4564
|
parsed = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
|
|
3621
4565
|
} catch {
|
|
3622
4566
|
parsed = null;
|
|
@@ -3627,6 +4571,95 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3627
4571
|
}
|
|
3628
4572
|
};
|
|
3629
4573
|
|
|
4574
|
+
// src/TokenRefreshScheduler.ts
|
|
4575
|
+
var REFRESH_LEAD_MS = 5 * 6e4;
|
|
4576
|
+
var SWEEP_INTERVAL_MS = 6e4;
|
|
4577
|
+
var OAUTH_PROVIDERS = ["claude", "codex", "gemini"];
|
|
4578
|
+
var TokenRefreshScheduler = class {
|
|
4579
|
+
constructor(store, logger, intervalMs = SWEEP_INTERVAL_MS, leadMs = REFRESH_LEAD_MS) {
|
|
4580
|
+
this.store = store;
|
|
4581
|
+
this.logger = logger;
|
|
4582
|
+
this.intervalMs = intervalMs;
|
|
4583
|
+
this.leadMs = leadMs;
|
|
4584
|
+
}
|
|
4585
|
+
store;
|
|
4586
|
+
logger;
|
|
4587
|
+
intervalMs;
|
|
4588
|
+
leadMs;
|
|
4589
|
+
timer = null;
|
|
4590
|
+
sweeping = false;
|
|
4591
|
+
/** Arm the sweep interval. Idempotent. The timer never holds the loop open. */
|
|
4592
|
+
start() {
|
|
4593
|
+
if (this.timer) return;
|
|
4594
|
+
this.timer = setInterval(() => void this.sweep(), this.intervalMs);
|
|
4595
|
+
this.timer.unref?.();
|
|
4596
|
+
}
|
|
4597
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
4598
|
+
dispose() {
|
|
4599
|
+
if (this.timer) {
|
|
4600
|
+
clearInterval(this.timer);
|
|
4601
|
+
this.timer = null;
|
|
4602
|
+
}
|
|
4603
|
+
}
|
|
4604
|
+
/** One sweep over every account of every OAuth provider. Exposed for tests. */
|
|
4605
|
+
async sweep(now = Date.now()) {
|
|
4606
|
+
if (this.sweeping) return;
|
|
4607
|
+
this.sweeping = true;
|
|
4608
|
+
try {
|
|
4609
|
+
const config = await this.store.getFullConfig();
|
|
4610
|
+
for (const provider of OAUTH_PROVIDERS) {
|
|
4611
|
+
const activeId = getActiveAccount(config, provider)?.id;
|
|
4612
|
+
for (const account of listAccounts(config, provider)) {
|
|
4613
|
+
if (!this.needsRefresh(account.tokens, now)) continue;
|
|
4614
|
+
await this.refreshOne(provider, account.id, account.id === activeId);
|
|
4615
|
+
}
|
|
4616
|
+
}
|
|
4617
|
+
} catch (error) {
|
|
4618
|
+
this.logger.warn("token-refresh sweep failed", {
|
|
4619
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4620
|
+
});
|
|
4621
|
+
} finally {
|
|
4622
|
+
this.sweeping = false;
|
|
4623
|
+
}
|
|
4624
|
+
}
|
|
4625
|
+
/** Expiring within the lead window, refreshable, and not already dead. */
|
|
4626
|
+
needsRefresh(tokens, now) {
|
|
4627
|
+
const t = tokens;
|
|
4628
|
+
if (!t.refreshToken || t.status === "expired" || t.status === "error") return false;
|
|
4629
|
+
if (!t.expiresAt) return false;
|
|
4630
|
+
const expiresAt = Date.parse(t.expiresAt);
|
|
4631
|
+
return Number.isFinite(expiresAt) && now >= expiresAt - this.leadMs;
|
|
4632
|
+
}
|
|
4633
|
+
/** Refresh one account; failures are logged, never thrown (the store has
|
|
4634
|
+
* already flagged the account `expired`). */
|
|
4635
|
+
async refreshOne(provider, id, isActive) {
|
|
4636
|
+
try {
|
|
4637
|
+
const ok = isActive ? await this.refreshActive(provider) : await this.store.refreshAccountById(provider, id);
|
|
4638
|
+
if (!ok) {
|
|
4639
|
+
this.logger.warn("background token refresh failed", { provider, accountId: id });
|
|
4640
|
+
} else {
|
|
4641
|
+
this.logger.info("background token refresh succeeded", { provider, accountId: id });
|
|
4642
|
+
}
|
|
4643
|
+
} catch (error) {
|
|
4644
|
+
this.logger.warn("background token refresh threw", {
|
|
4645
|
+
provider,
|
|
4646
|
+
accountId: id,
|
|
4647
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4648
|
+
});
|
|
4649
|
+
}
|
|
4650
|
+
}
|
|
4651
|
+
refreshActive(provider) {
|
|
4652
|
+
switch (provider) {
|
|
4653
|
+
case "claude":
|
|
4654
|
+
return this.store.refreshClaudeToken();
|
|
4655
|
+
case "codex":
|
|
4656
|
+
return this.store.refreshCodexToken();
|
|
4657
|
+
case "gemini":
|
|
4658
|
+
return this.store.refreshGeminiToken();
|
|
4659
|
+
}
|
|
4660
|
+
}
|
|
4661
|
+
};
|
|
4662
|
+
|
|
3630
4663
|
// src/bootstrap.ts
|
|
3631
4664
|
function buildDaemon(config, paths) {
|
|
3632
4665
|
const logger = new ConsoleLogger();
|
|
@@ -3659,7 +4692,14 @@ function buildDaemon(config, paths) {
|
|
|
3659
4692
|
autoDisableStore.markAutoDisabled(keyId, status, at);
|
|
3660
4693
|
}
|
|
3661
4694
|
);
|
|
3662
|
-
const
|
|
4695
|
+
const pricingStore = new JsonPricingStore(defaultPricingPath(paths.configPath));
|
|
4696
|
+
const pricingEngine = new PricingEngine(pricingStore, logger);
|
|
4697
|
+
const usageEventStore = new JsonlUsageEventStore(
|
|
4698
|
+
defaultUsageEventsPath(paths.configPath),
|
|
4699
|
+
async (providerId, model) => await pricingEngine.getEntry(providerId, model) !== null
|
|
4700
|
+
);
|
|
4701
|
+
const usageRecorder = new UsageRecorder(usageEventStore, pricingEngine, logger);
|
|
4702
|
+
const providerProxy = getProviderProxy({ llmConfig, apiKeyPool, usageRecorder });
|
|
3663
4703
|
llmConfig.setReloadHook(() => apiKeyPool.invalidateCache());
|
|
3664
4704
|
const outboundApiServer = getOutboundApiServer({
|
|
3665
4705
|
db: keyDb,
|
|
@@ -3705,10 +4745,23 @@ function buildDaemon(config, paths) {
|
|
|
3705
4745
|
// the multi-account append (`appendProviderAccount`, import re-encrypts at-
|
|
3706
4746
|
// rest). Confined to the export/import handlers; never reached by a GET.
|
|
3707
4747
|
migrationCredentialStore: credentialStore,
|
|
4748
|
+
// Code CLI launch (dashboard parity): the external-terminal opener + PATH probe
|
|
4749
|
+
// default to the real implementations; tests inject spies so no window spawns.
|
|
4750
|
+
cliTerminalOpener: paths.cliTerminalOpener,
|
|
4751
|
+
cliPathProbe: paths.cliPathProbe,
|
|
4752
|
+
cliCommandRunner: paths.cliCommandRunner,
|
|
4753
|
+
// Usage/pricing admin surface (usage-pricing child): stats queries go
|
|
4754
|
+
// through the recorder facade, pricing mutations through the engine, and
|
|
4755
|
+
// the row DELETE through the concrete store (delete is store-local — the
|
|
4756
|
+
// core port stays frozen). None of these can reach key material.
|
|
4757
|
+
usageRecorder,
|
|
4758
|
+
pricingEngine,
|
|
4759
|
+
pricingStore,
|
|
3708
4760
|
// Use the DECRYPTED config so `admin.token` (if stored as `enc:`) is the
|
|
3709
4761
|
// plaintext bearer the AdminServer's constant-time compare expects (D4).
|
|
3710
4762
|
getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin)
|
|
3711
4763
|
});
|
|
4764
|
+
const tokenRefreshScheduler = new TokenRefreshScheduler(credentialStore, logger);
|
|
3712
4765
|
return {
|
|
3713
4766
|
logger,
|
|
3714
4767
|
llmConfig,
|
|
@@ -3721,7 +4774,11 @@ function buildDaemon(config, paths) {
|
|
|
3721
4774
|
credentialStore,
|
|
3722
4775
|
subscriptionRegistry,
|
|
3723
4776
|
subscriptionAccounts,
|
|
3724
|
-
|
|
4777
|
+
pricingStore,
|
|
4778
|
+
pricingEngine,
|
|
4779
|
+
usageRecorder,
|
|
4780
|
+
adminServer,
|
|
4781
|
+
tokenRefreshScheduler
|
|
3725
4782
|
};
|
|
3726
4783
|
}
|
|
3727
4784
|
|
|
@@ -3763,10 +4820,10 @@ function buildCliSpawnPlan(opts) {
|
|
|
3763
4820
|
};
|
|
3764
4821
|
}
|
|
3765
4822
|
function resolveInPathDefault(candidate) {
|
|
3766
|
-
const segments = (process.env["PATH"] ?? "").split(
|
|
4823
|
+
const segments = (process.env["PATH"] ?? "").split(delimiter2).filter(Boolean);
|
|
3767
4824
|
for (const seg of segments) {
|
|
3768
|
-
const full =
|
|
3769
|
-
if (
|
|
4825
|
+
const full = join5(seg, candidate);
|
|
4826
|
+
if (existsSync10(full)) return full;
|
|
3770
4827
|
}
|
|
3771
4828
|
return null;
|
|
3772
4829
|
}
|
|
@@ -3806,9 +4863,10 @@ async function runLaunch(argv, deps) {
|
|
|
3806
4863
|
try {
|
|
3807
4864
|
await daemon.llmConfig.ready();
|
|
3808
4865
|
await daemon.providerProxy.start();
|
|
3809
|
-
} catch (
|
|
4866
|
+
} catch (err5) {
|
|
3810
4867
|
daemon.apiKeyPool.dispose();
|
|
3811
|
-
|
|
4868
|
+
daemon.tokenRefreshScheduler.dispose();
|
|
4869
|
+
throw err5;
|
|
3812
4870
|
}
|
|
3813
4871
|
let launch;
|
|
3814
4872
|
try {
|
|
@@ -3816,10 +4874,11 @@ async function runLaunch(argv, deps) {
|
|
|
3816
4874
|
providerId: values.provider,
|
|
3817
4875
|
model: values.model
|
|
3818
4876
|
});
|
|
3819
|
-
} catch (
|
|
4877
|
+
} catch (err5) {
|
|
3820
4878
|
await daemon.providerProxy.stop();
|
|
3821
4879
|
daemon.apiKeyPool.dispose();
|
|
3822
|
-
|
|
4880
|
+
daemon.tokenRefreshScheduler.dispose();
|
|
4881
|
+
throw err5;
|
|
3823
4882
|
}
|
|
3824
4883
|
try {
|
|
3825
4884
|
const plan = buildCliSpawnPlan({
|
|
@@ -3840,6 +4899,7 @@ async function runLaunch(argv, deps) {
|
|
|
3840
4899
|
launch.onSessionEnd();
|
|
3841
4900
|
await daemon.providerProxy.stop();
|
|
3842
4901
|
daemon.apiKeyPool.dispose();
|
|
4902
|
+
daemon.tokenRefreshScheduler.dispose();
|
|
3843
4903
|
}
|
|
3844
4904
|
}
|
|
3845
4905
|
async function buildLaunchConfig(cli, llmConfig, opts) {
|
|
@@ -3853,15 +4913,15 @@ async function buildLaunchConfig(cli, llmConfig, opts) {
|
|
|
3853
4913
|
};
|
|
3854
4914
|
switch (cli) {
|
|
3855
4915
|
case "claude":
|
|
3856
|
-
return
|
|
4916
|
+
return buildClaudeCliLaunchConfig2(common);
|
|
3857
4917
|
case "codex":
|
|
3858
|
-
return
|
|
4918
|
+
return buildCodexLaunchConfig2(common);
|
|
3859
4919
|
case "gemini":
|
|
3860
|
-
return
|
|
4920
|
+
return buildGeminiCliLaunchConfig2(common);
|
|
3861
4921
|
case "qwen":
|
|
3862
4922
|
case "copilot":
|
|
3863
4923
|
case "opencode":
|
|
3864
|
-
return
|
|
4924
|
+
return buildChatCliLaunchConfig2({ backendId: cli, ...common });
|
|
3865
4925
|
default: {
|
|
3866
4926
|
const _exhaustive = cli;
|
|
3867
4927
|
throw new Error(`Unsupported launch CLI: ${String(_exhaustive)}`);
|
|
@@ -3870,7 +4930,7 @@ async function buildLaunchConfig(cli, llmConfig, opts) {
|
|
|
3870
4930
|
}
|
|
3871
4931
|
function spawnCliInherit(plan) {
|
|
3872
4932
|
return new Promise((resolve, reject) => {
|
|
3873
|
-
const child =
|
|
4933
|
+
const child = spawn2(plan.command, plan.args, {
|
|
3874
4934
|
stdio: "inherit",
|
|
3875
4935
|
env: plan.env,
|
|
3876
4936
|
cwd: plan.cwd,
|
|
@@ -3889,9 +4949,9 @@ function spawnCliInherit(plan) {
|
|
|
3889
4949
|
process.removeListener("SIGINT", onSignal);
|
|
3890
4950
|
process.removeListener("SIGTERM", onSignal);
|
|
3891
4951
|
};
|
|
3892
|
-
child.on("error", (
|
|
4952
|
+
child.on("error", (err5) => {
|
|
3893
4953
|
detach();
|
|
3894
|
-
if (
|
|
4954
|
+
if (err5.code === "ENOENT") {
|
|
3895
4955
|
reject(
|
|
3896
4956
|
new Error(
|
|
3897
4957
|
`launch: "${plan.command}" not found on PATH \u2014 install the CLI first.`
|
|
@@ -3899,7 +4959,7 @@ function spawnCliInherit(plan) {
|
|
|
3899
4959
|
);
|
|
3900
4960
|
return;
|
|
3901
4961
|
}
|
|
3902
|
-
reject(
|
|
4962
|
+
reject(err5);
|
|
3903
4963
|
});
|
|
3904
4964
|
child.on("exit", (code, signal) => {
|
|
3905
4965
|
detach();
|
|
@@ -3909,7 +4969,7 @@ function spawnCliInherit(plan) {
|
|
|
3909
4969
|
}
|
|
3910
4970
|
|
|
3911
4971
|
// src/commands/login.ts
|
|
3912
|
-
import { spawn as
|
|
4972
|
+
import { spawn as spawn3 } from "child_process";
|
|
3913
4973
|
import { createInterface } from "readline";
|
|
3914
4974
|
import { parseArgs as parseArgs4 } from "util";
|
|
3915
4975
|
import { claudeOAuth as claudeOAuth3, codexOAuth as codexOAuth3, geminiOAuth as geminiOAuth3 } from "@omnicross/subscriptions";
|
|
@@ -4060,7 +5120,7 @@ function openBrowser(url) {
|
|
|
4060
5120
|
return new Promise((resolve) => {
|
|
4061
5121
|
try {
|
|
4062
5122
|
const { command, args } = buildOpenBrowserCommand(process.platform, url);
|
|
4063
|
-
const child =
|
|
5123
|
+
const child = spawn3(command, args, { stdio: "ignore", detached: true });
|
|
4064
5124
|
child.on("error", () => resolve(false));
|
|
4065
5125
|
child.unref();
|
|
4066
5126
|
resolve(true);
|
|
@@ -4080,7 +5140,7 @@ function promptPaste(prompt) {
|
|
|
4080
5140
|
}
|
|
4081
5141
|
|
|
4082
5142
|
// src/commands/providers.ts
|
|
4083
|
-
import { randomUUID as
|
|
5143
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
4084
5144
|
import { parseArgs as parseArgs5 } from "util";
|
|
4085
5145
|
async function runProviders(argv) {
|
|
4086
5146
|
const { values, positionals } = parseArgs5({
|
|
@@ -4202,7 +5262,7 @@ function providersAddKey(configPath, providerId, opts) {
|
|
|
4202
5262
|
const cfg = loadConfig(configPath);
|
|
4203
5263
|
const row = cfg.providers.find((p) => p.id === providerId);
|
|
4204
5264
|
if (!row) throw new Error(`providers add-key: unknown provider '${providerId}'`);
|
|
4205
|
-
const entry = { id:
|
|
5265
|
+
const entry = { id: randomUUID4(), apiKey: opts.key };
|
|
4206
5266
|
if (opts.label) entry.label = opts.label;
|
|
4207
5267
|
if (opts.weight !== void 0) {
|
|
4208
5268
|
const w = Number(opts.weight);
|
|
@@ -4230,7 +5290,7 @@ function providersRmKey(configPath, providerId, keyId) {
|
|
|
4230
5290
|
}
|
|
4231
5291
|
|
|
4232
5292
|
// src/commands/secrets.ts
|
|
4233
|
-
import { existsSync as
|
|
5293
|
+
import { existsSync as existsSync11, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "fs";
|
|
4234
5294
|
import { parseArgs as parseArgs6 } from "util";
|
|
4235
5295
|
async function runSecrets(argv) {
|
|
4236
5296
|
const { values, positionals } = parseArgs6({
|
|
@@ -4302,7 +5362,7 @@ function secretsStatus(args) {
|
|
|
4302
5362
|
reportField("admin.token", cfg.admin.token);
|
|
4303
5363
|
}
|
|
4304
5364
|
const tokensPath = defaultTokensPath(args.config);
|
|
4305
|
-
if (
|
|
5365
|
+
if (existsSync11(tokensPath)) {
|
|
4306
5366
|
console.info(`Secret status for ${tokensPath}:`);
|
|
4307
5367
|
reportTokenFields(tokensPath);
|
|
4308
5368
|
}
|
|
@@ -4342,7 +5402,7 @@ function secretsRotate(args) {
|
|
|
4342
5402
|
const tokensPath = defaultTokensPath(args.config);
|
|
4343
5403
|
try {
|
|
4344
5404
|
cfg = loadConfig(args.config);
|
|
4345
|
-
if (
|
|
5405
|
+
if (existsSync11(tokensPath)) tokensPlain = decryptTokensFile(tokensPath, oldBox);
|
|
4346
5406
|
} finally {
|
|
4347
5407
|
setSecretBox(null);
|
|
4348
5408
|
}
|
|
@@ -4371,28 +5431,28 @@ function secretsDecrypt(args) {
|
|
|
4371
5431
|
let tokensPlain = null;
|
|
4372
5432
|
try {
|
|
4373
5433
|
cfg = loadConfig(args.config);
|
|
4374
|
-
if (
|
|
5434
|
+
if (existsSync11(tokensPath)) tokensPlain = decryptTokensFile(tokensPath, box);
|
|
4375
5435
|
} finally {
|
|
4376
5436
|
setSecretBox(null);
|
|
4377
5437
|
}
|
|
4378
5438
|
saveConfig(args.config, cfg);
|
|
4379
5439
|
if (tokensPlain) {
|
|
4380
|
-
|
|
5440
|
+
writeFileSync8(tokensPath, JSON.stringify(tokensPlain, null, 2) + "\n", "utf8");
|
|
4381
5441
|
}
|
|
4382
5442
|
console.info(`Decrypted secrets to plaintext in ${args.config}` + tokensSuffix(args.config));
|
|
4383
5443
|
}
|
|
4384
|
-
function readRawConfig(
|
|
5444
|
+
function readRawConfig(path2) {
|
|
4385
5445
|
let parsed;
|
|
4386
5446
|
try {
|
|
4387
|
-
parsed = JSON.parse(
|
|
5447
|
+
parsed = JSON.parse(readFileSync11(path2, "utf8"));
|
|
4388
5448
|
} catch {
|
|
4389
|
-
throw new Error(`secrets: cannot read or parse '${
|
|
5449
|
+
throw new Error(`secrets: cannot read or parse '${path2}'`);
|
|
4390
5450
|
}
|
|
4391
5451
|
return validateConfig(parsed);
|
|
4392
5452
|
}
|
|
4393
|
-
function readRawJson(
|
|
5453
|
+
function readRawJson(path2) {
|
|
4394
5454
|
try {
|
|
4395
|
-
const parsed = JSON.parse(
|
|
5455
|
+
const parsed = JSON.parse(readFileSync11(path2, "utf8"));
|
|
4396
5456
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
4397
5457
|
return parsed;
|
|
4398
5458
|
}
|
|
@@ -4402,7 +5462,7 @@ function readRawJson(path) {
|
|
|
4402
5462
|
}
|
|
4403
5463
|
function encryptTokensFileInPlace(configPath, box) {
|
|
4404
5464
|
const tokensPath = defaultTokensPath(configPath);
|
|
4405
|
-
if (!
|
|
5465
|
+
if (!existsSync11(tokensPath)) return;
|
|
4406
5466
|
const plain = decryptTokensFile(tokensPath, box);
|
|
4407
5467
|
writeTokensEncrypted(tokensPath, plain, box);
|
|
4408
5468
|
}
|
|
@@ -4415,7 +5475,7 @@ function writeTokensEncrypted(tokensPath, plain, box) {
|
|
|
4415
5475
|
{ updatedAt: "", ...plain },
|
|
4416
5476
|
box
|
|
4417
5477
|
);
|
|
4418
|
-
|
|
5478
|
+
writeFileSync8(tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
|
|
4419
5479
|
}
|
|
4420
5480
|
var TOKEN_FIELDS2 = {
|
|
4421
5481
|
claude: ["accessToken", "refreshToken"],
|
|
@@ -4438,7 +5498,7 @@ function walkTokens(raw, fn) {
|
|
|
4438
5498
|
return next;
|
|
4439
5499
|
}
|
|
4440
5500
|
function tokensSuffix(configPath) {
|
|
4441
|
-
return
|
|
5501
|
+
return existsSync11(defaultTokensPath(configPath)) ? " (+ tokens.json)" : "";
|
|
4442
5502
|
}
|
|
4443
5503
|
|
|
4444
5504
|
// src/commands/start.ts
|
|
@@ -4480,6 +5540,7 @@ async function runStart(argv) {
|
|
|
4480
5540
|
await daemon.adminServer.start();
|
|
4481
5541
|
dashboardUrl = daemon.adminServer.getStatus().url;
|
|
4482
5542
|
}
|
|
5543
|
+
daemon.tokenRefreshScheduler.start();
|
|
4483
5544
|
const status = daemon.outboundApiServer.getStatus();
|
|
4484
5545
|
console.info("omnicross daemon is running.");
|
|
4485
5546
|
if (dashboardUrl) console.info(` dashboard : ${dashboardUrl}`);
|
|
@@ -4498,6 +5559,32 @@ async function runStart(argv) {
|
|
|
4498
5559
|
console.info(" dashboard: localhost-default; --no-dashboard to disable; admin.token for a bearer gate");
|
|
4499
5560
|
}
|
|
4500
5561
|
console.info("Press Ctrl+C to stop.");
|
|
5562
|
+
return { dashboardUrl };
|
|
5563
|
+
}
|
|
5564
|
+
|
|
5565
|
+
// src/commands/ui.ts
|
|
5566
|
+
async function runUi(argv, deps) {
|
|
5567
|
+
const start = deps?.start ?? runStart;
|
|
5568
|
+
const open = deps?.openBrowser ?? openBrowser;
|
|
5569
|
+
const noOpen = argv.includes("--no-open");
|
|
5570
|
+
const startArgv = argv.filter((a) => a !== "--no-open");
|
|
5571
|
+
if (!resolveUiDist()) {
|
|
5572
|
+
console.warn(
|
|
5573
|
+
"omnicross ui: Control Panel assets not found (@omnicross/ui has no built dist). The daemon will still start, but /ui will 404. Install @omnicross/ui (or build it: npm run build -w @omnicross/ui), or set OMNICROSS_UI_DIST."
|
|
5574
|
+
);
|
|
5575
|
+
}
|
|
5576
|
+
const { dashboardUrl } = await start(startArgv);
|
|
5577
|
+
if (!dashboardUrl) {
|
|
5578
|
+
console.warn("omnicross ui: the admin dashboard is disabled \u2014 no UI to open.");
|
|
5579
|
+
return;
|
|
5580
|
+
}
|
|
5581
|
+
const uiUrl = `${dashboardUrl}/ui/`;
|
|
5582
|
+
console.info(`Control Panel: ${uiUrl}`);
|
|
5583
|
+
if (noOpen) return;
|
|
5584
|
+
const launched = await open(uiUrl).catch(() => false);
|
|
5585
|
+
if (!launched) {
|
|
5586
|
+
console.info("(Could not open a browser automatically \u2014 open the URL above manually.)");
|
|
5587
|
+
}
|
|
4501
5588
|
}
|
|
4502
5589
|
|
|
4503
5590
|
// src/cli.ts
|
|
@@ -4505,6 +5592,8 @@ var USAGE = `omnicross \u2014 standalone @omnicross/core daemon
|
|
|
4505
5592
|
|
|
4506
5593
|
Usage:
|
|
4507
5594
|
omnicross start --config <path> Boot the daemon (BYO-key serving).
|
|
5595
|
+
omnicross ui --config <path> [--no-open] Boot the daemon + open the Control Panel
|
|
5596
|
+
(the web UI at <dashboard>/ui/) in a browser.
|
|
4508
5597
|
omnicross keys add <name> --config <p> Mint a named API key (shown once).
|
|
4509
5598
|
omnicross keys list --config <p> List stored keys (no secrets).
|
|
4510
5599
|
omnicross keys revoke <id> --config <p> Revoke a key.
|
|
@@ -4530,6 +5619,9 @@ async function main() {
|
|
|
4530
5619
|
case "start":
|
|
4531
5620
|
await runStart(rest);
|
|
4532
5621
|
return;
|
|
5622
|
+
case "ui":
|
|
5623
|
+
await runUi(rest);
|
|
5624
|
+
return;
|
|
4533
5625
|
case "keys":
|
|
4534
5626
|
await runKeys(rest);
|
|
4535
5627
|
return;
|
|
@@ -4561,7 +5653,7 @@ async function main() {
|
|
|
4561
5653
|
process.exitCode = 1;
|
|
4562
5654
|
}
|
|
4563
5655
|
}
|
|
4564
|
-
main().catch((
|
|
4565
|
-
console.error(
|
|
5656
|
+
main().catch((err5) => {
|
|
5657
|
+
console.error(err5 instanceof Error ? err5.message : String(err5));
|
|
4566
5658
|
process.exitCode = 1;
|
|
4567
5659
|
});
|