@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/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
__resetProviderProxyForTests,
|
|
12
12
|
getProviderProxy
|
|
13
13
|
} from "@omnicross/core/provider-proxy";
|
|
14
|
+
import { PricingEngine, UsageRecorder } from "@omnicross/core/usage";
|
|
14
15
|
import {
|
|
15
16
|
setSubscriptionAccountService,
|
|
16
17
|
setSubscriptionProviderRegistry,
|
|
@@ -203,23 +204,23 @@ function decodeEnvKey(raw) {
|
|
|
203
204
|
}
|
|
204
205
|
return buf;
|
|
205
206
|
}
|
|
206
|
-
function readKeyFile(
|
|
207
|
-
const raw = readFileSync(
|
|
207
|
+
function readKeyFile(path2) {
|
|
208
|
+
const raw = readFileSync(path2);
|
|
208
209
|
if (raw.length === KEY_BYTES2) return raw;
|
|
209
210
|
const text = raw.toString("utf8").trim();
|
|
210
211
|
if (/^[0-9a-fA-F]{64}$/.test(text)) return Buffer.from(text, "hex");
|
|
211
212
|
const b64 = Buffer.from(text, "base64");
|
|
212
213
|
if (b64.length === KEY_BYTES2) return b64;
|
|
213
214
|
throw new Error(
|
|
214
|
-
`master key file '${
|
|
215
|
+
`master key file '${path2}' is invalid: expected 32 raw bytes, 64 hex chars, or 32-byte base64`
|
|
215
216
|
);
|
|
216
217
|
}
|
|
217
|
-
function generateKeyFile(
|
|
218
|
+
function generateKeyFile(path2) {
|
|
218
219
|
const key = randomBytes2(KEY_BYTES2);
|
|
219
|
-
mkdirSync(dirname(
|
|
220
|
-
writeFileSync(
|
|
220
|
+
mkdirSync(dirname(path2), { recursive: true });
|
|
221
|
+
writeFileSync(path2, key, { mode: 384 });
|
|
221
222
|
try {
|
|
222
|
-
chmodSync(
|
|
223
|
+
chmodSync(path2, 384);
|
|
223
224
|
} catch {
|
|
224
225
|
}
|
|
225
226
|
return key;
|
|
@@ -598,25 +599,25 @@ var secretBox = null;
|
|
|
598
599
|
function setSecretBox(box) {
|
|
599
600
|
secretBox = box;
|
|
600
601
|
}
|
|
601
|
-
function loadConfig(
|
|
602
|
+
function loadConfig(path2) {
|
|
602
603
|
let raw;
|
|
603
604
|
try {
|
|
604
|
-
raw = readFileSync2(
|
|
605
|
+
raw = readFileSync2(path2, "utf8");
|
|
605
606
|
} catch {
|
|
606
|
-
throw new Error(`config: cannot read file at '${
|
|
607
|
+
throw new Error(`config: cannot read file at '${path2}'`);
|
|
607
608
|
}
|
|
608
609
|
let parsed;
|
|
609
610
|
try {
|
|
610
611
|
parsed = JSON.parse(raw);
|
|
611
612
|
} catch {
|
|
612
|
-
throw new Error(`config: '${
|
|
613
|
+
throw new Error(`config: '${path2}' is not valid JSON`);
|
|
613
614
|
}
|
|
614
615
|
const validated = validateConfig(parsed);
|
|
615
616
|
return secretBox ? decryptConfigSecrets(validated, secretBox) : validated;
|
|
616
617
|
}
|
|
617
|
-
function saveConfig(
|
|
618
|
+
function saveConfig(path2, cfg) {
|
|
618
619
|
const toWrite = secretBox ? encryptConfigSecrets(cfg, secretBox) : cfg;
|
|
619
|
-
writeFileSync2(
|
|
620
|
+
writeFileSync2(path2, JSON.stringify(toWrite, null, 2) + "\n", "utf8");
|
|
620
621
|
}
|
|
621
622
|
|
|
622
623
|
// src/pool/resolveEnvKey.ts
|
|
@@ -862,7 +863,8 @@ async function handleOAuthComplete(providerId, body, deps) {
|
|
|
862
863
|
const reason = exchangeError instanceof Error ? exchangeError.message : "token exchange failed";
|
|
863
864
|
return err2(502, `oauth token exchange failed for '${providerId}': ${reason}`);
|
|
864
865
|
}
|
|
865
|
-
|
|
866
|
+
const label = typeof body["label"] === "string" && body["label"].trim() ? body["label"].trim() : void 0;
|
|
867
|
+
await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block, label);
|
|
866
868
|
const status = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
867
869
|
return { status: 200, body: status ? { account: status } : { ok: true } };
|
|
868
870
|
}
|
|
@@ -895,8 +897,207 @@ async function exchangeGemini(code, codeVerifier, exchangeFetch) {
|
|
|
895
897
|
};
|
|
896
898
|
}
|
|
897
899
|
|
|
898
|
-
// src/
|
|
900
|
+
// src/admin/cliLaunch.ts
|
|
901
|
+
import { exec, spawn } from "child_process";
|
|
899
902
|
import { randomUUID } from "crypto";
|
|
903
|
+
import { existsSync as existsSync2 } from "fs";
|
|
904
|
+
import { delimiter, join as join2 } from "path";
|
|
905
|
+
import {
|
|
906
|
+
buildChatCliLaunchConfig,
|
|
907
|
+
buildClaudeCliLaunchConfig,
|
|
908
|
+
buildCodexLaunchConfig,
|
|
909
|
+
buildGeminiCliLaunchConfig
|
|
910
|
+
} from "@omnicross/cli-launcher";
|
|
911
|
+
var LAUNCHABLE_CLIS = [
|
|
912
|
+
{ id: "claude", displayName: "Claude Code", command: "claude" },
|
|
913
|
+
{ id: "codex", displayName: "Codex CLI", command: "codex" },
|
|
914
|
+
{ id: "gemini", displayName: "Gemini CLI", command: "gemini" },
|
|
915
|
+
{ id: "qwen", displayName: "Qwen Code", command: "qwen" },
|
|
916
|
+
{ id: "copilot", displayName: "GitHub Copilot CLI", command: "copilot" },
|
|
917
|
+
{ id: "opencode", displayName: "OpenCode", command: "opencode" }
|
|
918
|
+
];
|
|
919
|
+
var INSTALL_COMMANDS = {
|
|
920
|
+
claude: "npm install -g @anthropic-ai/claude-code",
|
|
921
|
+
codex: "npm install -g @openai/codex",
|
|
922
|
+
gemini: "npm install -g @google/gemini-cli",
|
|
923
|
+
qwen: "npm install -g @qwen-code/qwen-code",
|
|
924
|
+
copilot: "npm install -g @github/copilot",
|
|
925
|
+
opencode: "npm install -g opencode-ai"
|
|
926
|
+
};
|
|
927
|
+
var LAUNCHABLE_IDS = new Set(LAUNCHABLE_CLIS.map((c) => c.id));
|
|
928
|
+
function isLaunchCliId(id) {
|
|
929
|
+
return id !== void 0 && LAUNCHABLE_IDS.has(id);
|
|
930
|
+
}
|
|
931
|
+
function probeDefault(candidate) {
|
|
932
|
+
const segments = (process.env["PATH"] ?? "").split(delimiter).filter(Boolean);
|
|
933
|
+
for (const seg of segments) {
|
|
934
|
+
const full = join2(seg, candidate);
|
|
935
|
+
if (existsSync2(full)) return full;
|
|
936
|
+
}
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
939
|
+
function isCliInstalled(command, platform = process.platform, probe = probeDefault) {
|
|
940
|
+
if (platform === "win32") {
|
|
941
|
+
return Boolean(probe(`${command}.exe`) || probe(`${command}.cmd`) || probe(`${command}.bat`));
|
|
942
|
+
}
|
|
943
|
+
return Boolean(probe(command));
|
|
944
|
+
}
|
|
945
|
+
function detectClis(platform = process.platform, probe = probeDefault) {
|
|
946
|
+
return LAUNCHABLE_CLIS.map((c) => ({
|
|
947
|
+
id: c.id,
|
|
948
|
+
displayName: c.displayName,
|
|
949
|
+
command: c.command,
|
|
950
|
+
installed: isCliInstalled(c.command, platform, probe),
|
|
951
|
+
installable: Boolean(INSTALL_COMMANDS[c.id])
|
|
952
|
+
}));
|
|
953
|
+
}
|
|
954
|
+
function resolveLaunchTarget(providers, requested) {
|
|
955
|
+
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));
|
|
956
|
+
if (!pick) {
|
|
957
|
+
throw new Error("no provider with a model is configured \u2014 add one on the Providers page first");
|
|
958
|
+
}
|
|
959
|
+
const model = requested?.model || firstModel(pick);
|
|
960
|
+
if (!model) {
|
|
961
|
+
throw new Error(`provider "${pick.id}" has no models \u2014 add a model on the Providers page first`);
|
|
962
|
+
}
|
|
963
|
+
return { providerId: pick.id, model };
|
|
964
|
+
}
|
|
965
|
+
function firstModel(p) {
|
|
966
|
+
return p.models?.[0] ?? p.modelConfigs?.[0]?.id;
|
|
967
|
+
}
|
|
968
|
+
async function buildLaunchEnv(cli, llmConfig, target) {
|
|
969
|
+
const common = {
|
|
970
|
+
llmConfig,
|
|
971
|
+
providerId: target.providerId,
|
|
972
|
+
model: target.model,
|
|
973
|
+
sessionId: `dashboard:${cli}`
|
|
974
|
+
};
|
|
975
|
+
switch (cli) {
|
|
976
|
+
case "claude":
|
|
977
|
+
return buildClaudeCliLaunchConfig(common);
|
|
978
|
+
case "codex":
|
|
979
|
+
return buildCodexLaunchConfig(common);
|
|
980
|
+
case "gemini":
|
|
981
|
+
return buildGeminiCliLaunchConfig(common);
|
|
982
|
+
case "qwen":
|
|
983
|
+
case "copilot":
|
|
984
|
+
case "opencode":
|
|
985
|
+
return buildChatCliLaunchConfig({ backendId: cli, ...common });
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
function shq(s) {
|
|
989
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
990
|
+
}
|
|
991
|
+
var defaultTerminalOpener = ({ cli, command, extraArgs, env, cwd, platform }) => {
|
|
992
|
+
const childEnv = { ...process.env, ...env };
|
|
993
|
+
if (platform === "win32") {
|
|
994
|
+
const args = ["/c", "start", `"omnicross ${cli}"`];
|
|
995
|
+
if (cwd) args.push("/D", `"${cwd}"`);
|
|
996
|
+
args.push("cmd", "/k", command, ...extraArgs);
|
|
997
|
+
spawn(process.env["ComSpec"] || "cmd.exe", args, {
|
|
998
|
+
env: childEnv,
|
|
999
|
+
windowsVerbatimArguments: true,
|
|
1000
|
+
detached: true,
|
|
1001
|
+
stdio: "ignore"
|
|
1002
|
+
}).unref();
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
const exportLine = Object.entries(env).map(([k, v]) => `export ${k}=${shq(v)}`).join("; ");
|
|
1006
|
+
const runLine = [command, ...extraArgs].map(shq).join(" ");
|
|
1007
|
+
const script = `${exportLine}; ${cwd ? `cd ${shq(cwd)}; ` : ""}${runLine}`;
|
|
1008
|
+
if (platform === "darwin") {
|
|
1009
|
+
const osa = `tell application "Terminal" to do script ${JSON.stringify(script)}`;
|
|
1010
|
+
spawn("osascript", ["-e", osa], { detached: true, stdio: "ignore" }).unref();
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
spawn("x-terminal-emulator", ["-e", "bash", "-lc", `${script}; exec bash`], {
|
|
1014
|
+
detached: true,
|
|
1015
|
+
stdio: "ignore"
|
|
1016
|
+
}).unref();
|
|
1017
|
+
};
|
|
1018
|
+
var sessions = /* @__PURE__ */ new Map();
|
|
1019
|
+
function errBody(message) {
|
|
1020
|
+
return { error: { type: "admin_api_error", message } };
|
|
1021
|
+
}
|
|
1022
|
+
var defaultCommandRunner = (command) => new Promise((resolve) => {
|
|
1023
|
+
exec(command, { timeout: 18e4 }, (err5, _stdout, stderr) => {
|
|
1024
|
+
if (err5) resolve({ ok: false, error: stderr.trim() || err5.message });
|
|
1025
|
+
else resolve({ ok: true });
|
|
1026
|
+
});
|
|
1027
|
+
});
|
|
1028
|
+
async function handleCliInstall(cli, runner = defaultCommandRunner) {
|
|
1029
|
+
const cmd = INSTALL_COMMANDS[cli];
|
|
1030
|
+
if (!cmd) {
|
|
1031
|
+
return { status: 400, body: errBody(`no install command for cli '${cli}' (manual install only)`) };
|
|
1032
|
+
}
|
|
1033
|
+
const result = await runner(cmd);
|
|
1034
|
+
if (!result.ok) {
|
|
1035
|
+
return { status: 500, body: errBody(result.error || "install failed") };
|
|
1036
|
+
}
|
|
1037
|
+
return { status: 200, body: { ok: true } };
|
|
1038
|
+
}
|
|
1039
|
+
function handleCliList(platform = process.platform, probe = probeDefault) {
|
|
1040
|
+
return { status: 200, body: { clis: detectClis(platform, probe) } };
|
|
1041
|
+
}
|
|
1042
|
+
function handleCliSessions() {
|
|
1043
|
+
const list = [...sessions.values()].map(({ onSessionEnd: _drop, ...rest }) => rest);
|
|
1044
|
+
return { status: 200, body: { sessions: list } };
|
|
1045
|
+
}
|
|
1046
|
+
function handleCliStop(id) {
|
|
1047
|
+
const s = sessions.get(id);
|
|
1048
|
+
if (!s) return { status: 404, body: errBody(`session '${id}' not found`) };
|
|
1049
|
+
try {
|
|
1050
|
+
s.onSessionEnd();
|
|
1051
|
+
} catch {
|
|
1052
|
+
}
|
|
1053
|
+
sessions.delete(id);
|
|
1054
|
+
return { status: 200, body: { ok: true } };
|
|
1055
|
+
}
|
|
1056
|
+
async function handleCliLaunch(cli, body, ctx) {
|
|
1057
|
+
const platform = ctx.platform ?? process.platform;
|
|
1058
|
+
const probe = ctx.probe ?? probeDefault;
|
|
1059
|
+
const meta = LAUNCHABLE_CLIS.find((c) => c.id === cli);
|
|
1060
|
+
if (!meta) return { status: 404, body: errBody(`unknown cli '${cli}'`) };
|
|
1061
|
+
if (!isCliInstalled(meta.command, platform, probe)) {
|
|
1062
|
+
return { status: 400, body: errBody(`"${meta.command}" is not installed (not found on PATH)`) };
|
|
1063
|
+
}
|
|
1064
|
+
let target;
|
|
1065
|
+
try {
|
|
1066
|
+
target = resolveLaunchTarget(ctx.providers, {
|
|
1067
|
+
providerId: typeof body["providerId"] === "string" ? body["providerId"] : void 0,
|
|
1068
|
+
model: typeof body["model"] === "string" ? body["model"] : void 0
|
|
1069
|
+
});
|
|
1070
|
+
} catch (err5) {
|
|
1071
|
+
return { status: 400, body: errBody(err5 instanceof Error ? err5.message : "no launch target") };
|
|
1072
|
+
}
|
|
1073
|
+
let launch;
|
|
1074
|
+
try {
|
|
1075
|
+
launch = await buildLaunchEnv(cli, ctx.llmConfig, target);
|
|
1076
|
+
} catch (err5) {
|
|
1077
|
+
return { status: 400, body: errBody(err5 instanceof Error ? err5.message : "failed to build launch env") };
|
|
1078
|
+
}
|
|
1079
|
+
const cwd = typeof body["cwd"] === "string" && body["cwd"].trim() ? body["cwd"].trim() : void 0;
|
|
1080
|
+
const opener = ctx.opener ?? defaultTerminalOpener;
|
|
1081
|
+
try {
|
|
1082
|
+
opener({ cli, command: meta.command, extraArgs: launch.extraArgs ?? [], env: launch.env, cwd, platform });
|
|
1083
|
+
} catch (err5) {
|
|
1084
|
+
launch.onSessionEnd();
|
|
1085
|
+
return { status: 500, body: errBody(err5 instanceof Error ? err5.message : "failed to open terminal") };
|
|
1086
|
+
}
|
|
1087
|
+
const id = randomUUID();
|
|
1088
|
+
sessions.set(id, {
|
|
1089
|
+
id,
|
|
1090
|
+
cli,
|
|
1091
|
+
providerId: target.providerId,
|
|
1092
|
+
model: target.model,
|
|
1093
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1094
|
+
onSessionEnd: launch.onSessionEnd
|
|
1095
|
+
});
|
|
1096
|
+
return { status: 200, body: { sessionId: id, providerId: target.providerId, model: target.model } };
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// src/ports/account-multi.ts
|
|
1100
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
900
1101
|
var PROVIDER_KEYS = {
|
|
901
1102
|
claude: { block: "claude", accounts: "claudeAccounts", active: "activeClaudeAccountId" },
|
|
902
1103
|
codex: { block: "codex", accounts: "codexAccounts", active: "activeCodexAccountId" },
|
|
@@ -962,7 +1163,7 @@ function migrateLazily(config) {
|
|
|
962
1163
|
}
|
|
963
1164
|
function addAccount(config, p, tokens, label) {
|
|
964
1165
|
const accounts = [...getAccounts(config, p)];
|
|
965
|
-
const id =
|
|
1166
|
+
const id = randomUUID2();
|
|
966
1167
|
accounts.push({
|
|
967
1168
|
id,
|
|
968
1169
|
label: label ?? `Account ${accounts.length + 1}`,
|
|
@@ -999,6 +1200,13 @@ function setActiveAccount(config, p, id) {
|
|
|
999
1200
|
deriveMirror(config, p);
|
|
1000
1201
|
return { ok: true };
|
|
1001
1202
|
}
|
|
1203
|
+
function listAccounts(config, p) {
|
|
1204
|
+
return getAccounts(config, p);
|
|
1205
|
+
}
|
|
1206
|
+
function getAccountById(config, p, id) {
|
|
1207
|
+
const account = getAccounts(config, p).find((a) => a.id === id);
|
|
1208
|
+
return account ? { id: account.id, tokens: account.tokens } : void 0;
|
|
1209
|
+
}
|
|
1002
1210
|
function getActiveAccount(config, p) {
|
|
1003
1211
|
const active = getAccounts(config, p).find((a) => a.id === getActiveId(config, p));
|
|
1004
1212
|
return active ? { id: active.id, tokens: active.tokens } : void 0;
|
|
@@ -1032,12 +1240,27 @@ function sanitizeAccounts(config, p) {
|
|
|
1032
1240
|
id: a.id,
|
|
1033
1241
|
label: a.label,
|
|
1034
1242
|
status: t.status ?? "unconfigured",
|
|
1243
|
+
authMethod: t.authMethod,
|
|
1244
|
+
subscriptionLevel: t.subscriptionLevel,
|
|
1035
1245
|
expiresAt: t.expiresAt,
|
|
1246
|
+
lastRefreshedAt: t.lastRefreshedAt,
|
|
1247
|
+
isSetupToken: t.isSetupToken,
|
|
1036
1248
|
hasAccessToken: !!(t.accessToken || t.apiKey),
|
|
1037
|
-
isActive: a.id === activeId
|
|
1249
|
+
isActive: a.id === activeId,
|
|
1250
|
+
syncWarning: t.syncWarning
|
|
1038
1251
|
};
|
|
1039
1252
|
});
|
|
1040
1253
|
}
|
|
1254
|
+
function renameAccount(config, p, id, label) {
|
|
1255
|
+
const accounts = getAccounts(config, p);
|
|
1256
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
1257
|
+
setAccounts(
|
|
1258
|
+
config,
|
|
1259
|
+
p,
|
|
1260
|
+
accounts.map((a) => a.id === id ? { ...a, label } : a)
|
|
1261
|
+
);
|
|
1262
|
+
return { ok: true };
|
|
1263
|
+
}
|
|
1041
1264
|
function clearProvider(config, p) {
|
|
1042
1265
|
setBlock(config, p, void 0);
|
|
1043
1266
|
setAccounts(config, p, void 0);
|
|
@@ -1284,6 +1507,169 @@ async function handleImport(body, deps) {
|
|
|
1284
1507
|
}
|
|
1285
1508
|
}
|
|
1286
1509
|
|
|
1510
|
+
// src/admin/usagePricing.ts
|
|
1511
|
+
var err4 = (status, message) => ({
|
|
1512
|
+
status,
|
|
1513
|
+
body: { error: { type: "admin_api_error", message } }
|
|
1514
|
+
});
|
|
1515
|
+
function parseFiniteInt(raw) {
|
|
1516
|
+
if (raw === null || raw.trim() === "") return null;
|
|
1517
|
+
const n = Number(raw);
|
|
1518
|
+
return Number.isFinite(n) && Number.isInteger(n) ? n : null;
|
|
1519
|
+
}
|
|
1520
|
+
function parseRange(query) {
|
|
1521
|
+
const startTs = parseFiniteInt(query.get("startTs"));
|
|
1522
|
+
const endTs = parseFiniteInt(query.get("endTs"));
|
|
1523
|
+
if (startTs === null || endTs === null) {
|
|
1524
|
+
return err4(400, "startTs and endTs are required finite-integer unix-millis query params");
|
|
1525
|
+
}
|
|
1526
|
+
return { startTs, endTs };
|
|
1527
|
+
}
|
|
1528
|
+
var isRange = (v) => v.startTs !== void 0 && !("status" in v);
|
|
1529
|
+
async function handleUsageGet(view, query, deps) {
|
|
1530
|
+
const range = parseRange(query);
|
|
1531
|
+
if (!isRange(range)) return range;
|
|
1532
|
+
switch (view) {
|
|
1533
|
+
case "totals":
|
|
1534
|
+
return { status: 200, body: await deps.usageRecorder.getTotals(range) };
|
|
1535
|
+
case "by-model":
|
|
1536
|
+
return { status: 200, body: await deps.usageRecorder.getByModel(range) };
|
|
1537
|
+
case "by-api-key": {
|
|
1538
|
+
const rows = await deps.usageRecorder.getByApiKey(range);
|
|
1539
|
+
const labels = poolKeyLabels(loadConfig(deps.configPath));
|
|
1540
|
+
return {
|
|
1541
|
+
status: 200,
|
|
1542
|
+
body: rows.map((r) => {
|
|
1543
|
+
if (r.apiKeyId === null) {
|
|
1544
|
+
return { ...r, label: "unattributed", providerId: null };
|
|
1545
|
+
}
|
|
1546
|
+
const known = labels.get(r.apiKeyId);
|
|
1547
|
+
return known ? { ...r, label: known.label, providerId: known.providerId } : { ...r, label: r.apiKeyId };
|
|
1548
|
+
})
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
default:
|
|
1552
|
+
return err4(404, `unknown usage view '${view ?? ""}'`);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
function poolKeyLabels(cfg) {
|
|
1556
|
+
const out = /* @__PURE__ */ new Map();
|
|
1557
|
+
for (const provider of cfg.providers) {
|
|
1558
|
+
for (const key of provider.apiKeys ?? []) {
|
|
1559
|
+
out.set(key.id, {
|
|
1560
|
+
label: key.label && key.label.length > 0 ? key.label : key.id,
|
|
1561
|
+
providerId: provider.id
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
return out;
|
|
1566
|
+
}
|
|
1567
|
+
var INVALID_PRICE = /* @__PURE__ */ Symbol("invalid-price");
|
|
1568
|
+
function parseOptionalPrice(b, key) {
|
|
1569
|
+
if (!(key in b) || b[key] === null || b[key] === void 0) return null;
|
|
1570
|
+
const v = b[key];
|
|
1571
|
+
return typeof v === "number" && Number.isFinite(v) ? v : INVALID_PRICE;
|
|
1572
|
+
}
|
|
1573
|
+
function parsePricingEntryInput(raw) {
|
|
1574
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
1575
|
+
const b = raw;
|
|
1576
|
+
const providerId = typeof b["providerId"] === "string" && b["providerId"].trim() ? b["providerId"].trim() : "";
|
|
1577
|
+
const modelId = typeof b["modelId"] === "string" && b["modelId"].trim() ? b["modelId"].trim() : "";
|
|
1578
|
+
const inputPrice = b["inputPricePer1m"];
|
|
1579
|
+
const outputPrice = b["outputPricePer1m"];
|
|
1580
|
+
if (!providerId || !modelId) return null;
|
|
1581
|
+
if (typeof inputPrice !== "number" || !Number.isFinite(inputPrice)) return null;
|
|
1582
|
+
if (typeof outputPrice !== "number" || !Number.isFinite(outputPrice)) return null;
|
|
1583
|
+
const cacheRead = parseOptionalPrice(b, "cacheReadPricePer1m");
|
|
1584
|
+
const cacheWrite = parseOptionalPrice(b, "cacheWritePricePer1m");
|
|
1585
|
+
if (cacheRead === INVALID_PRICE || cacheWrite === INVALID_PRICE) return null;
|
|
1586
|
+
return {
|
|
1587
|
+
providerId,
|
|
1588
|
+
modelId,
|
|
1589
|
+
inputPricePer1m: inputPrice,
|
|
1590
|
+
outputPricePer1m: outputPrice,
|
|
1591
|
+
cacheReadPricePer1m: cacheRead,
|
|
1592
|
+
cacheWritePricePer1m: cacheWrite
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
async function handlePricingList(deps) {
|
|
1596
|
+
return { status: 200, body: { entries: await deps.pricingEngine.getAll() } };
|
|
1597
|
+
}
|
|
1598
|
+
async function handlePricingUpsert(body, deps) {
|
|
1599
|
+
const input = parsePricingEntryInput(body);
|
|
1600
|
+
if (!input) {
|
|
1601
|
+
return err4(400, "invalid pricing entry (providerId, modelId, finite numeric inputPricePer1m/outputPricePer1m required)");
|
|
1602
|
+
}
|
|
1603
|
+
const entry = await deps.pricingEngine.upsertManual(input);
|
|
1604
|
+
return { status: 200, body: { entry } };
|
|
1605
|
+
}
|
|
1606
|
+
async function handlePricingDelete(query, deps) {
|
|
1607
|
+
const providerId = query.get("providerId")?.trim() ?? "";
|
|
1608
|
+
const modelId = query.get("modelId")?.trim() ?? "";
|
|
1609
|
+
if (!providerId || !modelId) {
|
|
1610
|
+
return err4(400, "delete requires providerId and modelId query params");
|
|
1611
|
+
}
|
|
1612
|
+
const deleted = await deps.pricingStore.delete(providerId, modelId);
|
|
1613
|
+
if (deleted) await deps.pricingEngine.invalidateCache();
|
|
1614
|
+
return { status: 200, body: { deleted } };
|
|
1615
|
+
}
|
|
1616
|
+
async function handlePricingFetchLatest(deps) {
|
|
1617
|
+
try {
|
|
1618
|
+
const result = await deps.pricingEngine.fetchLatestFromSource();
|
|
1619
|
+
return {
|
|
1620
|
+
status: 200,
|
|
1621
|
+
body: {
|
|
1622
|
+
appliedCount: result.applied.length,
|
|
1623
|
+
conflicts: result.conflicts,
|
|
1624
|
+
fetchedAt: result.fetchedAt,
|
|
1625
|
+
sourceUrl: result.sourceUrl
|
|
1626
|
+
}
|
|
1627
|
+
};
|
|
1628
|
+
} catch (e) {
|
|
1629
|
+
return err4(502, `pricing-source fetch failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
async function handlePricingResolveConflicts(body, deps) {
|
|
1633
|
+
const raw = body["resolutions"];
|
|
1634
|
+
if (!Array.isArray(raw)) {
|
|
1635
|
+
return err4(400, "resolve-conflicts requires { resolutions: [...] }");
|
|
1636
|
+
}
|
|
1637
|
+
const currentRows = await deps.pricingStore.getAll();
|
|
1638
|
+
const userEditedKeys = new Set(
|
|
1639
|
+
currentRows.filter((r) => r.userEdited).map((r) => `${r.providerId}::${r.modelId}`)
|
|
1640
|
+
);
|
|
1641
|
+
const decisions = [];
|
|
1642
|
+
const pendingIncoming = /* @__PURE__ */ new Map();
|
|
1643
|
+
let staleCount = 0;
|
|
1644
|
+
for (const item of raw) {
|
|
1645
|
+
if (!item || typeof item !== "object") return err4(400, "invalid resolution entry");
|
|
1646
|
+
const r = item;
|
|
1647
|
+
const action = r["action"];
|
|
1648
|
+
if (action !== "overwrite" && action !== "skip") {
|
|
1649
|
+
return err4(400, "resolution action must be 'overwrite' or 'skip'");
|
|
1650
|
+
}
|
|
1651
|
+
const providerId = typeof r["providerId"] === "string" && r["providerId"].trim() ? r["providerId"].trim() : "";
|
|
1652
|
+
const modelId = typeof r["modelId"] === "string" && r["modelId"].trim() ? r["modelId"].trim() : "";
|
|
1653
|
+
if (!providerId || !modelId) {
|
|
1654
|
+
return err4(400, "each resolution requires top-level providerId and modelId");
|
|
1655
|
+
}
|
|
1656
|
+
const incoming = parsePricingEntryInput(r["incoming"]);
|
|
1657
|
+
if (!incoming) return err4(400, "each resolution must echo a valid incoming pricing entry");
|
|
1658
|
+
if (incoming.providerId !== providerId || incoming.modelId !== modelId) {
|
|
1659
|
+
return err4(400, "resolution providerId/modelId must match the echoed incoming entry");
|
|
1660
|
+
}
|
|
1661
|
+
const key = `${providerId}::${modelId}`;
|
|
1662
|
+
if (action === "overwrite" && !userEditedKeys.has(key)) {
|
|
1663
|
+
staleCount += 1;
|
|
1664
|
+
continue;
|
|
1665
|
+
}
|
|
1666
|
+
decisions.push({ providerId, modelId, action });
|
|
1667
|
+
pendingIncoming.set(key, incoming);
|
|
1668
|
+
}
|
|
1669
|
+
const resolution = await deps.pricingEngine.resolveConflicts(decisions, pendingIncoming);
|
|
1670
|
+
return { status: 200, body: { ...resolution, staleCount } };
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1287
1673
|
// src/admin/adminApi.ts
|
|
1288
1674
|
function readBody(req) {
|
|
1289
1675
|
return new Promise((resolve, reject) => {
|
|
@@ -1374,9 +1760,9 @@ function toProviderView(row) {
|
|
|
1374
1760
|
selectedApiModeId: row.selectedApiModeId
|
|
1375
1761
|
};
|
|
1376
1762
|
}
|
|
1377
|
-
async function handleAdminApi(req, res,
|
|
1763
|
+
async function handleAdminApi(req, res, path2, deps) {
|
|
1378
1764
|
const method = (req.method ?? "GET").toUpperCase();
|
|
1379
|
-
const sub =
|
|
1765
|
+
const sub = path2.slice("/admin/api/".length);
|
|
1380
1766
|
const [resource, ...rest] = sub.split("/").filter((s) => s.length > 0);
|
|
1381
1767
|
try {
|
|
1382
1768
|
switch (resource) {
|
|
@@ -1390,6 +1776,8 @@ async function handleAdminApi(req, res, path, deps) {
|
|
|
1390
1776
|
return await handleServer(req, res, method, deps);
|
|
1391
1777
|
case "accounts":
|
|
1392
1778
|
return await handleAccounts(req, res, method, rest, deps);
|
|
1779
|
+
case "cli":
|
|
1780
|
+
return await handleCli(req, res, method, rest, deps);
|
|
1393
1781
|
case "status":
|
|
1394
1782
|
return await handleStatus(res, method, deps);
|
|
1395
1783
|
case "playground":
|
|
@@ -1398,13 +1786,48 @@ async function handleAdminApi(req, res, path, deps) {
|
|
|
1398
1786
|
return await handleMigrationExport(req, res, method, deps);
|
|
1399
1787
|
case "import":
|
|
1400
1788
|
return await handleMigrationImport(req, res, method, deps);
|
|
1789
|
+
case "usage":
|
|
1790
|
+
return await handleUsage(req, res, method, rest, deps);
|
|
1791
|
+
case "pricing":
|
|
1792
|
+
return await handlePricing(req, res, method, rest, deps);
|
|
1401
1793
|
default:
|
|
1402
1794
|
return writeJsonError(res, 404, `unknown admin resource '${resource}'`);
|
|
1403
1795
|
}
|
|
1404
|
-
} catch (
|
|
1405
|
-
writeJsonError(res, 500,
|
|
1796
|
+
} catch (err5) {
|
|
1797
|
+
writeJsonError(res, 500, err5 instanceof Error ? err5.message : String(err5));
|
|
1406
1798
|
}
|
|
1407
1799
|
}
|
|
1800
|
+
function requestQuery(req) {
|
|
1801
|
+
const raw = req.url ?? "";
|
|
1802
|
+
const qIdx = raw.indexOf("?");
|
|
1803
|
+
return new URLSearchParams(qIdx >= 0 ? raw.slice(qIdx + 1) : "");
|
|
1804
|
+
}
|
|
1805
|
+
function writeResult(res, result) {
|
|
1806
|
+
writeJson(res, result.status, result.body);
|
|
1807
|
+
}
|
|
1808
|
+
async function handleUsage(req, res, method, rest, deps) {
|
|
1809
|
+
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on usage`);
|
|
1810
|
+
return writeResult(res, await handleUsageGet(rest[0], requestQuery(req), deps));
|
|
1811
|
+
}
|
|
1812
|
+
async function handlePricing(req, res, method, rest, deps) {
|
|
1813
|
+
if (rest.length === 0) {
|
|
1814
|
+
if (method === "GET") return writeResult(res, await handlePricingList(deps));
|
|
1815
|
+
if (method === "PUT") {
|
|
1816
|
+
return writeResult(res, await handlePricingUpsert(await readJsonBody(req), deps));
|
|
1817
|
+
}
|
|
1818
|
+
if (method === "DELETE") {
|
|
1819
|
+
return writeResult(res, await handlePricingDelete(requestQuery(req), deps));
|
|
1820
|
+
}
|
|
1821
|
+
return writeJsonError(res, 405, `method ${method} not allowed on pricing`);
|
|
1822
|
+
}
|
|
1823
|
+
if (method === "POST" && rest.length === 1 && rest[0] === "fetch-latest") {
|
|
1824
|
+
return writeResult(res, await handlePricingFetchLatest(deps));
|
|
1825
|
+
}
|
|
1826
|
+
if (method === "POST" && rest.length === 1 && rest[0] === "resolve-conflicts") {
|
|
1827
|
+
return writeResult(res, await handlePricingResolveConflicts(await readJsonBody(req), deps));
|
|
1828
|
+
}
|
|
1829
|
+
return writeJsonError(res, 404, `unknown pricing route '${rest.join("/")}'`);
|
|
1830
|
+
}
|
|
1408
1831
|
function migrationDeps(deps) {
|
|
1409
1832
|
return {
|
|
1410
1833
|
configPath: deps.configPath,
|
|
@@ -1451,6 +1874,11 @@ async function handleProviders(req, res, method, rest, deps) {
|
|
|
1451
1874
|
if (method === "POST" && rest.length === 2 && rest[1] === "test") {
|
|
1452
1875
|
return await handleTestModel(req, res, rest[0], cfg);
|
|
1453
1876
|
}
|
|
1877
|
+
if (method === "GET" && rest.length === 2 && rest[1] === "reveal-key") {
|
|
1878
|
+
const row = cfg.providers.find((p) => p.id === rest[0]);
|
|
1879
|
+
if (!row) return writeJsonError(res, 404, `provider '${rest[0]}' not found`);
|
|
1880
|
+
return writeJson(res, 200, { apiKey: row.apiKey ?? "" });
|
|
1881
|
+
}
|
|
1454
1882
|
if (method === "GET") {
|
|
1455
1883
|
return writeJson(res, 200, { providers: cfg.providers.map(toProviderView) });
|
|
1456
1884
|
}
|
|
@@ -1546,8 +1974,8 @@ async function handleDiscoverModels(res, id, cfg) {
|
|
|
1546
1974
|
const data = await response.json();
|
|
1547
1975
|
const models = Array.isArray(data?.data) ? data.data.map((m) => typeof m?.id === "string" ? m.id : "").filter((m) => m.length > 0) : [];
|
|
1548
1976
|
return writeJson(res, 200, { models });
|
|
1549
|
-
} catch (
|
|
1550
|
-
const message =
|
|
1977
|
+
} catch (err5) {
|
|
1978
|
+
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
1551
1979
|
return writeJson(res, 200, { models: [], error: `discovery failed: ${message}` });
|
|
1552
1980
|
}
|
|
1553
1981
|
}
|
|
@@ -1606,8 +2034,8 @@ async function handleTestModel(req, res, id, cfg) {
|
|
|
1606
2034
|
latencyMs,
|
|
1607
2035
|
sample: extractSampleText(text, row.apiFormat)
|
|
1608
2036
|
});
|
|
1609
|
-
} catch (
|
|
1610
|
-
const message =
|
|
2037
|
+
} catch (err5) {
|
|
2038
|
+
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
1611
2039
|
return writeJson(res, 200, { ok: false, latencyMs: Date.now() - startedAt, message });
|
|
1612
2040
|
}
|
|
1613
2041
|
}
|
|
@@ -1956,7 +2384,8 @@ async function handleAccounts(req, res, method, rest, deps) {
|
|
|
1956
2384
|
if (method === "GET" && rest.length === 0) {
|
|
1957
2385
|
const accounts = await deps.subscriptionAccounts.listAll();
|
|
1958
2386
|
const providerAccounts = await deps.subscriptionTokenWriter.listSanitizedAccounts();
|
|
1959
|
-
|
|
2387
|
+
const externalCli = await deps.subscriptionTokenWriter.listExternalCliAvailability();
|
|
2388
|
+
return writeJson(res, 200, { accounts, providerAccounts, externalCli });
|
|
1960
2389
|
}
|
|
1961
2390
|
if (method === "GET" && rest[0] === "codex" && rest[1] === "oauth" && rest[3] === "status") {
|
|
1962
2391
|
const result = handleCodexOAuthStatus(rest[2], deps);
|
|
@@ -1976,6 +2405,47 @@ async function handleAccounts(req, res, method, rest, deps) {
|
|
|
1976
2405
|
const result = await handleOAuthComplete(providerId, body2, deps);
|
|
1977
2406
|
return writeJson(res, result.status, result.body);
|
|
1978
2407
|
}
|
|
2408
|
+
if (method === "POST" && rest[1] === "accounts") {
|
|
2409
|
+
const body2 = await readJsonBody(req);
|
|
2410
|
+
const block = validateTokenBody(providerId, body2);
|
|
2411
|
+
if (!block) {
|
|
2412
|
+
return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
|
|
2413
|
+
}
|
|
2414
|
+
const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
|
|
2415
|
+
await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block, label);
|
|
2416
|
+
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2417
|
+
return writeJson(res, 200, status2 ? { account: status2 } : { ok: true });
|
|
2418
|
+
}
|
|
2419
|
+
if (method === "POST" && rest[1] === "import-external") {
|
|
2420
|
+
if (providerId !== "claude" && providerId !== "codex") {
|
|
2421
|
+
return writeJsonError(res, 400, `provider '${providerId}' has no external CLI store`);
|
|
2422
|
+
}
|
|
2423
|
+
const body2 = await readJsonBody(req);
|
|
2424
|
+
const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
|
|
2425
|
+
const result = await deps.subscriptionTokenWriter.importExternalCliAccount(providerId, label);
|
|
2426
|
+
if (!result.ok) {
|
|
2427
|
+
return writeJsonError(res, 409, `no usable external ${providerId} CLI credential found`);
|
|
2428
|
+
}
|
|
2429
|
+
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2430
|
+
return writeJson(res, 200, { ok: true, account: status2 ?? void 0 });
|
|
2431
|
+
}
|
|
2432
|
+
if (method === "POST" && rest[1] === "refresh") {
|
|
2433
|
+
if (providerId === "opencodego") {
|
|
2434
|
+
return writeJsonError(res, 400, "opencodego credentials are not refreshable");
|
|
2435
|
+
}
|
|
2436
|
+
const writer = deps.subscriptionTokenWriter;
|
|
2437
|
+
const ok = providerId === "claude" ? await writer.refreshClaudeToken() : providerId === "codex" ? await writer.refreshCodexToken() : await writer.refreshGeminiToken();
|
|
2438
|
+
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2439
|
+
return writeJson(res, 200, { ok, account: status2 ?? void 0 });
|
|
2440
|
+
}
|
|
2441
|
+
if (method === "POST" && rest[2] === "label") {
|
|
2442
|
+
const accountId = rest[1];
|
|
2443
|
+
const body2 = await readJsonBody(req);
|
|
2444
|
+
const label = typeof body2["label"] === "string" ? body2["label"].trim() : "";
|
|
2445
|
+
const result = await deps.subscriptionTokenWriter.renameAccount(providerId, accountId, label);
|
|
2446
|
+
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
2447
|
+
return writeJson(res, 200, { ok: true });
|
|
2448
|
+
}
|
|
1979
2449
|
if (method === "PUT" && rest[1] === "active") {
|
|
1980
2450
|
const body2 = await readJsonBody(req);
|
|
1981
2451
|
const id = typeof body2["id"] === "string" ? body2["id"] : "";
|
|
@@ -2005,6 +2475,44 @@ async function handleAccounts(req, res, method, rest, deps) {
|
|
|
2005
2475
|
}
|
|
2006
2476
|
return writeJsonError(res, 405, `method ${method} not allowed on accounts`);
|
|
2007
2477
|
}
|
|
2478
|
+
async function handleCli(req, res, method, rest, deps) {
|
|
2479
|
+
if (method === "GET" && rest.length === 0) {
|
|
2480
|
+
const result = handleCliList(process.platform, deps.cliPathProbe);
|
|
2481
|
+
return writeJson(res, result.status, result.body);
|
|
2482
|
+
}
|
|
2483
|
+
if (method === "GET" && rest[0] === "sessions") {
|
|
2484
|
+
const result = handleCliSessions();
|
|
2485
|
+
return writeJson(res, result.status, result.body);
|
|
2486
|
+
}
|
|
2487
|
+
if (method === "DELETE" && rest[0] === "sessions" && rest[1]) {
|
|
2488
|
+
const result = handleCliStop(rest[1]);
|
|
2489
|
+
return writeJson(res, result.status, result.body);
|
|
2490
|
+
}
|
|
2491
|
+
if (method === "POST" && rest[1] === "install") {
|
|
2492
|
+
const cli = rest[0];
|
|
2493
|
+
if (!isLaunchCliId(cli)) {
|
|
2494
|
+
return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
|
|
2495
|
+
}
|
|
2496
|
+
const result = await handleCliInstall(cli, deps.cliCommandRunner);
|
|
2497
|
+
return writeJson(res, result.status, result.body);
|
|
2498
|
+
}
|
|
2499
|
+
if (method === "POST" && rest[1] === "launch") {
|
|
2500
|
+
const cli = rest[0];
|
|
2501
|
+
if (!isLaunchCliId(cli)) {
|
|
2502
|
+
return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
|
|
2503
|
+
}
|
|
2504
|
+
const body = await readJsonBody(req);
|
|
2505
|
+
const providers = loadConfig(deps.configPath).providers ?? [];
|
|
2506
|
+
const result = await handleCliLaunch(cli, body, {
|
|
2507
|
+
llmConfig: deps.llmConfig,
|
|
2508
|
+
providers,
|
|
2509
|
+
opener: deps.cliTerminalOpener,
|
|
2510
|
+
probe: deps.cliPathProbe
|
|
2511
|
+
});
|
|
2512
|
+
return writeJson(res, result.status, result.body);
|
|
2513
|
+
}
|
|
2514
|
+
return writeJsonError(res, 405, `method ${method} not allowed on cli`);
|
|
2515
|
+
}
|
|
2008
2516
|
async function handleStatus(res, method, deps) {
|
|
2009
2517
|
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on status`);
|
|
2010
2518
|
const status = deps.outboundApiServer.getStatus();
|
|
@@ -2040,21 +2548,21 @@ async function handlePlayground(req, res, method, deps) {
|
|
|
2040
2548
|
const payload = body["body"];
|
|
2041
2549
|
const status = deps.outboundApiServer.getStatus();
|
|
2042
2550
|
if (!status.running || !status.port) return writeJsonError(res, 503, "outbound server not running");
|
|
2043
|
-
const
|
|
2044
|
-
if (!
|
|
2551
|
+
const path2 = resolvePlaygroundPath(endpoint, isRecord(payload) ? payload : {});
|
|
2552
|
+
if (!path2) return writeJsonError(res, 400, `unknown endpoint '${endpoint}'`);
|
|
2045
2553
|
const upstreamBody = typeof payload === "string" ? payload : JSON.stringify(payload ?? {});
|
|
2046
|
-
await proxyToOutbound(res, status.port,
|
|
2554
|
+
await proxyToOutbound(res, status.port, path2, key, upstreamBody);
|
|
2047
2555
|
}
|
|
2048
2556
|
function isRecord(v) {
|
|
2049
2557
|
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
2050
2558
|
}
|
|
2051
|
-
function proxyToOutbound(res, outboundPort,
|
|
2559
|
+
function proxyToOutbound(res, outboundPort, path2, key, body) {
|
|
2052
2560
|
return new Promise((resolve) => {
|
|
2053
2561
|
const upstream = http.request(
|
|
2054
2562
|
{
|
|
2055
2563
|
host: "127.0.0.1",
|
|
2056
2564
|
port: outboundPort,
|
|
2057
|
-
path,
|
|
2565
|
+
path: path2,
|
|
2058
2566
|
method: "POST",
|
|
2059
2567
|
headers: {
|
|
2060
2568
|
"Content-Type": "application/json",
|
|
@@ -2074,8 +2582,8 @@ function proxyToOutbound(res, outboundPort, path, key, body) {
|
|
|
2074
2582
|
});
|
|
2075
2583
|
}
|
|
2076
2584
|
);
|
|
2077
|
-
upstream.on("error", (
|
|
2078
|
-
if (!res.headersSent) writeJsonError(res, 502, `playground proxy failed: ${
|
|
2585
|
+
upstream.on("error", (err5) => {
|
|
2586
|
+
if (!res.headersSent) writeJsonError(res, 502, `playground proxy failed: ${err5.message}`);
|
|
2079
2587
|
else res.end();
|
|
2080
2588
|
resolve();
|
|
2081
2589
|
});
|
|
@@ -2084,463 +2592,105 @@ function proxyToOutbound(res, outboundPort, path, key, body) {
|
|
|
2084
2592
|
});
|
|
2085
2593
|
}
|
|
2086
2594
|
|
|
2087
|
-
// src/admin/
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); }
|
|
2114
|
-
function td(text) { var c = document.createElement('td'); c.textContent = text == null ? '' : String(text); return c; }
|
|
2115
|
-
function btn(label, cls, fn) { var b = document.createElement('button'); b.textContent = label; if (cls) b.className = cls; b.onclick = fn; return b; }
|
|
2116
|
-
|
|
2117
|
-
// ── Status ────────────────────────────────────────────────────────────────
|
|
2118
|
-
async function loadStatus() {
|
|
2119
|
-
var r = await api('GET', 'status');
|
|
2120
|
-
var s = r.json || {};
|
|
2121
|
-
$('statusBadge').textContent = s.running ? ('running :' + s.port) : 'stopped';
|
|
2122
|
-
var html = '';
|
|
2123
|
-
if (s.running) {
|
|
2124
|
-
html += 'Outbound server <span class="pill ok">running</span> on port ' + s.port + '<br/>';
|
|
2125
|
-
if (s.formats) {
|
|
2126
|
-
html += '<div class="mono muted">' +
|
|
2127
|
-
'chat: ' + s.formats.chat + '<br/>responses: ' + s.formats.responses +
|
|
2128
|
-
'<br/>messages: ' + s.formats.messages + '<br/>gemini: ' + s.formats.gemini + '</div>';
|
|
2129
|
-
}
|
|
2130
|
-
} else {
|
|
2131
|
-
html += 'Outbound server <span class="pill bad">stopped</span>';
|
|
2132
|
-
}
|
|
2133
|
-
$('statusBody').innerHTML = html;
|
|
2134
|
-
}
|
|
2135
|
-
|
|
2136
|
-
// ── Providers ───────────────────────────────────────────────────────────────
|
|
2137
|
-
// Curated presets loaded from GET /admin/api/presets. Selecting one prefills
|
|
2138
|
-
// the add-form (format/base/models); the WRITE still goes through the existing
|
|
2139
|
-
// POST/PUT /admin/api/providers path (no new write endpoint).
|
|
2140
|
-
var presetsById = {};
|
|
2141
|
-
var presetModels = []; // models staged by the last preset prefill
|
|
2142
|
-
|
|
2143
|
-
async function loadPresets() {
|
|
2144
|
-
var r = await api('GET', 'presets');
|
|
2145
|
-
var sel = $('pPreset');
|
|
2146
|
-
// Keep the placeholder; drop any previously appended options.
|
|
2147
|
-
while (sel.options.length > 1) sel.remove(1);
|
|
2148
|
-
presetsById = {};
|
|
2149
|
-
(r.json && r.json.presets || []).forEach(function (p) {
|
|
2150
|
-
presetsById[p.id] = p;
|
|
2151
|
-
var opt = document.createElement('option');
|
|
2152
|
-
opt.value = p.id;
|
|
2153
|
-
opt.textContent = p.name + ' (' + p.apiFormat + ')';
|
|
2154
|
-
sel.appendChild(opt);
|
|
2155
|
-
});
|
|
2156
|
-
}
|
|
2157
|
-
|
|
2158
|
-
function onPresetChange() {
|
|
2159
|
-
var p = presetsById[$('pPreset').value];
|
|
2160
|
-
if (!p) { presetModels = []; return; }
|
|
2161
|
-
if (!$('pId').value.trim()) $('pId').value = p.id;
|
|
2162
|
-
$('pFormat').value = p.apiFormat;
|
|
2163
|
-
$('pBase').value = p.baseUrl;
|
|
2164
|
-
presetModels = Array.isArray(p.models) ? p.models.slice() : [];
|
|
2165
|
-
}
|
|
2166
|
-
|
|
2167
|
-
async function loadProviders() {
|
|
2168
|
-
var r = await api('GET', 'providers');
|
|
2169
|
-
var body = $('providersTable').querySelector('tbody');
|
|
2170
|
-
clear(body);
|
|
2171
|
-
(r.json && r.json.providers || []).forEach(function (p) {
|
|
2172
|
-
var tr = document.createElement('tr');
|
|
2173
|
-
tr.appendChild(td(p.id));
|
|
2174
|
-
tr.appendChild(td(p.apiFormat));
|
|
2175
|
-
tr.appendChild(td(p.baseUrl));
|
|
2176
|
-
tr.appendChild(td(p.hasApiKey ? p.apiKeyMasked : '(none)'));
|
|
2177
|
-
var act = document.createElement('td');
|
|
2178
|
-
act.appendChild(btn('Edit', 'secondary', function () {
|
|
2179
|
-
$('pId').value = p.id; $('pFormat').value = p.apiFormat; $('pBase').value = p.baseUrl; $('pKey').value = '';
|
|
2180
|
-
}));
|
|
2181
|
-
act.appendChild(btn('Pool', 'secondary', function () { loadProviderKeys(p.id); }));
|
|
2182
|
-
act.appendChild(btn('Delete', 'danger', async function () {
|
|
2183
|
-
if (window.confirm('Delete provider ' + p.id + '?')) { await api('DELETE', 'providers/' + encodeURIComponent(p.id)); loadProviders(); }
|
|
2184
|
-
}));
|
|
2185
|
-
tr.appendChild(act);
|
|
2186
|
-
body.appendChild(tr);
|
|
2187
|
-
});
|
|
2188
|
-
}
|
|
2189
|
-
|
|
2190
|
-
// ── Pool health (read-only; key-pool change) ──────────────────────────────
|
|
2191
|
-
// GET /admin/api/providers/:id/keys → masked pool view. Multi-key is
|
|
2192
|
-
// cold-standby + observable in v1 (no outbound failover yet — see panel note).
|
|
2193
|
-
async function loadProviderKeys(providerId) {
|
|
2194
|
-
var r = await api('GET', 'providers/' + encodeURIComponent(providerId) + '/keys');
|
|
2195
|
-
var panel = $('poolPanel');
|
|
2196
|
-
var body = $('poolTable').querySelector('tbody');
|
|
2197
|
-
clear(body);
|
|
2198
|
-
$('poolTitle').textContent = 'API key pool — ' + providerId;
|
|
2199
|
-
(r.json && r.json.keys || []).forEach(function (k) {
|
|
2200
|
-
var tr = document.createElement('tr');
|
|
2201
|
-
tr.appendChild(td(k.id));
|
|
2202
|
-
tr.appendChild(td(k.label));
|
|
2203
|
-
tr.appendChild(td(k.apiKeyMasked));
|
|
2204
|
-
tr.appendChild(td(k.enabled ? 'yes' : 'no'));
|
|
2205
|
-
tr.appendChild(td(k.weight));
|
|
2206
|
-
var h = '';
|
|
2207
|
-
if (k.health && k.health.autoDisabled) h += 'auto-disabled (' + k.health.autoDisabled.status + ') ';
|
|
2208
|
-
if (k.health && k.health.cooldown) h += 'cooldown until ' + new Date(k.health.cooldown.until).toLocaleTimeString();
|
|
2209
|
-
tr.appendChild(td(h || 'ok'));
|
|
2210
|
-
body.appendChild(tr);
|
|
2211
|
-
});
|
|
2212
|
-
panel.style.display = 'block';
|
|
2213
|
-
}
|
|
2214
|
-
|
|
2215
|
-
async function saveProvider() {
|
|
2216
|
-
$('pErr').textContent = '';
|
|
2217
|
-
var id = $('pId').value.trim();
|
|
2218
|
-
if (!id) { $('pErr').textContent = 'id required'; return; }
|
|
2219
|
-
var payload = { id: id, apiFormat: $('pFormat').value, baseUrl: $('pBase').value.trim(), apiKey: $('pKey').value };
|
|
2220
|
-
// Carry the preset-prefilled models (existing parseProviderInput accepts them).
|
|
2221
|
-
if (presetModels.length) payload.models = presetModels;
|
|
2222
|
-
// Try PUT first (edit, blank key keeps existing); fall back to POST (create).
|
|
2223
|
-
var r = await api('PUT', 'providers/' + encodeURIComponent(id), payload);
|
|
2224
|
-
if (r.status === 404) r = await api('POST', 'providers', payload);
|
|
2225
|
-
if (r.status >= 400) { $('pErr').textContent = (r.json && r.json.error && r.json.error.message) || ('error ' + r.status); return; }
|
|
2226
|
-
$('pId').value = ''; $('pBase').value = ''; $('pKey').value = '';
|
|
2227
|
-
$('pPreset').value = ''; presetModels = []; // reset the picker after a write
|
|
2228
|
-
loadProviders();
|
|
2229
|
-
}
|
|
2230
|
-
|
|
2231
|
-
// ── Keys ────────────────────────────────────────────────────────────────────
|
|
2232
|
-
async function loadKeys() {
|
|
2233
|
-
var r = await api('GET', 'keys');
|
|
2234
|
-
var body = $('keysTable').querySelector('tbody');
|
|
2235
|
-
clear(body);
|
|
2236
|
-
(r.json && r.json.keys || []).forEach(function (k) {
|
|
2237
|
-
var tr = document.createElement('tr');
|
|
2238
|
-
tr.appendChild(td(k.name));
|
|
2239
|
-
tr.appendChild(td(k.keyPrefix));
|
|
2240
|
-
tr.appendChild(td(k.enabled ? 'yes' : 'no'));
|
|
2241
|
-
tr.appendChild(td(k.revoked ? 'yes' : 'no'));
|
|
2242
|
-
var act = document.createElement('td');
|
|
2243
|
-
if (!k.revoked) {
|
|
2244
|
-
act.appendChild(btn(k.enabled ? 'Disable' : 'Enable', 'secondary', async function () {
|
|
2245
|
-
await api('POST', 'keys/' + encodeURIComponent(k.id) + '/enabled', { enabled: !k.enabled }); loadKeys();
|
|
2246
|
-
}));
|
|
2247
|
-
act.appendChild(btn('Revoke', 'danger', async function () {
|
|
2248
|
-
if (window.confirm('Revoke ' + k.name + '?')) { await api('POST', 'keys/' + encodeURIComponent(k.id) + '/revoke'); loadKeys(); }
|
|
2249
|
-
}));
|
|
2250
|
-
}
|
|
2251
|
-
tr.appendChild(act);
|
|
2252
|
-
body.appendChild(tr);
|
|
2253
|
-
});
|
|
2254
|
-
}
|
|
2255
|
-
|
|
2256
|
-
function showKeyModal(plaintext) {
|
|
2257
|
-
$('keyPlaintext').textContent = plaintext;
|
|
2258
|
-
$('keyModalBg').classList.add('show');
|
|
2259
|
-
$('keyCopy').onclick = function () { navigator.clipboard && navigator.clipboard.writeText(plaintext); };
|
|
2260
|
-
$('keyClose').onclick = function () {
|
|
2261
|
-
$('keyModalBg').classList.remove('show');
|
|
2262
|
-
$('keyPlaintext').textContent = ''; // never persist the plaintext
|
|
2263
|
-
};
|
|
2264
|
-
}
|
|
2265
|
-
|
|
2266
|
-
async function createKey() {
|
|
2267
|
-
var name = $('kName').value.trim() || 'key';
|
|
2268
|
-
var r = await api('POST', 'keys', { name: name });
|
|
2269
|
-
if (r.json && r.json.plaintextOnce) { showKeyModal(r.json.plaintextOnce); $('kName').value = ''; loadKeys(); }
|
|
2270
|
-
}
|
|
2271
|
-
|
|
2272
|
-
// ── Server config ───────────────────────────────────────────────────────────
|
|
2273
|
-
async function loadServer() {
|
|
2274
|
-
var r = await api('GET', 'server');
|
|
2275
|
-
var s = (r.json && r.json.server) || {};
|
|
2276
|
-
$('sEnabled').checked = !!s.enabled;
|
|
2277
|
-
$('sLan').checked = !!s.networkBinding;
|
|
2278
|
-
$('sPort').value = s.port || '';
|
|
2279
|
-
var body = $('endpointsTable').querySelector('tbody');
|
|
2280
|
-
clear(body);
|
|
2281
|
-
(s.endpoints || []).forEach(function (e) {
|
|
2282
|
-
var tr = document.createElement('tr');
|
|
2283
|
-
tr.appendChild(td(e.endpoint));
|
|
2284
|
-
tr.appendChild(td(e.defaultModel));
|
|
2285
|
-
tr.appendChild(td(e.useSubscription ? 'yes' : 'no'));
|
|
2286
|
-
body.appendChild(tr);
|
|
2287
|
-
});
|
|
2595
|
+
// src/admin/uiStatic.ts
|
|
2596
|
+
import { existsSync as existsSync3, statSync } from "fs";
|
|
2597
|
+
import { readFile } from "fs/promises";
|
|
2598
|
+
import { createRequire } from "module";
|
|
2599
|
+
import path from "path";
|
|
2600
|
+
var CONTENT_TYPES = {
|
|
2601
|
+
".html": "text/html; charset=utf-8",
|
|
2602
|
+
".js": "text/javascript; charset=utf-8",
|
|
2603
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
2604
|
+
".css": "text/css; charset=utf-8",
|
|
2605
|
+
".json": "application/json; charset=utf-8",
|
|
2606
|
+
".svg": "image/svg+xml",
|
|
2607
|
+
".png": "image/png",
|
|
2608
|
+
".ico": "image/x-icon",
|
|
2609
|
+
".webp": "image/webp",
|
|
2610
|
+
".woff": "font/woff",
|
|
2611
|
+
".woff2": "font/woff2",
|
|
2612
|
+
".ttf": "font/ttf",
|
|
2613
|
+
".map": "application/json; charset=utf-8",
|
|
2614
|
+
".txt": "text/plain; charset=utf-8"
|
|
2615
|
+
};
|
|
2616
|
+
function resolveUiDist() {
|
|
2617
|
+
const fromEnv = process.env["OMNICROSS_UI_DIST"];
|
|
2618
|
+
if (fromEnv) {
|
|
2619
|
+
return existsSync3(path.join(fromEnv, "index.html")) ? path.resolve(fromEnv) : null;
|
|
2288
2620
|
}
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2621
|
+
try {
|
|
2622
|
+
const req = createRequire(typeof __filename !== "undefined" ? __filename : import.meta.url);
|
|
2623
|
+
const pkgJson = req.resolve("@omnicross/ui/package.json");
|
|
2624
|
+
const dist = path.join(path.dirname(pkgJson), "dist");
|
|
2625
|
+
return existsSync3(path.join(dist, "index.html")) ? dist : null;
|
|
2626
|
+
} catch {
|
|
2627
|
+
return null;
|
|
2296
2628
|
}
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
clear(body);
|
|
2305
|
-
(r.json && r.json.accounts || []).forEach(function (a) {
|
|
2306
|
-
var tr = document.createElement('tr');
|
|
2307
|
-
tr.appendChild(td(a.displayName || a.providerId));
|
|
2308
|
-
tr.appendChild(td(a.kind));
|
|
2309
|
-
var st = document.createElement('td');
|
|
2310
|
-
var ok = a.credentialStatus && a.credentialStatus.ok;
|
|
2311
|
-
var pill = document.createElement('span');
|
|
2312
|
-
pill.className = 'pill ' + (ok ? 'ok' : 'bad');
|
|
2313
|
-
pill.textContent = ok ? 'ok' : ((a.credentialStatus && a.credentialStatus.reason) || 'no credential');
|
|
2314
|
-
st.appendChild(pill);
|
|
2315
|
-
tr.appendChild(st);
|
|
2316
|
-
var act = document.createElement('td');
|
|
2317
|
-
act.appendChild(btn('Clear', 'danger', async function () {
|
|
2318
|
-
if (window.confirm('Clear ' + a.providerId + ' token?')) {
|
|
2319
|
-
await api('DELETE', 'accounts/' + encodeURIComponent(a.providerId));
|
|
2320
|
-
loadAccounts();
|
|
2321
|
-
}
|
|
2322
|
-
}));
|
|
2323
|
-
tr.appendChild(act);
|
|
2324
|
-
body.appendChild(tr);
|
|
2325
|
-
});
|
|
2326
|
-
renderProviderAccounts(r.json && r.json.providerAccounts || {});
|
|
2629
|
+
}
|
|
2630
|
+
async function handleUiStatic(req, res, urlPath, uiDist) {
|
|
2631
|
+
if (urlPath !== "/ui" && !urlPath.startsWith("/ui/")) return false;
|
|
2632
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2633
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
2634
|
+
res.end(JSON.stringify({ error: { type: "method_not_allowed", message: "GET/HEAD only" } }));
|
|
2635
|
+
return true;
|
|
2327
2636
|
}
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
(byProvider[provider] || []).forEach(function (acc) {
|
|
2336
|
-
var tr = document.createElement('tr');
|
|
2337
|
-
tr.appendChild(td(provider));
|
|
2338
|
-
tr.appendChild(td(acc.label || acc.id));
|
|
2339
|
-
tr.appendChild(td(acc.status));
|
|
2340
|
-
tr.appendChild(td(acc.isActive ? 'yes' : ''));
|
|
2341
|
-
var act = document.createElement('td');
|
|
2342
|
-
if (!acc.isActive) {
|
|
2343
|
-
act.appendChild(btn('Set active', '', async function () {
|
|
2344
|
-
await api('PUT', 'accounts/' + encodeURIComponent(provider) + '/active', { id: acc.id });
|
|
2345
|
-
loadAccounts();
|
|
2346
|
-
}));
|
|
2637
|
+
if (!uiDist) {
|
|
2638
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2639
|
+
res.end(
|
|
2640
|
+
JSON.stringify({
|
|
2641
|
+
error: {
|
|
2642
|
+
type: "ui_not_installed",
|
|
2643
|
+
message: "Control Panel UI not installed (@omnicross/ui has no built dist). Install/build @omnicross/ui or set OMNICROSS_UI_DIST."
|
|
2347
2644
|
}
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
loadAccounts();
|
|
2352
|
-
}
|
|
2353
|
-
}));
|
|
2354
|
-
tr.appendChild(act);
|
|
2355
|
-
body.appendChild(tr);
|
|
2356
|
-
});
|
|
2357
|
-
});
|
|
2358
|
-
}
|
|
2359
|
-
|
|
2360
|
-
async function saveAccount() {
|
|
2361
|
-
$('acErr').textContent = '';
|
|
2362
|
-
var provider = $('acProvider').value;
|
|
2363
|
-
var raw = $('acBody').value.trim();
|
|
2364
|
-
if (!raw) { $('acErr').textContent = 'paste a token JSON'; return; }
|
|
2365
|
-
var payload; try { payload = JSON.parse(raw); } catch (e) { $('acErr').textContent = 'invalid JSON'; return; }
|
|
2366
|
-
var r = await api('PUT', 'accounts/' + encodeURIComponent(provider), payload);
|
|
2367
|
-
if (r.status >= 400) { $('acErr').textContent = (r.json && r.json.error && r.json.error.message) || ('error ' + r.status); return; }
|
|
2368
|
-
$('acBody').value = ''; // never persist/echo the just-saved token
|
|
2369
|
-
loadAccounts();
|
|
2370
|
-
}
|
|
2371
|
-
|
|
2372
|
-
// ── Playground ──────────────────────────────────────────────────────────────
|
|
2373
|
-
async function sendPlayground() {
|
|
2374
|
-
var pre = $('plResponse');
|
|
2375
|
-
pre.classList.remove('muted');
|
|
2376
|
-
pre.textContent = 'sending…';
|
|
2377
|
-
var bodyText = $('plBody').value;
|
|
2378
|
-
var parsed; try { parsed = JSON.parse(bodyText); } catch (e) { parsed = bodyText; }
|
|
2379
|
-
var r = await fetch('/admin/api/playground', {
|
|
2380
|
-
method: 'POST',
|
|
2381
|
-
headers: headers({ 'Content-Type': 'application/json' }),
|
|
2382
|
-
body: JSON.stringify({ endpoint: $('plEndpoint').value, key: $('plKey').value, body: parsed }),
|
|
2383
|
-
});
|
|
2384
|
-
var text = await r.text();
|
|
2385
|
-
pre.textContent = '[' + r.status + ']\n' + text;
|
|
2645
|
+
})
|
|
2646
|
+
);
|
|
2647
|
+
return true;
|
|
2386
2648
|
}
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
$('kCreate').onclick = createKey;
|
|
2392
|
-
$('sSave').onclick = saveServer;
|
|
2393
|
-
$('acSave').onclick = saveAccount;
|
|
2394
|
-
$('plSend').onclick = sendPlayground;
|
|
2395
|
-
refresh();
|
|
2649
|
+
if (urlPath === "/ui") {
|
|
2650
|
+
res.writeHead(302, { Location: "/ui/" });
|
|
2651
|
+
res.end();
|
|
2652
|
+
return true;
|
|
2396
2653
|
}
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2654
|
+
let rel;
|
|
2655
|
+
try {
|
|
2656
|
+
rel = decodeURIComponent(urlPath.slice("/ui/".length));
|
|
2657
|
+
} catch {
|
|
2658
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2659
|
+
res.end(JSON.stringify({ error: { type: "bad_request", message: "malformed path" } }));
|
|
2660
|
+
return true;
|
|
2661
|
+
}
|
|
2662
|
+
if (rel.includes("\\") || rel.includes("\0")) {
|
|
2663
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2664
|
+
res.end(JSON.stringify({ error: { type: "bad_request", message: "invalid path" } }));
|
|
2665
|
+
return true;
|
|
2666
|
+
}
|
|
2667
|
+
const filePath = path.resolve(uiDist, rel === "" ? "index.html" : rel);
|
|
2668
|
+
if (filePath !== uiDist && !filePath.startsWith(uiDist + path.sep)) {
|
|
2669
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2670
|
+
res.end(JSON.stringify({ error: { type: "forbidden", message: "path outside ui root" } }));
|
|
2671
|
+
return true;
|
|
2672
|
+
}
|
|
2673
|
+
let target = filePath;
|
|
2674
|
+
if (!existsSync3(target) || statSync(target).isDirectory()) {
|
|
2675
|
+
if (path.extname(rel) === "") {
|
|
2676
|
+
target = path.join(uiDist, "index.html");
|
|
2677
|
+
} else {
|
|
2678
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2679
|
+
res.end(JSON.stringify({ error: { type: "not_found", message: "no such ui asset" } }));
|
|
2680
|
+
return true;
|
|
2681
|
+
}
|
|
2400
2682
|
}
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
// src/admin/html.ts
|
|
2408
|
-
var STYLE = `
|
|
2409
|
-
:root { --bg:#0f1115; --panel:#171a21; --line:#272b35; --fg:#e6e8ec; --muted:#8b91a0; --accent:#5b8cff; --danger:#ff5b6e; --ok:#3ecf8e; }
|
|
2410
|
-
* { box-sizing: border-box; }
|
|
2411
|
-
body { margin:0; font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--fg); }
|
|
2412
|
-
header { padding:14px 20px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:12px; }
|
|
2413
|
-
header h1 { font-size:16px; margin:0; font-weight:600; }
|
|
2414
|
-
header .badge { font-size:12px; color:var(--muted); }
|
|
2415
|
-
main { padding:20px; display:grid; gap:20px; max-width:980px; margin:0 auto; }
|
|
2416
|
-
section { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:16px; }
|
|
2417
|
-
section h2 { font-size:14px; margin:0 0 12px; font-weight:600; }
|
|
2418
|
-
table { width:100%; border-collapse:collapse; font-size:13px; }
|
|
2419
|
-
th, td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); }
|
|
2420
|
-
th { color:var(--muted); font-weight:500; }
|
|
2421
|
-
input, select, textarea { background:var(--bg); color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:6px 8px; font:inherit; }
|
|
2422
|
-
textarea { width:100%; min-height:90px; resize:vertical; font-family:ui-monospace,Menlo,monospace; }
|
|
2423
|
-
button { background:var(--accent); color:#fff; border:0; border-radius:6px; padding:6px 12px; cursor:pointer; font:inherit; }
|
|
2424
|
-
button.secondary { background:#2a2f3a; }
|
|
2425
|
-
button.danger { background:var(--danger); }
|
|
2426
|
-
.row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:8px; }
|
|
2427
|
-
.muted { color:var(--muted); }
|
|
2428
|
-
.mono { font-family:ui-monospace,Menlo,monospace; }
|
|
2429
|
-
.pill { padding:1px 8px; border-radius:999px; font-size:12px; }
|
|
2430
|
-
.pill.ok { background:rgba(62,207,142,.15); color:var(--ok); }
|
|
2431
|
-
.pill.bad { background:rgba(255,91,110,.15); color:var(--danger); }
|
|
2432
|
-
pre { background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:10px; overflow:auto; max-height:320px; white-space:pre-wrap; }
|
|
2433
|
-
.modal-bg { position:fixed; inset:0; background:rgba(0,0,0,.6); display:none; align-items:center; justify-content:center; }
|
|
2434
|
-
.modal-bg.show { display:flex; }
|
|
2435
|
-
.modal { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:20px; max-width:520px; width:90%; }
|
|
2436
|
-
.warn { color:var(--danger); font-size:13px; margin:8px 0; }
|
|
2437
|
-
.err { color:var(--danger); font-size:13px; }
|
|
2438
|
-
`;
|
|
2439
|
-
var BODY = `
|
|
2440
|
-
<header>
|
|
2441
|
-
<h1>omnicross daemon dashboard</h1>
|
|
2442
|
-
<span class="badge" id="statusBadge">connecting\u2026</span>
|
|
2443
|
-
</header>
|
|
2444
|
-
<main>
|
|
2445
|
-
<section id="statusSection">
|
|
2446
|
-
<h2>Runtime status</h2>
|
|
2447
|
-
<div id="statusBody" class="muted">loading\u2026</div>
|
|
2448
|
-
</section>
|
|
2449
|
-
|
|
2450
|
-
<section>
|
|
2451
|
-
<h2>Providers</h2>
|
|
2452
|
-
<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>
|
|
2453
|
-
<div class="row">
|
|
2454
|
-
<select id="pPreset"><option value="">-- \u9009\u62E9\u9884\u7F6E --</option></select>
|
|
2455
|
-
<input id="pId" placeholder="id" size="10" />
|
|
2456
|
-
<select id="pFormat"><option value="openai">openai</option><option value="anthropic">anthropic</option><option value="gemini">gemini</option></select>
|
|
2457
|
-
<input id="pBase" placeholder="base URL" size="26" />
|
|
2458
|
-
<input id="pKey" placeholder="apiKey (blank = keep on edit)" size="22" />
|
|
2459
|
-
<button id="pSave">Save provider</button>
|
|
2460
|
-
</div>
|
|
2461
|
-
<div class="err" id="pErr"></div>
|
|
2462
|
-
<div id="poolPanel" style="display:none; margin-top:12px;">
|
|
2463
|
-
<h2 id="poolTitle" style="font-size:13px;">API key pool</h2>
|
|
2464
|
-
<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>
|
|
2465
|
-
<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>
|
|
2466
|
-
</div>
|
|
2467
|
-
</section>
|
|
2468
|
-
|
|
2469
|
-
<section>
|
|
2470
|
-
<h2>Named keys</h2>
|
|
2471
|
-
<table id="keysTable"><thead><tr><th>name</th><th>prefix</th><th>enabled</th><th>revoked</th><th></th></tr></thead><tbody></tbody></table>
|
|
2472
|
-
<div class="row">
|
|
2473
|
-
<input id="kName" placeholder="key name" size="16" />
|
|
2474
|
-
<button id="kCreate">Create key</button>
|
|
2475
|
-
</div>
|
|
2476
|
-
</section>
|
|
2477
|
-
|
|
2478
|
-
<section>
|
|
2479
|
-
<h2>Server config</h2>
|
|
2480
|
-
<div class="row">
|
|
2481
|
-
<label><input type="checkbox" id="sEnabled" /> enabled</label>
|
|
2482
|
-
<label><input type="checkbox" id="sLan" /> networkBinding (LAN)</label>
|
|
2483
|
-
<label>port <input id="sPort" size="6" /></label>
|
|
2484
|
-
<button id="sSave">Apply server config</button>
|
|
2485
|
-
</div>
|
|
2486
|
-
<table id="endpointsTable"><thead><tr><th>endpoint</th><th>defaultModel</th><th>subscription</th></tr></thead><tbody></tbody></table>
|
|
2487
|
-
</section>
|
|
2488
|
-
|
|
2489
|
-
<section>
|
|
2490
|
-
<h2>Accounts <span class="muted">(subscription tokens)</span></h2>
|
|
2491
|
-
<table id="accountsTable"><thead><tr><th>provider</th><th>kind</th><th>status</th><th></th></tr></thead><tbody></tbody></table>
|
|
2492
|
-
<h3>Per-provider accounts <span class="muted">(multi-account \u2014 sanitized, no tokens)</span></h3>
|
|
2493
|
-
<table id="providerAccountsTable"><thead><tr><th>provider</th><th>label</th><th>status</th><th>active</th><th></th></tr></thead><tbody></tbody></table>
|
|
2494
|
-
<div class="row">
|
|
2495
|
-
<select id="acProvider"><option value="claude">claude</option><option value="codex">codex</option><option value="gemini">gemini</option><option value="opencodego">opencodego</option></select>
|
|
2496
|
-
<button id="acSave">Save token</button>
|
|
2497
|
-
</div>
|
|
2498
|
-
<textarea id="acBody" placeholder='{"authMethod":"oauth","status":"authorized","accessToken":"\u2026","refreshToken":"\u2026"}'></textarea>
|
|
2499
|
-
<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>
|
|
2500
|
-
<div class="err" id="acErr"></div>
|
|
2501
|
-
</section>
|
|
2502
|
-
|
|
2503
|
-
<section>
|
|
2504
|
-
<h2>Playground</h2>
|
|
2505
|
-
<div class="row">
|
|
2506
|
-
<select id="plEndpoint"><option value="chat">chat</option><option value="responses">responses</option><option value="messages">messages</option><option value="gemini">gemini</option></select>
|
|
2507
|
-
<input id="plKey" placeholder="named key (sk-omnicross-\u2026)" size="30" />
|
|
2508
|
-
<button id="plSend">Send</button>
|
|
2509
|
-
</div>
|
|
2510
|
-
<textarea id="plBody">{"model":"","messages":[{"role":"user","content":"ping"}]}</textarea>
|
|
2511
|
-
<pre id="plResponse" class="muted">response will appear here</pre>
|
|
2512
|
-
</section>
|
|
2513
|
-
</main>
|
|
2514
|
-
|
|
2515
|
-
<div class="modal-bg" id="keyModalBg">
|
|
2516
|
-
<div class="modal">
|
|
2517
|
-
<h2>Key created</h2>
|
|
2518
|
-
<p class="warn">This secret is shown ONCE. Copy it now \u2014 it cannot be retrieved again.</p>
|
|
2519
|
-
<pre id="keyPlaintext" class="mono"></pre>
|
|
2520
|
-
<div class="row">
|
|
2521
|
-
<button id="keyCopy">Copy</button>
|
|
2522
|
-
<button class="secondary" id="keyClose">Close</button>
|
|
2523
|
-
</div>
|
|
2524
|
-
</div>
|
|
2525
|
-
</div>
|
|
2526
|
-
`;
|
|
2527
|
-
var DASHBOARD_HTML = `<!doctype html>
|
|
2528
|
-
<html lang="en">
|
|
2529
|
-
<head>
|
|
2530
|
-
<meta charset="utf-8" />
|
|
2531
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2532
|
-
<title>omnicross daemon dashboard</title>
|
|
2533
|
-
<style>${STYLE}</style>
|
|
2534
|
-
</head>
|
|
2535
|
-
<body>
|
|
2536
|
-
${BODY}
|
|
2537
|
-
<script>${DASHBOARD_JS}</script>
|
|
2538
|
-
</body>
|
|
2539
|
-
</html>`;
|
|
2683
|
+
const body = await readFile(target);
|
|
2684
|
+
const type = CONTENT_TYPES[path.extname(target).toLowerCase()] ?? "application/octet-stream";
|
|
2685
|
+
res.writeHead(200, { "Content-Type": type, "Content-Length": body.length });
|
|
2686
|
+
res.end(req.method === "HEAD" ? void 0 : body);
|
|
2687
|
+
return true;
|
|
2688
|
+
}
|
|
2540
2689
|
|
|
2541
2690
|
// src/admin/AdminServer.ts
|
|
2542
2691
|
var LOOPBACK_ADDR = "127.0.0.1";
|
|
2543
2692
|
var LAN_ADDR = "0.0.0.0";
|
|
2693
|
+
var DAEMON_VERSION = true ? "0.1.2" : "0.0.0-dev";
|
|
2544
2694
|
var AdminServer = class {
|
|
2545
2695
|
constructor(deps) {
|
|
2546
2696
|
this.deps = deps;
|
|
@@ -2549,6 +2699,8 @@ var AdminServer = class {
|
|
|
2549
2699
|
server = null;
|
|
2550
2700
|
boundPort = 0;
|
|
2551
2701
|
boundAddr = LOOPBACK_ADDR;
|
|
2702
|
+
/** Control Panel dist dir (resolved once at first request; null = no UI). */
|
|
2703
|
+
uiDist;
|
|
2552
2704
|
/**
|
|
2553
2705
|
* Start the admin listener honoring the resolved admin config. Returns the
|
|
2554
2706
|
* actual bound port, or `0` when it refuses/declines to bind (disabled or the
|
|
@@ -2577,13 +2729,13 @@ var AdminServer = class {
|
|
|
2577
2729
|
const server = http2.createServer((req, res) => {
|
|
2578
2730
|
this.onRequest(req, res);
|
|
2579
2731
|
});
|
|
2580
|
-
const onError = (
|
|
2581
|
-
if (
|
|
2732
|
+
const onError = (err5) => {
|
|
2733
|
+
if (err5.code === "EADDRINUSE" && port !== 0) {
|
|
2582
2734
|
server.removeListener("error", onError);
|
|
2583
2735
|
this.listen(bindAddr, 0).then(resolve, reject);
|
|
2584
2736
|
return;
|
|
2585
2737
|
}
|
|
2586
|
-
reject(
|
|
2738
|
+
reject(err5);
|
|
2587
2739
|
};
|
|
2588
2740
|
server.on("error", onError);
|
|
2589
2741
|
server.listen(port, bindAddr, () => {
|
|
@@ -2601,8 +2753,8 @@ var AdminServer = class {
|
|
|
2601
2753
|
}
|
|
2602
2754
|
/** Per-request handler: auth gate (when a token is set) → routing. */
|
|
2603
2755
|
onRequest(req, res) {
|
|
2604
|
-
void this.dispatch(req, res).catch((
|
|
2605
|
-
const message =
|
|
2756
|
+
void this.dispatch(req, res).catch((err5) => {
|
|
2757
|
+
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
2606
2758
|
console.error("[AdminServer] unhandled error:", message);
|
|
2607
2759
|
if (!res.headersSent) {
|
|
2608
2760
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
@@ -2612,22 +2764,26 @@ var AdminServer = class {
|
|
|
2612
2764
|
}
|
|
2613
2765
|
async dispatch(req, res) {
|
|
2614
2766
|
const cfg = this.deps.getAdminConfig();
|
|
2767
|
+
res.setHeader("x-omnicross-daemon", DAEMON_VERSION);
|
|
2768
|
+
res.setHeader("x-omnicross-pid", String(process.pid));
|
|
2615
2769
|
if (cfg.token && !this.isAuthorized(req, cfg.token)) {
|
|
2616
2770
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
2617
2771
|
res.end(JSON.stringify({ error: { type: "unauthorized", message: "admin token required" } }));
|
|
2618
2772
|
return;
|
|
2619
2773
|
}
|
|
2620
2774
|
const url = req.url ?? "/";
|
|
2621
|
-
const
|
|
2622
|
-
if ((req.method === "GET" || req.method === "HEAD") && (
|
|
2623
|
-
res.writeHead(
|
|
2624
|
-
res.end(
|
|
2775
|
+
const path2 = url.split("?")[0];
|
|
2776
|
+
if ((req.method === "GET" || req.method === "HEAD") && (path2 === "/" || path2 === "/admin")) {
|
|
2777
|
+
res.writeHead(302, { Location: "/ui/" });
|
|
2778
|
+
res.end();
|
|
2625
2779
|
return;
|
|
2626
2780
|
}
|
|
2627
|
-
if (
|
|
2628
|
-
await handleAdminApi(req, res,
|
|
2781
|
+
if (path2.startsWith("/admin/api/")) {
|
|
2782
|
+
await handleAdminApi(req, res, path2, this.deps);
|
|
2629
2783
|
return;
|
|
2630
2784
|
}
|
|
2785
|
+
if (this.uiDist === void 0) this.uiDist = resolveUiDist();
|
|
2786
|
+
if (await handleUiStatic(req, res, path2, this.uiDist)) return;
|
|
2631
2787
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2632
2788
|
res.end(JSON.stringify({ error: { type: "not_found", message: "no such admin route" } }));
|
|
2633
2789
|
}
|
|
@@ -2750,18 +2906,18 @@ function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
2750
2906
|
res.end(pageHtml("Login complete."));
|
|
2751
2907
|
finish(server, () => resolve(code));
|
|
2752
2908
|
});
|
|
2753
|
-
server.on("error", (
|
|
2909
|
+
server.on("error", (err5) => {
|
|
2754
2910
|
if (settled) return;
|
|
2755
2911
|
settled = true;
|
|
2756
2912
|
clearTimeout(timer);
|
|
2757
|
-
if (
|
|
2913
|
+
if (err5.code === "EADDRINUSE") {
|
|
2758
2914
|
reject(
|
|
2759
2915
|
new Error(
|
|
2760
2916
|
`login: cannot bind ${LOOPBACK_HOST}:${LOOPBACK_PORT} (address in use) \u2014 another codex login or process is holding the port`
|
|
2761
2917
|
)
|
|
2762
2918
|
);
|
|
2763
2919
|
} else {
|
|
2764
|
-
reject(
|
|
2920
|
+
reject(err5);
|
|
2765
2921
|
}
|
|
2766
2922
|
});
|
|
2767
2923
|
const timer = setTimeout(() => {
|
|
@@ -2836,6 +2992,15 @@ function createPoolKeysLoader(getProviderRow, autoDisabled) {
|
|
|
2836
2992
|
};
|
|
2837
2993
|
}
|
|
2838
2994
|
|
|
2995
|
+
// src/commands/paths.ts
|
|
2996
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
2997
|
+
function defaultPricingPath(configPath) {
|
|
2998
|
+
return join3(dirname2(configPath), "pricing.json");
|
|
2999
|
+
}
|
|
3000
|
+
function defaultUsageEventsPath(configPath) {
|
|
3001
|
+
return join3(dirname2(configPath), "usage-events.jsonl");
|
|
3002
|
+
}
|
|
3003
|
+
|
|
2839
3004
|
// src/ports/ConfigFileProviderConfigSource.ts
|
|
2840
3005
|
import {
|
|
2841
3006
|
registerBuiltinTransformers,
|
|
@@ -3048,8 +3213,211 @@ var JsonApiServerSettingsStore = class {
|
|
|
3048
3213
|
}
|
|
3049
3214
|
};
|
|
3050
3215
|
|
|
3216
|
+
// src/ports/JsonlUsageEventStore.ts
|
|
3217
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
3218
|
+
import { appendFileSync, existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
|
|
3219
|
+
var JsonlUsageEventStore = class {
|
|
3220
|
+
constructor(eventsPath, isPriced) {
|
|
3221
|
+
this.eventsPath = eventsPath;
|
|
3222
|
+
this.isPriced = isPriced;
|
|
3223
|
+
}
|
|
3224
|
+
eventsPath;
|
|
3225
|
+
isPriced;
|
|
3226
|
+
/** Persist one event: assign `id`, stamp `ts` when absent, append ONE line. */
|
|
3227
|
+
async insert(input) {
|
|
3228
|
+
const row = {
|
|
3229
|
+
...input,
|
|
3230
|
+
id: randomUUID3(),
|
|
3231
|
+
ts: input.ts ?? Date.now()
|
|
3232
|
+
};
|
|
3233
|
+
appendFileSync(this.eventsPath, JSON.stringify(row) + "\n", "utf8");
|
|
3234
|
+
return row.id;
|
|
3235
|
+
}
|
|
3236
|
+
async getTotals(range) {
|
|
3237
|
+
const totals = {
|
|
3238
|
+
inputTokens: 0,
|
|
3239
|
+
outputTokens: 0,
|
|
3240
|
+
cacheReadTokens: 0,
|
|
3241
|
+
cacheCreationTokens: 0,
|
|
3242
|
+
reasoningTokens: 0,
|
|
3243
|
+
costUsd: 0,
|
|
3244
|
+
costSavedByCacheUsd: 0,
|
|
3245
|
+
eventCount: 0
|
|
3246
|
+
};
|
|
3247
|
+
for (const row of this.readRows(range)) {
|
|
3248
|
+
totals.inputTokens += row.inputTokens;
|
|
3249
|
+
totals.outputTokens += row.outputTokens;
|
|
3250
|
+
totals.cacheReadTokens += row.cacheReadTokens;
|
|
3251
|
+
totals.cacheCreationTokens += row.cacheCreationTokens;
|
|
3252
|
+
totals.reasoningTokens += row.reasoningTokens;
|
|
3253
|
+
totals.costUsd += row.costUsd;
|
|
3254
|
+
totals.costSavedByCacheUsd += row.costSavedByCacheUsd;
|
|
3255
|
+
totals.eventCount += 1;
|
|
3256
|
+
}
|
|
3257
|
+
return totals;
|
|
3258
|
+
}
|
|
3259
|
+
async getByModel(range) {
|
|
3260
|
+
const groups = /* @__PURE__ */ new Map();
|
|
3261
|
+
for (const row of this.readRows(range)) {
|
|
3262
|
+
const key = `${row.providerId}::${row.model}`;
|
|
3263
|
+
let g = groups.get(key);
|
|
3264
|
+
if (!g) {
|
|
3265
|
+
g = {
|
|
3266
|
+
providerId: row.providerId,
|
|
3267
|
+
model: row.model,
|
|
3268
|
+
eventCount: 0,
|
|
3269
|
+
inputTokens: 0,
|
|
3270
|
+
outputTokens: 0,
|
|
3271
|
+
cacheReadTokens: 0,
|
|
3272
|
+
cacheCreationTokens: 0,
|
|
3273
|
+
costUsd: 0,
|
|
3274
|
+
costSavedByCacheUsd: 0,
|
|
3275
|
+
unpriced: false
|
|
3276
|
+
};
|
|
3277
|
+
groups.set(key, g);
|
|
3278
|
+
}
|
|
3279
|
+
g.eventCount += 1;
|
|
3280
|
+
g.inputTokens += row.inputTokens;
|
|
3281
|
+
g.outputTokens += row.outputTokens;
|
|
3282
|
+
g.cacheReadTokens += row.cacheReadTokens;
|
|
3283
|
+
g.cacheCreationTokens += row.cacheCreationTokens;
|
|
3284
|
+
g.costUsd += row.costUsd;
|
|
3285
|
+
g.costSavedByCacheUsd += row.costSavedByCacheUsd;
|
|
3286
|
+
}
|
|
3287
|
+
const rows = Array.from(groups.values());
|
|
3288
|
+
for (const g of rows) {
|
|
3289
|
+
g.unpriced = !await this.isPriced(g.providerId, g.model);
|
|
3290
|
+
}
|
|
3291
|
+
return rows;
|
|
3292
|
+
}
|
|
3293
|
+
/**
|
|
3294
|
+
* Group by RAW apiKeyId (null forms the unattributed sentinel group). Label
|
|
3295
|
+
* here is the raw id fallback — the admin handler resolves display labels
|
|
3296
|
+
* against the configured pool keys (the store stays config-schema-free).
|
|
3297
|
+
*/
|
|
3298
|
+
async getByApiKey(range) {
|
|
3299
|
+
const groups = /* @__PURE__ */ new Map();
|
|
3300
|
+
for (const row of this.readRows(range)) {
|
|
3301
|
+
const key = row.apiKeyId;
|
|
3302
|
+
let g = groups.get(key);
|
|
3303
|
+
if (!g) {
|
|
3304
|
+
g = {
|
|
3305
|
+
apiKeyId: key,
|
|
3306
|
+
label: key ?? "unattributed",
|
|
3307
|
+
providerId: key === null ? null : row.providerId,
|
|
3308
|
+
eventCount: 0,
|
|
3309
|
+
inputTokens: 0,
|
|
3310
|
+
outputTokens: 0,
|
|
3311
|
+
costUsd: 0
|
|
3312
|
+
};
|
|
3313
|
+
groups.set(key, g);
|
|
3314
|
+
}
|
|
3315
|
+
g.eventCount += 1;
|
|
3316
|
+
g.inputTokens += row.inputTokens;
|
|
3317
|
+
g.outputTokens += row.outputTokens;
|
|
3318
|
+
g.costUsd += row.costUsd;
|
|
3319
|
+
}
|
|
3320
|
+
return Array.from(groups.values());
|
|
3321
|
+
}
|
|
3322
|
+
async getMessagesForSession(sessionId) {
|
|
3323
|
+
return this.readAllRows().filter((r) => r.sessionId === sessionId).sort((a, b) => a.ts - b.ts).map((r) => ({
|
|
3324
|
+
id: r.id,
|
|
3325
|
+
ts: r.ts,
|
|
3326
|
+
messageId: r.messageId,
|
|
3327
|
+
parentMessageId: r.parentMessageId,
|
|
3328
|
+
sessionId: r.sessionId,
|
|
3329
|
+
providerId: r.providerId,
|
|
3330
|
+
model: r.model,
|
|
3331
|
+
apiKeyId: r.apiKeyId,
|
|
3332
|
+
engineOrigin: r.engineOrigin,
|
|
3333
|
+
inputTokens: r.inputTokens,
|
|
3334
|
+
outputTokens: r.outputTokens,
|
|
3335
|
+
cacheReadTokens: r.cacheReadTokens,
|
|
3336
|
+
cacheCreationTokens: r.cacheCreationTokens,
|
|
3337
|
+
reasoningTokens: r.reasoningTokens,
|
|
3338
|
+
costUsd: r.costUsd,
|
|
3339
|
+
costSavedByCacheUsd: r.costSavedByCacheUsd
|
|
3340
|
+
}));
|
|
3341
|
+
}
|
|
3342
|
+
async getSessionCacheStats(sessionId) {
|
|
3343
|
+
const stats = {
|
|
3344
|
+
sessionId,
|
|
3345
|
+
inputTokens: 0,
|
|
3346
|
+
cacheReadTokens: 0,
|
|
3347
|
+
cacheCreationTokens: 0,
|
|
3348
|
+
outputTokens: 0,
|
|
3349
|
+
eventCount: 0,
|
|
3350
|
+
hitRate: 0
|
|
3351
|
+
};
|
|
3352
|
+
for (const r of this.readAllRows()) {
|
|
3353
|
+
if (r.sessionId !== sessionId) continue;
|
|
3354
|
+
stats.inputTokens += r.inputTokens;
|
|
3355
|
+
stats.cacheReadTokens += r.cacheReadTokens;
|
|
3356
|
+
stats.cacheCreationTokens += r.cacheCreationTokens;
|
|
3357
|
+
stats.outputTokens += r.outputTokens;
|
|
3358
|
+
stats.eventCount += 1;
|
|
3359
|
+
}
|
|
3360
|
+
const promptSide = stats.inputTokens + stats.cacheReadTokens + stats.cacheCreationTokens;
|
|
3361
|
+
stats.hitRate = promptSide > 0 ? stats.cacheReadTokens / promptSide : 0;
|
|
3362
|
+
return stats;
|
|
3363
|
+
}
|
|
3364
|
+
/** Rows inside `startTs <= ts < endTs` (endTs EXCLUSIVE). */
|
|
3365
|
+
readRows(range) {
|
|
3366
|
+
return this.readAllRows().filter((r) => r.ts >= range.startTs && r.ts < range.endTs);
|
|
3367
|
+
}
|
|
3368
|
+
/** Parse every line, skipping malformed/torn lines defensively. */
|
|
3369
|
+
readAllRows() {
|
|
3370
|
+
if (!existsSync4(this.eventsPath)) return [];
|
|
3371
|
+
let raw;
|
|
3372
|
+
try {
|
|
3373
|
+
raw = readFileSync4(this.eventsPath, "utf8");
|
|
3374
|
+
} catch {
|
|
3375
|
+
return [];
|
|
3376
|
+
}
|
|
3377
|
+
const rows = [];
|
|
3378
|
+
for (const line of raw.split("\n")) {
|
|
3379
|
+
const trimmed = line.trim();
|
|
3380
|
+
if (!trimmed) continue;
|
|
3381
|
+
try {
|
|
3382
|
+
const parsed = JSON.parse(trimmed);
|
|
3383
|
+
if (isUsageEventRecord(parsed)) rows.push(parsed);
|
|
3384
|
+
} catch {
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
return rows;
|
|
3388
|
+
}
|
|
3389
|
+
};
|
|
3390
|
+
var NUMERIC_FIELDS = [
|
|
3391
|
+
"ts",
|
|
3392
|
+
"inputTokens",
|
|
3393
|
+
"outputTokens",
|
|
3394
|
+
"cacheReadTokens",
|
|
3395
|
+
"cacheCreationTokens",
|
|
3396
|
+
"reasoningTokens",
|
|
3397
|
+
"costUsd",
|
|
3398
|
+
"costSavedByCacheUsd"
|
|
3399
|
+
];
|
|
3400
|
+
var NULLABLE_STRING_FIELDS = ["messageId", "parentMessageId", "sessionId", "apiKeyId"];
|
|
3401
|
+
var isStringOrNull = (v) => v === null || typeof v === "string";
|
|
3402
|
+
function isUsageEventRecord(parsed) {
|
|
3403
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
|
|
3404
|
+
const r = parsed;
|
|
3405
|
+
if (typeof r["id"] !== "string") return false;
|
|
3406
|
+
if (typeof r["providerId"] !== "string") return false;
|
|
3407
|
+
if (typeof r["model"] !== "string") return false;
|
|
3408
|
+
if (typeof r["engineOrigin"] !== "string") return false;
|
|
3409
|
+
for (const f of NULLABLE_STRING_FIELDS) {
|
|
3410
|
+
if (!isStringOrNull(r[f])) return false;
|
|
3411
|
+
}
|
|
3412
|
+
for (const f of NUMERIC_FIELDS) {
|
|
3413
|
+
const v = r[f];
|
|
3414
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return false;
|
|
3415
|
+
}
|
|
3416
|
+
return true;
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3051
3419
|
// src/ports/JsonOutboundKeyDb.ts
|
|
3052
|
-
import { existsSync as
|
|
3420
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
3053
3421
|
var JsonOutboundKeyDb = class {
|
|
3054
3422
|
constructor(keysPath) {
|
|
3055
3423
|
this.keysPath = keysPath;
|
|
@@ -3113,9 +3481,9 @@ var JsonOutboundKeyDb = class {
|
|
|
3113
3481
|
}
|
|
3114
3482
|
/** Read the key rows, tolerating a missing/corrupt file (→ empty list). */
|
|
3115
3483
|
readRows() {
|
|
3116
|
-
if (!
|
|
3484
|
+
if (!existsSync5(this.keysPath)) return [];
|
|
3117
3485
|
try {
|
|
3118
|
-
const parsed = JSON.parse(
|
|
3486
|
+
const parsed = JSON.parse(readFileSync5(this.keysPath, "utf8"));
|
|
3119
3487
|
return Array.isArray(parsed) ? parsed : [];
|
|
3120
3488
|
} catch {
|
|
3121
3489
|
return [];
|
|
@@ -3126,14 +3494,357 @@ var JsonOutboundKeyDb = class {
|
|
|
3126
3494
|
}
|
|
3127
3495
|
};
|
|
3128
3496
|
|
|
3497
|
+
// src/ports/JsonPricingStore.ts
|
|
3498
|
+
import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
3499
|
+
var JsonPricingStore = class {
|
|
3500
|
+
constructor(pricingPath) {
|
|
3501
|
+
this.pricingPath = pricingPath;
|
|
3502
|
+
}
|
|
3503
|
+
pricingPath;
|
|
3504
|
+
async getAll() {
|
|
3505
|
+
return this.readRows();
|
|
3506
|
+
}
|
|
3507
|
+
/**
|
|
3508
|
+
* Insert or update one row keyed (providerId, modelId). `asUserEdit` stamps
|
|
3509
|
+
* user provenance (source 'user', userEdited, editedAt now) so the row is
|
|
3510
|
+
* protected from auto-overwrite during source refreshes; a non-user upsert
|
|
3511
|
+
* stamps source 'litellm' and clears nothing it should not (a plain source
|
|
3512
|
+
* upsert through this method overwrites the row wholesale).
|
|
3513
|
+
*/
|
|
3514
|
+
async upsert(input, asUserEdit) {
|
|
3515
|
+
const rows = this.readRows();
|
|
3516
|
+
const entry = this.applyUpsert(rows, input, asUserEdit);
|
|
3517
|
+
this.writeRows(rows);
|
|
3518
|
+
return entry;
|
|
3519
|
+
}
|
|
3520
|
+
/**
|
|
3521
|
+
* Apply a batch fetched from a pricing source. Rows whose local copy is
|
|
3522
|
+
* user-edited are NOT applied — they come back as `{ current, incoming }`
|
|
3523
|
+
* conflicts; everything else is upserted (source 'litellm'). ONE file write
|
|
3524
|
+
* for the whole batch.
|
|
3525
|
+
*/
|
|
3526
|
+
async bulkApplyFromSource(entries) {
|
|
3527
|
+
const rows = this.readRows();
|
|
3528
|
+
const applied = [];
|
|
3529
|
+
const conflicts = [];
|
|
3530
|
+
for (const incoming of entries) {
|
|
3531
|
+
const current = rows.find(
|
|
3532
|
+
(r) => r.providerId === incoming.providerId && r.modelId === incoming.modelId
|
|
3533
|
+
);
|
|
3534
|
+
if (current && current.userEdited) {
|
|
3535
|
+
conflicts.push({ current, incoming });
|
|
3536
|
+
continue;
|
|
3537
|
+
}
|
|
3538
|
+
applied.push(this.applyUpsert(
|
|
3539
|
+
rows,
|
|
3540
|
+
incoming,
|
|
3541
|
+
/* asUserEdit */
|
|
3542
|
+
false
|
|
3543
|
+
));
|
|
3544
|
+
}
|
|
3545
|
+
if (applied.length > 0) this.writeRows(rows);
|
|
3546
|
+
return { applied, conflicts };
|
|
3547
|
+
}
|
|
3548
|
+
/**
|
|
3549
|
+
* Apply per-row conflict decisions: 'overwrite' replaces the local row with
|
|
3550
|
+
* the incoming values (clearing the user-edited mark), 'skip' counts only.
|
|
3551
|
+
*/
|
|
3552
|
+
async applyResolutions(resolutions) {
|
|
3553
|
+
const rows = this.readRows();
|
|
3554
|
+
let overwrittenCount = 0;
|
|
3555
|
+
let skippedCount = 0;
|
|
3556
|
+
for (const r of resolutions) {
|
|
3557
|
+
if (r.action === "skip") {
|
|
3558
|
+
skippedCount += 1;
|
|
3559
|
+
continue;
|
|
3560
|
+
}
|
|
3561
|
+
this.applyUpsert(
|
|
3562
|
+
rows,
|
|
3563
|
+
r.incoming,
|
|
3564
|
+
/* asUserEdit */
|
|
3565
|
+
false
|
|
3566
|
+
);
|
|
3567
|
+
overwrittenCount += 1;
|
|
3568
|
+
}
|
|
3569
|
+
if (overwrittenCount > 0) this.writeRows(rows);
|
|
3570
|
+
return { overwrittenCount, skippedCount };
|
|
3571
|
+
}
|
|
3572
|
+
/**
|
|
3573
|
+
* STORE-LOCAL (not on the core port): remove one row. Returns whether a row
|
|
3574
|
+
* was actually removed. The admin DELETE handler calls this then invalidates
|
|
3575
|
+
* the engine cache.
|
|
3576
|
+
*/
|
|
3577
|
+
async delete(providerId, modelId) {
|
|
3578
|
+
const rows = this.readRows();
|
|
3579
|
+
const idx = rows.findIndex((r) => r.providerId === providerId && r.modelId === modelId);
|
|
3580
|
+
if (idx < 0) return false;
|
|
3581
|
+
rows.splice(idx, 1);
|
|
3582
|
+
this.writeRows(rows);
|
|
3583
|
+
return true;
|
|
3584
|
+
}
|
|
3585
|
+
/** Upsert into `rows` IN PLACE (no write) and return the resulting entry. */
|
|
3586
|
+
applyUpsert(rows, input, asUserEdit) {
|
|
3587
|
+
const now = Date.now();
|
|
3588
|
+
const entry = {
|
|
3589
|
+
providerId: input.providerId,
|
|
3590
|
+
modelId: input.modelId,
|
|
3591
|
+
inputPricePer1m: input.inputPricePer1m,
|
|
3592
|
+
outputPricePer1m: input.outputPricePer1m,
|
|
3593
|
+
cacheReadPricePer1m: input.cacheReadPricePer1m ?? null,
|
|
3594
|
+
cacheWritePricePer1m: input.cacheWritePricePer1m ?? null,
|
|
3595
|
+
source: asUserEdit ? "user" : "litellm",
|
|
3596
|
+
userEdited: asUserEdit,
|
|
3597
|
+
editedAt: asUserEdit ? now : null,
|
|
3598
|
+
updatedAt: now
|
|
3599
|
+
};
|
|
3600
|
+
const idx = rows.findIndex(
|
|
3601
|
+
(r) => r.providerId === input.providerId && r.modelId === input.modelId
|
|
3602
|
+
);
|
|
3603
|
+
if (idx >= 0) rows[idx] = entry;
|
|
3604
|
+
else rows.push(entry);
|
|
3605
|
+
return entry;
|
|
3606
|
+
}
|
|
3607
|
+
/** Read the pricing rows, tolerating a missing/corrupt file (→ empty list). */
|
|
3608
|
+
readRows() {
|
|
3609
|
+
if (!existsSync6(this.pricingPath)) return [];
|
|
3610
|
+
try {
|
|
3611
|
+
const parsed = JSON.parse(readFileSync6(this.pricingPath, "utf8"));
|
|
3612
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
3613
|
+
} catch {
|
|
3614
|
+
return [];
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
3617
|
+
writeRows(rows) {
|
|
3618
|
+
writeFileSync5(this.pricingPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
|
|
3619
|
+
}
|
|
3620
|
+
};
|
|
3621
|
+
|
|
3129
3622
|
// src/ports/JsonSubscriptionCredentialStore.ts
|
|
3130
|
-
import { existsSync as
|
|
3131
|
-
import { dirname as
|
|
3623
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync9, writeFileSync as writeFileSync7 } from "fs";
|
|
3624
|
+
import { dirname as dirname4 } from "path";
|
|
3132
3625
|
import {
|
|
3133
3626
|
claudeOAuth as claudeOAuth2,
|
|
3134
3627
|
codexOAuth as codexOAuth2,
|
|
3135
3628
|
geminiOAuth as geminiOAuth2
|
|
3136
3629
|
} from "@omnicross/subscriptions";
|
|
3630
|
+
|
|
3631
|
+
// src/ports/account-sync.ts
|
|
3632
|
+
var IMPORT_EXPIRY_MARGIN_MS = 6e4;
|
|
3633
|
+
function viewOf(tokens) {
|
|
3634
|
+
return tokens;
|
|
3635
|
+
}
|
|
3636
|
+
function decideExternalImport(captured, external, now = Date.now()) {
|
|
3637
|
+
if (!external?.accessToken) return "no-credential";
|
|
3638
|
+
const capturedRt = viewOf(captured).refreshToken;
|
|
3639
|
+
const hasNewRefresh = Boolean(external.refreshToken && external.refreshToken !== capturedRt);
|
|
3640
|
+
const accessStillValid = external.expiresAt ? Date.parse(external.expiresAt) > now + IMPORT_EXPIRY_MARGIN_MS : true;
|
|
3641
|
+
return hasNewRefresh || accessStillValid ? "import" : "not-rotated";
|
|
3642
|
+
}
|
|
3643
|
+
function buildImportedTokens(captured, external) {
|
|
3644
|
+
const imported = {
|
|
3645
|
+
...captured,
|
|
3646
|
+
accessToken: external.accessToken,
|
|
3647
|
+
status: "authorized",
|
|
3648
|
+
errorMessage: void 0,
|
|
3649
|
+
syncWarning: void 0,
|
|
3650
|
+
lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3651
|
+
};
|
|
3652
|
+
if (external.refreshToken) imported.refreshToken = external.refreshToken;
|
|
3653
|
+
if (external.expiresAt) imported.expiresAt = external.expiresAt;
|
|
3654
|
+
else delete imported.expiresAt;
|
|
3655
|
+
if (external.idToken) imported.idToken = external.idToken;
|
|
3656
|
+
if (external.scopes) imported.scopes = external.scopes;
|
|
3657
|
+
return imported;
|
|
3658
|
+
}
|
|
3659
|
+
function buildTokensFromExternal(provider, external) {
|
|
3660
|
+
const base = {
|
|
3661
|
+
authMethod: "oauth",
|
|
3662
|
+
status: "authorized",
|
|
3663
|
+
accessToken: external.accessToken,
|
|
3664
|
+
lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3665
|
+
};
|
|
3666
|
+
if (provider === "claude") {
|
|
3667
|
+
const tokens2 = { ...base };
|
|
3668
|
+
if (external.refreshToken) tokens2.refreshToken = external.refreshToken;
|
|
3669
|
+
if (external.expiresAt) tokens2.expiresAt = external.expiresAt;
|
|
3670
|
+
if (external.scopes) tokens2.scopes = external.scopes;
|
|
3671
|
+
return tokens2;
|
|
3672
|
+
}
|
|
3673
|
+
const tokens = { ...base };
|
|
3674
|
+
if (external.refreshToken) tokens.refreshToken = external.refreshToken;
|
|
3675
|
+
if (external.expiresAt) tokens.expiresAt = external.expiresAt;
|
|
3676
|
+
if (external.idToken) tokens.idToken = external.idToken;
|
|
3677
|
+
return tokens;
|
|
3678
|
+
}
|
|
3679
|
+
function isExternalDivergent(stored, external) {
|
|
3680
|
+
if (!external?.accessToken || !external.refreshToken) return false;
|
|
3681
|
+
const view = viewOf(stored);
|
|
3682
|
+
if (!view.refreshToken || external.refreshToken === view.refreshToken) return false;
|
|
3683
|
+
const storedExp = view.expiresAt ? Date.parse(view.expiresAt) : NaN;
|
|
3684
|
+
const externalExp = external.expiresAt ? Date.parse(external.expiresAt) : Infinity;
|
|
3685
|
+
return !Number.isFinite(storedExp) || externalExp > storedExp;
|
|
3686
|
+
}
|
|
3687
|
+
function findDuplicateCredentialIds(accounts) {
|
|
3688
|
+
const byCredential = /* @__PURE__ */ new Map();
|
|
3689
|
+
for (const account of accounts) {
|
|
3690
|
+
const view = viewOf(account.tokens);
|
|
3691
|
+
const credential = view.refreshToken ?? view.apiKey ?? view.accessToken;
|
|
3692
|
+
if (!credential) continue;
|
|
3693
|
+
const ids = byCredential.get(credential) ?? [];
|
|
3694
|
+
ids.push(account.id);
|
|
3695
|
+
byCredential.set(credential, ids);
|
|
3696
|
+
}
|
|
3697
|
+
const duplicates = /* @__PURE__ */ new Set();
|
|
3698
|
+
for (const ids of byCredential.values()) {
|
|
3699
|
+
if (ids.length > 1) for (const id of ids) duplicates.add(id);
|
|
3700
|
+
}
|
|
3701
|
+
return duplicates;
|
|
3702
|
+
}
|
|
3703
|
+
|
|
3704
|
+
// src/ports/external-cli-credentials.ts
|
|
3705
|
+
import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
|
|
3706
|
+
import { homedir as homedir2 } from "os";
|
|
3707
|
+
import { join as join4 } from "path";
|
|
3708
|
+
function externalStorePath(provider, home = homedir2()) {
|
|
3709
|
+
return provider === "claude" ? join4(home, ".claude", ".credentials.json") : join4(home, ".codex", "auth.json");
|
|
3710
|
+
}
|
|
3711
|
+
function decodeJwtExpiryMs(token) {
|
|
3712
|
+
try {
|
|
3713
|
+
const payload = token.split(".")[1];
|
|
3714
|
+
if (!payload) return void 0;
|
|
3715
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
3716
|
+
if (typeof decoded.exp === "number" && Number.isFinite(decoded.exp)) {
|
|
3717
|
+
return decoded.exp * 1e3;
|
|
3718
|
+
}
|
|
3719
|
+
} catch {
|
|
3720
|
+
}
|
|
3721
|
+
return void 0;
|
|
3722
|
+
}
|
|
3723
|
+
function parseClaudeOAuthEnvelope(raw) {
|
|
3724
|
+
const oauth = raw?.claudeAiOauth;
|
|
3725
|
+
if (!oauth || typeof oauth.accessToken !== "string" || !oauth.accessToken) return null;
|
|
3726
|
+
const parsed = { accessToken: oauth.accessToken };
|
|
3727
|
+
if (typeof oauth.refreshToken === "string" && oauth.refreshToken) {
|
|
3728
|
+
parsed.refreshToken = oauth.refreshToken;
|
|
3729
|
+
}
|
|
3730
|
+
if (typeof oauth.expiresAt === "number" && Number.isFinite(oauth.expiresAt)) {
|
|
3731
|
+
parsed.expiresAt = new Date(oauth.expiresAt).toISOString();
|
|
3732
|
+
}
|
|
3733
|
+
if (Array.isArray(oauth.scopes) && oauth.scopes.every((s) => typeof s === "string")) {
|
|
3734
|
+
parsed.scopes = oauth.scopes;
|
|
3735
|
+
}
|
|
3736
|
+
return parsed;
|
|
3737
|
+
}
|
|
3738
|
+
function parseCodexTokensEnvelope(raw) {
|
|
3739
|
+
const tokens = raw?.tokens;
|
|
3740
|
+
if (!tokens) return null;
|
|
3741
|
+
const accessToken = typeof tokens.access_token === "string" && tokens.access_token ? tokens.access_token : void 0;
|
|
3742
|
+
const idToken = typeof tokens.id_token === "string" && tokens.id_token ? tokens.id_token : void 0;
|
|
3743
|
+
if (!accessToken && !idToken) return null;
|
|
3744
|
+
const parsed = {};
|
|
3745
|
+
if (accessToken) {
|
|
3746
|
+
parsed.accessToken = accessToken;
|
|
3747
|
+
const expMs = decodeJwtExpiryMs(accessToken);
|
|
3748
|
+
if (expMs !== void 0) parsed.expiresAt = new Date(expMs).toISOString();
|
|
3749
|
+
}
|
|
3750
|
+
if (idToken) parsed.idToken = idToken;
|
|
3751
|
+
if (typeof tokens.refresh_token === "string" && tokens.refresh_token) {
|
|
3752
|
+
parsed.refreshToken = tokens.refresh_token;
|
|
3753
|
+
}
|
|
3754
|
+
return parsed;
|
|
3755
|
+
}
|
|
3756
|
+
function readExternalCliCredentials(provider, home = homedir2()) {
|
|
3757
|
+
const path2 = externalStorePath(provider, home);
|
|
3758
|
+
if (!existsSync7(path2)) return null;
|
|
3759
|
+
let raw;
|
|
3760
|
+
try {
|
|
3761
|
+
const parsed = JSON.parse(readFileSync7(path2, "utf8"));
|
|
3762
|
+
raw = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
|
|
3763
|
+
} catch {
|
|
3764
|
+
return null;
|
|
3765
|
+
}
|
|
3766
|
+
return provider === "claude" ? parseClaudeOAuthEnvelope(raw) : parseCodexTokensEnvelope(raw);
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
// src/ports/external-cli-store.ts
|
|
3770
|
+
import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync2, readFileSync as readFileSync8, renameSync, writeFileSync as writeFileSync6 } from "fs";
|
|
3771
|
+
import { homedir as homedir3 } from "os";
|
|
3772
|
+
import { dirname as dirname3 } from "path";
|
|
3773
|
+
function markerPath(provider, home) {
|
|
3774
|
+
return `${externalStorePath(provider, home)}.omnicross-managed`;
|
|
3775
|
+
}
|
|
3776
|
+
function backupPath(provider, home) {
|
|
3777
|
+
return `${externalStorePath(provider, home)}.omnicross-backup`;
|
|
3778
|
+
}
|
|
3779
|
+
function buildClaudeOAuthEnvelope(tokens) {
|
|
3780
|
+
if (!tokens.accessToken) return null;
|
|
3781
|
+
const envelope = { accessToken: tokens.accessToken };
|
|
3782
|
+
if (tokens.refreshToken) envelope.refreshToken = tokens.refreshToken;
|
|
3783
|
+
if (tokens.expiresAt) {
|
|
3784
|
+
const ms = Date.parse(tokens.expiresAt);
|
|
3785
|
+
if (Number.isFinite(ms)) envelope.expiresAt = ms;
|
|
3786
|
+
}
|
|
3787
|
+
if (tokens.scopes && tokens.scopes.length > 0) envelope.scopes = tokens.scopes;
|
|
3788
|
+
return envelope;
|
|
3789
|
+
}
|
|
3790
|
+
function buildCodexTokensEnvelope(tokens) {
|
|
3791
|
+
if (!tokens.accessToken && !tokens.idToken) return null;
|
|
3792
|
+
const envelope = { access_token: tokens.accessToken ?? "" };
|
|
3793
|
+
if (tokens.idToken) envelope.id_token = tokens.idToken;
|
|
3794
|
+
if (tokens.refreshToken) envelope.refresh_token = tokens.refreshToken;
|
|
3795
|
+
return envelope;
|
|
3796
|
+
}
|
|
3797
|
+
function readExistingObject(path2) {
|
|
3798
|
+
if (!existsSync8(path2)) return {};
|
|
3799
|
+
try {
|
|
3800
|
+
const parsed = JSON.parse(readFileSync8(path2, "utf8"));
|
|
3801
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
3802
|
+
} catch {
|
|
3803
|
+
return {};
|
|
3804
|
+
}
|
|
3805
|
+
}
|
|
3806
|
+
function writeAtomic(path2, content) {
|
|
3807
|
+
mkdirSync2(dirname3(path2), { recursive: true });
|
|
3808
|
+
const temp = `${path2}.omnicross-tmp`;
|
|
3809
|
+
writeFileSync6(temp, content, "utf8");
|
|
3810
|
+
renameSync(temp, path2);
|
|
3811
|
+
}
|
|
3812
|
+
function createExternalCliStore(home = homedir3()) {
|
|
3813
|
+
return {
|
|
3814
|
+
readMarkerAccountId(provider) {
|
|
3815
|
+
const path2 = markerPath(provider, home);
|
|
3816
|
+
if (!existsSync8(path2)) return void 0;
|
|
3817
|
+
try {
|
|
3818
|
+
const parsed = JSON.parse(readFileSync8(path2, "utf8"));
|
|
3819
|
+
return typeof parsed.accountId === "string" && parsed.accountId ? parsed.accountId : void 0;
|
|
3820
|
+
} catch {
|
|
3821
|
+
return void 0;
|
|
3822
|
+
}
|
|
3823
|
+
},
|
|
3824
|
+
writeMarker(provider, accountId) {
|
|
3825
|
+
writeAtomic(
|
|
3826
|
+
markerPath(provider, home),
|
|
3827
|
+
JSON.stringify({ accountId, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n"
|
|
3828
|
+
);
|
|
3829
|
+
},
|
|
3830
|
+
writeBack(provider, accountId, tokens) {
|
|
3831
|
+
const owner = this.readMarkerAccountId(provider);
|
|
3832
|
+
if (owner !== accountId) return false;
|
|
3833
|
+
const envelope = provider === "claude" ? buildClaudeOAuthEnvelope(tokens) : buildCodexTokensEnvelope(tokens);
|
|
3834
|
+
if (!envelope) return false;
|
|
3835
|
+
const storePath = externalStorePath(provider, home);
|
|
3836
|
+
if (existsSync8(storePath) && !existsSync8(backupPath(provider, home))) {
|
|
3837
|
+
copyFileSync(storePath, backupPath(provider, home));
|
|
3838
|
+
}
|
|
3839
|
+
const existing = readExistingObject(storePath);
|
|
3840
|
+
const merged = provider === "claude" ? { ...existing, claudeAiOauth: envelope } : { ...existing, tokens: envelope };
|
|
3841
|
+
writeAtomic(storePath, JSON.stringify(merged, null, 2) + "\n");
|
|
3842
|
+
return true;
|
|
3843
|
+
}
|
|
3844
|
+
};
|
|
3845
|
+
}
|
|
3846
|
+
|
|
3847
|
+
// src/ports/JsonSubscriptionCredentialStore.ts
|
|
3137
3848
|
var JsonSubscriptionCredentialStore = class {
|
|
3138
3849
|
/**
|
|
3139
3850
|
* @param tokensPath on-disk `tokens.json` location.
|
|
@@ -3143,14 +3854,33 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3143
3854
|
* is unchanged; tests inject a mock fetch. NOT used by any
|
|
3144
3855
|
* read/write path — only by `refresh*Token`.
|
|
3145
3856
|
*/
|
|
3146
|
-
constructor(tokensPath, box, fetchImpl = (url, init) => fetch(url, init)) {
|
|
3857
|
+
constructor(tokensPath, box, fetchImpl = (url, init) => fetch(url, init), externalCliReader = readExternalCliCredentials, externalCliStore = createExternalCliStore()) {
|
|
3147
3858
|
this.tokensPath = tokensPath;
|
|
3148
3859
|
this.box = box;
|
|
3149
3860
|
this.fetchImpl = fetchImpl;
|
|
3861
|
+
this.externalCliReader = externalCliReader;
|
|
3862
|
+
this.externalCliStore = externalCliStore;
|
|
3150
3863
|
}
|
|
3151
3864
|
tokensPath;
|
|
3152
3865
|
box;
|
|
3153
3866
|
fetchImpl;
|
|
3867
|
+
externalCliReader;
|
|
3868
|
+
externalCliStore;
|
|
3869
|
+
/**
|
|
3870
|
+
* In-flight refresh coalescing (external-cli-sync). OAuth refresh tokens are
|
|
3871
|
+
* SINGLE-USE: two concurrent refreshes of one account each spend the same
|
|
3872
|
+
* token and the loser bricks a healthy account. Every refresh entry point
|
|
3873
|
+
* (auth-strategy lazy refresh, 401 retry, background scheduler) funnels
|
|
3874
|
+
* through `coalesce`, so overlapping callers share ONE upstream round-trip.
|
|
3875
|
+
*/
|
|
3876
|
+
inFlightRefreshes = /* @__PURE__ */ new Map();
|
|
3877
|
+
coalesce(key, task) {
|
|
3878
|
+
const existing = this.inFlightRefreshes.get(key);
|
|
3879
|
+
if (existing) return existing;
|
|
3880
|
+
const run = task().finally(() => this.inFlightRefreshes.delete(key));
|
|
3881
|
+
this.inFlightRefreshes.set(key, run);
|
|
3882
|
+
return run;
|
|
3883
|
+
}
|
|
3154
3884
|
/** Full parsed account-tokens config (or a minimal `{ updatedAt }` when the
|
|
3155
3885
|
* file is absent/corrupt). This is the hot read — the codex / gemini auth
|
|
3156
3886
|
* strategies pull `accessToken` / `expiresAt` / `status` from it. */
|
|
@@ -3178,10 +3908,41 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3178
3908
|
const out = {};
|
|
3179
3909
|
for (const provider of ["claude", "codex", "gemini", "opencodego"]) {
|
|
3180
3910
|
const sanitized = sanitizeAccounts(config, provider);
|
|
3181
|
-
if (sanitized.length > 0) out[provider] = sanitized;
|
|
3911
|
+
if (sanitized.length > 0) out[provider] = this.attachSyncWarnings(config, provider, sanitized);
|
|
3182
3912
|
}
|
|
3183
3913
|
return out;
|
|
3184
3914
|
}
|
|
3915
|
+
/**
|
|
3916
|
+
* List-time credential-conflict warnings (external-cli-sync). Computed, not
|
|
3917
|
+
* persisted: (a) `duplicate-token` when two accounts of one provider share a
|
|
3918
|
+
* credential, (b) `external-divergent` when the external CLI native store has
|
|
3919
|
+
* rotated PAST the ACTIVE account (claude/codex only). A warning persisted by
|
|
3920
|
+
* a failed refresh (`external-not-rotated`) takes precedence — it is the most
|
|
3921
|
+
* actionable state.
|
|
3922
|
+
*/
|
|
3923
|
+
attachSyncWarnings(config, provider, sanitized) {
|
|
3924
|
+
const duplicates = findDuplicateCredentialIds(listAccounts(config, provider));
|
|
3925
|
+
let divergentId;
|
|
3926
|
+
if (provider === "claude" || provider === "codex") {
|
|
3927
|
+
const active = getActiveAccount(config, provider);
|
|
3928
|
+
if (active && isExternalDivergent(active.tokens, this.safeReadExternal(provider))) {
|
|
3929
|
+
divergentId = active.id;
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
if (duplicates.size === 0 && !divergentId) return sanitized;
|
|
3933
|
+
return sanitized.map((account) => {
|
|
3934
|
+
const computed = account.id === divergentId ? "external-divergent" : duplicates.has(account.id) ? "duplicate-token" : void 0;
|
|
3935
|
+
return { ...account, syncWarning: account.syncWarning ?? computed };
|
|
3936
|
+
});
|
|
3937
|
+
}
|
|
3938
|
+
/** Read the external CLI store, never letting an fs/parse error escape. */
|
|
3939
|
+
safeReadExternal(provider) {
|
|
3940
|
+
try {
|
|
3941
|
+
return this.externalCliReader(provider);
|
|
3942
|
+
} catch {
|
|
3943
|
+
return null;
|
|
3944
|
+
}
|
|
3945
|
+
}
|
|
3185
3946
|
/**
|
|
3186
3947
|
* Refresh the Claude OAuth access token (oauth design D4). HONEST `false` when
|
|
3187
3948
|
* the block has no refresh_token (setup-token / manual) — no upstream call, the
|
|
@@ -3191,30 +3952,44 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3191
3952
|
* errorMessage → `false`.
|
|
3192
3953
|
*/
|
|
3193
3954
|
async refreshClaudeToken() {
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3955
|
+
return this.coalesce("claude:active", async () => {
|
|
3956
|
+
const config = this.readConfig();
|
|
3957
|
+
const active = getActiveAccount(config, "claude");
|
|
3958
|
+
const claude = active?.tokens;
|
|
3959
|
+
if (!active || !claude?.refreshToken) return false;
|
|
3960
|
+
const capturedId = active.id;
|
|
3961
|
+
this.materializeMigration(config);
|
|
3962
|
+
try {
|
|
3963
|
+
const result = await claudeOAuth2.refreshAccessToken(claude.refreshToken, this.fetchImpl);
|
|
3964
|
+
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
3965
|
+
const next = {
|
|
3966
|
+
...claude,
|
|
3967
|
+
accessToken: result.accessToken,
|
|
3968
|
+
refreshToken: result.refreshToken,
|
|
3969
|
+
expiresAt,
|
|
3970
|
+
status: "authorized",
|
|
3971
|
+
lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3972
|
+
errorMessage: void 0,
|
|
3973
|
+
syncWarning: void 0
|
|
3974
|
+
};
|
|
3975
|
+
this.writeBackById("claude", capturedId, next);
|
|
3976
|
+
this.resyncExternal("claude", capturedId, next);
|
|
3977
|
+
return true;
|
|
3978
|
+
} catch (error) {
|
|
3979
|
+
if (await this.tryExternalImport("claude", capturedId, claude, async (rt) => {
|
|
3980
|
+
const r = await claudeOAuth2.refreshAccessToken(rt, this.fetchImpl);
|
|
3981
|
+
return {
|
|
3982
|
+
accessToken: r.accessToken,
|
|
3983
|
+
refreshToken: r.refreshToken,
|
|
3984
|
+
expiresAt: new Date(Date.now() + r.expiresIn * 1e3).toISOString()
|
|
3985
|
+
};
|
|
3986
|
+
})) {
|
|
3987
|
+
return true;
|
|
3988
|
+
}
|
|
3989
|
+
this.markExpiredById("claude", capturedId, claude, error);
|
|
3990
|
+
return false;
|
|
3991
|
+
}
|
|
3992
|
+
});
|
|
3218
3993
|
}
|
|
3219
3994
|
/**
|
|
3220
3995
|
* Refresh the Codex (ChatGPT) OAuth access token. Same shape
|
|
@@ -3222,31 +3997,46 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3222
3997
|
* HONEST `false` when no refresh_token.
|
|
3223
3998
|
*/
|
|
3224
3999
|
async refreshCodexToken() {
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
4000
|
+
return this.coalesce("codex:active", async () => {
|
|
4001
|
+
const config = this.readConfig();
|
|
4002
|
+
const active = getActiveAccount(config, "codex");
|
|
4003
|
+
const codex = active?.tokens;
|
|
4004
|
+
if (!active || !codex?.refreshToken) return false;
|
|
4005
|
+
const capturedId = active.id;
|
|
4006
|
+
this.materializeMigration(config);
|
|
4007
|
+
try {
|
|
4008
|
+
const result = await codexOAuth2.refreshAccessToken(codex.refreshToken, this.fetchImpl);
|
|
4009
|
+
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
4010
|
+
const next = {
|
|
4011
|
+
...codex,
|
|
4012
|
+
accessToken: result.accessToken,
|
|
4013
|
+
refreshToken: result.refreshToken,
|
|
4014
|
+
idToken: result.idToken,
|
|
4015
|
+
expiresAt,
|
|
4016
|
+
status: "authorized",
|
|
4017
|
+
lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4018
|
+
errorMessage: void 0,
|
|
4019
|
+
syncWarning: void 0
|
|
4020
|
+
};
|
|
4021
|
+
this.writeBackById("codex", capturedId, next);
|
|
4022
|
+
this.resyncExternal("codex", capturedId, next);
|
|
4023
|
+
return true;
|
|
4024
|
+
} catch (error) {
|
|
4025
|
+
if (await this.tryExternalImport("codex", capturedId, codex, async (rt) => {
|
|
4026
|
+
const r = await codexOAuth2.refreshAccessToken(rt, this.fetchImpl);
|
|
4027
|
+
return {
|
|
4028
|
+
accessToken: r.accessToken,
|
|
4029
|
+
refreshToken: r.refreshToken,
|
|
4030
|
+
idToken: r.idToken,
|
|
4031
|
+
expiresAt: new Date(Date.now() + r.expiresIn * 1e3).toISOString()
|
|
4032
|
+
};
|
|
4033
|
+
})) {
|
|
4034
|
+
return true;
|
|
4035
|
+
}
|
|
4036
|
+
this.markExpiredById("codex", capturedId, codex, error);
|
|
4037
|
+
return false;
|
|
4038
|
+
}
|
|
4039
|
+
});
|
|
3250
4040
|
}
|
|
3251
4041
|
/**
|
|
3252
4042
|
* Refresh the Gemini (Google) OAuth access token. The Google
|
|
@@ -3256,30 +4046,175 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3256
4046
|
* destroy the ability to refresh again). HONEST `false` when no refresh_token.
|
|
3257
4047
|
*/
|
|
3258
4048
|
async refreshGeminiToken() {
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
4049
|
+
return this.coalesce("gemini:active", async () => {
|
|
4050
|
+
const config = this.readConfig();
|
|
4051
|
+
const active = getActiveAccount(config, "gemini");
|
|
4052
|
+
const gemini = active?.tokens;
|
|
4053
|
+
if (!active || !gemini?.refreshToken) return false;
|
|
4054
|
+
const capturedId = active.id;
|
|
4055
|
+
this.materializeMigration(config);
|
|
4056
|
+
try {
|
|
4057
|
+
const result = await geminiOAuth2.refreshAccessToken(gemini.refreshToken, this.fetchImpl);
|
|
4058
|
+
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
4059
|
+
const next = {
|
|
4060
|
+
...gemini,
|
|
4061
|
+
// KEEP the existing refreshToken (response omits it).
|
|
4062
|
+
accessToken: result.accessToken,
|
|
4063
|
+
expiresAt,
|
|
4064
|
+
status: "authorized",
|
|
4065
|
+
lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4066
|
+
errorMessage: void 0
|
|
4067
|
+
};
|
|
4068
|
+
this.writeBackById("gemini", capturedId, next);
|
|
4069
|
+
return true;
|
|
4070
|
+
} catch (error) {
|
|
4071
|
+
this.markExpiredById("gemini", capturedId, gemini, error);
|
|
4072
|
+
return false;
|
|
4073
|
+
}
|
|
4074
|
+
});
|
|
4075
|
+
}
|
|
4076
|
+
/**
|
|
4077
|
+
* Refresh a SPECIFIC account by id (background scheduler sweep,
|
|
4078
|
+
* external-cli-sync). Unlike the active-account refreshers it does NOT
|
|
4079
|
+
* attempt the external-import fallback — the external CLI file's lineage can
|
|
4080
|
+
* only plausibly match the ACTIVE account. Coalesced per `provider:id`; on
|
|
4081
|
+
* failure flags ONLY that account `expired`.
|
|
4082
|
+
*/
|
|
4083
|
+
async refreshAccountById(provider, id) {
|
|
4084
|
+
return this.coalesce(`${provider}:${id}`, async () => {
|
|
4085
|
+
const config = this.readConfig();
|
|
4086
|
+
const account = getAccountById(config, provider, id);
|
|
4087
|
+
const captured = account?.tokens;
|
|
4088
|
+
if (!account || !captured?.refreshToken) return false;
|
|
4089
|
+
this.materializeMigration(config);
|
|
4090
|
+
try {
|
|
4091
|
+
const refreshed = await this.refreshUpstream(provider, captured.refreshToken);
|
|
4092
|
+
const next = {
|
|
4093
|
+
...captured,
|
|
4094
|
+
accessToken: refreshed.accessToken,
|
|
4095
|
+
// Gemini's refresh response omits a new refresh token — keep the captured.
|
|
4096
|
+
refreshToken: refreshed.refreshToken ?? captured.refreshToken,
|
|
4097
|
+
expiresAt: refreshed.expiresAt,
|
|
4098
|
+
status: "authorized",
|
|
4099
|
+
lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4100
|
+
errorMessage: void 0,
|
|
4101
|
+
syncWarning: void 0
|
|
4102
|
+
};
|
|
4103
|
+
if (refreshed.idToken) next.idToken = refreshed.idToken;
|
|
4104
|
+
this.writeBackById(provider, id, next);
|
|
4105
|
+
if (provider !== "gemini") this.resyncExternal(provider, id, next);
|
|
4106
|
+
return true;
|
|
4107
|
+
} catch (error) {
|
|
4108
|
+
this.markExpiredById(provider, id, captured, error);
|
|
4109
|
+
return false;
|
|
4110
|
+
}
|
|
4111
|
+
});
|
|
4112
|
+
}
|
|
4113
|
+
/** Dispatch one OAuth refresh round-trip to the provider's shared flow. */
|
|
4114
|
+
async refreshUpstream(provider, refreshToken) {
|
|
4115
|
+
const flow = provider === "claude" ? claudeOAuth2 : provider === "codex" ? codexOAuth2 : geminiOAuth2;
|
|
4116
|
+
const r = await flow.refreshAccessToken(refreshToken, this.fetchImpl);
|
|
4117
|
+
return {
|
|
4118
|
+
accessToken: r.accessToken,
|
|
4119
|
+
refreshToken: r.refreshToken,
|
|
4120
|
+
idToken: r.idToken,
|
|
4121
|
+
expiresAt: new Date(Date.now() + r.expiresIn * 1e3).toISOString()
|
|
4122
|
+
};
|
|
4123
|
+
}
|
|
4124
|
+
/**
|
|
4125
|
+
* External-import fallback for a FAILED active-account refresh
|
|
4126
|
+
* (external-cli-sync). Reads the CLI native store; imports when the external
|
|
4127
|
+
* lineage ROTATED (different refresh token) or its access token is still
|
|
4128
|
+
* valid. When the imported access token is already expired it refreshes once
|
|
4129
|
+
* with the rotated refresh token. A `not-rotated` outcome persists the
|
|
4130
|
+
* `external-not-rotated` warning on the (about-to-be-expired) account so the
|
|
4131
|
+
* UI can tell "genuine revocation" apart from a plain refresh failure.
|
|
4132
|
+
*/
|
|
4133
|
+
async tryExternalImport(provider, capturedId, captured, refreshWithToken) {
|
|
4134
|
+
const markerOwner = this.safeReadMarker(provider);
|
|
4135
|
+
if (markerOwner && markerOwner !== capturedId) return false;
|
|
4136
|
+
const external = this.safeReadExternal(provider);
|
|
4137
|
+
const decision = decideExternalImport(captured, external);
|
|
4138
|
+
if (decision === "not-rotated") {
|
|
4139
|
+
captured.syncWarning = "external-not-rotated";
|
|
3281
4140
|
return false;
|
|
3282
4141
|
}
|
|
4142
|
+
if (decision !== "import" || !external) return false;
|
|
4143
|
+
let imported = buildImportedTokens(
|
|
4144
|
+
captured,
|
|
4145
|
+
external
|
|
4146
|
+
);
|
|
4147
|
+
const accessStillValid = external.expiresAt ? Date.parse(external.expiresAt) > Date.now() + 6e4 : true;
|
|
4148
|
+
if (!accessStillValid) {
|
|
4149
|
+
try {
|
|
4150
|
+
const refreshed = await refreshWithToken(external.refreshToken);
|
|
4151
|
+
imported = {
|
|
4152
|
+
...imported,
|
|
4153
|
+
accessToken: refreshed.accessToken,
|
|
4154
|
+
refreshToken: refreshed.refreshToken ?? imported.refreshToken,
|
|
4155
|
+
expiresAt: refreshed.expiresAt,
|
|
4156
|
+
lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4157
|
+
};
|
|
4158
|
+
if (refreshed.idToken) imported.idToken = refreshed.idToken;
|
|
4159
|
+
} catch {
|
|
4160
|
+
return false;
|
|
4161
|
+
}
|
|
4162
|
+
}
|
|
4163
|
+
this.writeBackById(provider, capturedId, imported);
|
|
4164
|
+
this.resyncExternal(provider, capturedId, imported);
|
|
4165
|
+
return true;
|
|
4166
|
+
}
|
|
4167
|
+
/**
|
|
4168
|
+
* Marker-gated external write-back (external-cli-sync). After a successful
|
|
4169
|
+
* refresh of the account that OWNS the provider's native CLI store (imported
|
|
4170
|
+
* via `importExternalCliAccount`), push the rotated credential back into the
|
|
4171
|
+
* file — otherwise the daemon's refresh invalidates the single-use refresh
|
|
4172
|
+
* token and silently logs the bare CLI out. NON-FATAL: the internal store is
|
|
4173
|
+
* already persisted; a failed external write only leaves the file stale,
|
|
4174
|
+
* which the `external-divergent` warning surfaces.
|
|
4175
|
+
*/
|
|
4176
|
+
resyncExternal(provider, accountId, tokens) {
|
|
4177
|
+
try {
|
|
4178
|
+
this.externalCliStore.writeBack(provider, accountId, tokens);
|
|
4179
|
+
} catch {
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
/** Read the marker's owning account id, never letting an fs error escape. */
|
|
4183
|
+
safeReadMarker(provider) {
|
|
4184
|
+
try {
|
|
4185
|
+
return this.externalCliStore.readMarkerAccountId(provider);
|
|
4186
|
+
} catch {
|
|
4187
|
+
return void 0;
|
|
4188
|
+
}
|
|
4189
|
+
}
|
|
4190
|
+
/**
|
|
4191
|
+
* DAEMON-ONLY (admin import button): which providers have a usable external
|
|
4192
|
+
* CLI credential on THIS machine. Pure detection — reads the native files,
|
|
4193
|
+
* never mutates anything, never returns a token.
|
|
4194
|
+
*/
|
|
4195
|
+
async listExternalCliAvailability() {
|
|
4196
|
+
return {
|
|
4197
|
+
claude: Boolean(this.safeReadExternal("claude")?.accessToken),
|
|
4198
|
+
codex: Boolean(this.safeReadExternal("codex")?.accessToken)
|
|
4199
|
+
};
|
|
4200
|
+
}
|
|
4201
|
+
/**
|
|
4202
|
+
* DAEMON-ONLY (admin import button): import the external CLI's current login
|
|
4203
|
+
* as a NEW account (+ activate), and take MANAGED ownership of the native
|
|
4204
|
+
* store (marker) so subsequent refreshes write back — keeping the bare CLI
|
|
4205
|
+
* and the daemon on the same live credential instead of silently killing one
|
|
4206
|
+
* side's single-use refresh token.
|
|
4207
|
+
*/
|
|
4208
|
+
async importExternalCliAccount(provider, label) {
|
|
4209
|
+
const external = this.safeReadExternal(provider);
|
|
4210
|
+
if (!external?.accessToken) return { ok: false, reason: "no-credential" };
|
|
4211
|
+
const tokens = buildTokensFromExternal(provider, external);
|
|
4212
|
+
const result = await this.appendProviderAccount(provider, tokens, label);
|
|
4213
|
+
try {
|
|
4214
|
+
this.externalCliStore.writeMarker(provider, result.id);
|
|
4215
|
+
} catch {
|
|
4216
|
+
}
|
|
4217
|
+
return { ok: true, id: result.id };
|
|
3283
4218
|
}
|
|
3284
4219
|
/**
|
|
3285
4220
|
* Materialize a lazily-synthesized account id to disk (design D3). On a legacy
|
|
@@ -3363,6 +4298,18 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3363
4298
|
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
3364
4299
|
return result;
|
|
3365
4300
|
}
|
|
4301
|
+
/**
|
|
4302
|
+
* DAEMON-ONLY per-account rename (NOT on the port). Update one account's label;
|
|
4303
|
+
* rejects an unknown id. Label-only — no token material is read or written
|
|
4304
|
+
* (the secret-free invariant holds).
|
|
4305
|
+
*/
|
|
4306
|
+
async renameAccount(providerId, id, label) {
|
|
4307
|
+
const current = this.readConfig();
|
|
4308
|
+
const result = renameAccount(current, providerId, id, label);
|
|
4309
|
+
if (!result.ok) return result;
|
|
4310
|
+
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
4311
|
+
return result;
|
|
4312
|
+
}
|
|
3366
4313
|
/**
|
|
3367
4314
|
* DAEMON-ONLY CLEAR (design D1/D3, NOT on the port). Remove a single provider's
|
|
3368
4315
|
* block from `tokens.json` and re-persist (the strategies already tolerate an
|
|
@@ -3379,9 +4326,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3379
4326
|
* → `enc:v1:`; already-`enc:`/`$ENV` untouched) before serializing, so any
|
|
3380
4327
|
* write — incl. child 4's future refresh writes — lands encrypted. */
|
|
3381
4328
|
persist(config) {
|
|
3382
|
-
|
|
4329
|
+
mkdirSync3(dirname4(this.tokensPath), { recursive: true });
|
|
3383
4330
|
const encrypted = encryptTokens(config, this.box);
|
|
3384
|
-
|
|
4331
|
+
writeFileSync7(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
|
|
3385
4332
|
}
|
|
3386
4333
|
/**
|
|
3387
4334
|
* Read + parse `tokens.json`, tolerating a missing/corrupt file, then DECRYPT
|
|
@@ -3397,10 +4344,10 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3397
4344
|
* `config.ts loadConfig`, which decrypts outside its parse try.
|
|
3398
4345
|
*/
|
|
3399
4346
|
readConfig() {
|
|
3400
|
-
if (!
|
|
4347
|
+
if (!existsSync9(this.tokensPath)) return { updatedAt: "" };
|
|
3401
4348
|
let parsed;
|
|
3402
4349
|
try {
|
|
3403
|
-
const raw = JSON.parse(
|
|
4350
|
+
const raw = JSON.parse(readFileSync9(this.tokensPath, "utf8"));
|
|
3404
4351
|
parsed = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
|
|
3405
4352
|
} catch {
|
|
3406
4353
|
parsed = null;
|
|
@@ -3411,6 +4358,95 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3411
4358
|
}
|
|
3412
4359
|
};
|
|
3413
4360
|
|
|
4361
|
+
// src/TokenRefreshScheduler.ts
|
|
4362
|
+
var REFRESH_LEAD_MS = 5 * 6e4;
|
|
4363
|
+
var SWEEP_INTERVAL_MS = 6e4;
|
|
4364
|
+
var OAUTH_PROVIDERS = ["claude", "codex", "gemini"];
|
|
4365
|
+
var TokenRefreshScheduler = class {
|
|
4366
|
+
constructor(store, logger, intervalMs = SWEEP_INTERVAL_MS, leadMs = REFRESH_LEAD_MS) {
|
|
4367
|
+
this.store = store;
|
|
4368
|
+
this.logger = logger;
|
|
4369
|
+
this.intervalMs = intervalMs;
|
|
4370
|
+
this.leadMs = leadMs;
|
|
4371
|
+
}
|
|
4372
|
+
store;
|
|
4373
|
+
logger;
|
|
4374
|
+
intervalMs;
|
|
4375
|
+
leadMs;
|
|
4376
|
+
timer = null;
|
|
4377
|
+
sweeping = false;
|
|
4378
|
+
/** Arm the sweep interval. Idempotent. The timer never holds the loop open. */
|
|
4379
|
+
start() {
|
|
4380
|
+
if (this.timer) return;
|
|
4381
|
+
this.timer = setInterval(() => void this.sweep(), this.intervalMs);
|
|
4382
|
+
this.timer.unref?.();
|
|
4383
|
+
}
|
|
4384
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
4385
|
+
dispose() {
|
|
4386
|
+
if (this.timer) {
|
|
4387
|
+
clearInterval(this.timer);
|
|
4388
|
+
this.timer = null;
|
|
4389
|
+
}
|
|
4390
|
+
}
|
|
4391
|
+
/** One sweep over every account of every OAuth provider. Exposed for tests. */
|
|
4392
|
+
async sweep(now = Date.now()) {
|
|
4393
|
+
if (this.sweeping) return;
|
|
4394
|
+
this.sweeping = true;
|
|
4395
|
+
try {
|
|
4396
|
+
const config = await this.store.getFullConfig();
|
|
4397
|
+
for (const provider of OAUTH_PROVIDERS) {
|
|
4398
|
+
const activeId = getActiveAccount(config, provider)?.id;
|
|
4399
|
+
for (const account of listAccounts(config, provider)) {
|
|
4400
|
+
if (!this.needsRefresh(account.tokens, now)) continue;
|
|
4401
|
+
await this.refreshOne(provider, account.id, account.id === activeId);
|
|
4402
|
+
}
|
|
4403
|
+
}
|
|
4404
|
+
} catch (error) {
|
|
4405
|
+
this.logger.warn("token-refresh sweep failed", {
|
|
4406
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4407
|
+
});
|
|
4408
|
+
} finally {
|
|
4409
|
+
this.sweeping = false;
|
|
4410
|
+
}
|
|
4411
|
+
}
|
|
4412
|
+
/** Expiring within the lead window, refreshable, and not already dead. */
|
|
4413
|
+
needsRefresh(tokens, now) {
|
|
4414
|
+
const t = tokens;
|
|
4415
|
+
if (!t.refreshToken || t.status === "expired" || t.status === "error") return false;
|
|
4416
|
+
if (!t.expiresAt) return false;
|
|
4417
|
+
const expiresAt = Date.parse(t.expiresAt);
|
|
4418
|
+
return Number.isFinite(expiresAt) && now >= expiresAt - this.leadMs;
|
|
4419
|
+
}
|
|
4420
|
+
/** Refresh one account; failures are logged, never thrown (the store has
|
|
4421
|
+
* already flagged the account `expired`). */
|
|
4422
|
+
async refreshOne(provider, id, isActive) {
|
|
4423
|
+
try {
|
|
4424
|
+
const ok = isActive ? await this.refreshActive(provider) : await this.store.refreshAccountById(provider, id);
|
|
4425
|
+
if (!ok) {
|
|
4426
|
+
this.logger.warn("background token refresh failed", { provider, accountId: id });
|
|
4427
|
+
} else {
|
|
4428
|
+
this.logger.info("background token refresh succeeded", { provider, accountId: id });
|
|
4429
|
+
}
|
|
4430
|
+
} catch (error) {
|
|
4431
|
+
this.logger.warn("background token refresh threw", {
|
|
4432
|
+
provider,
|
|
4433
|
+
accountId: id,
|
|
4434
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4435
|
+
});
|
|
4436
|
+
}
|
|
4437
|
+
}
|
|
4438
|
+
refreshActive(provider) {
|
|
4439
|
+
switch (provider) {
|
|
4440
|
+
case "claude":
|
|
4441
|
+
return this.store.refreshClaudeToken();
|
|
4442
|
+
case "codex":
|
|
4443
|
+
return this.store.refreshCodexToken();
|
|
4444
|
+
case "gemini":
|
|
4445
|
+
return this.store.refreshGeminiToken();
|
|
4446
|
+
}
|
|
4447
|
+
}
|
|
4448
|
+
};
|
|
4449
|
+
|
|
3414
4450
|
// src/bootstrap.ts
|
|
3415
4451
|
function buildDaemon(config, paths) {
|
|
3416
4452
|
const logger = new ConsoleLogger();
|
|
@@ -3443,7 +4479,14 @@ function buildDaemon(config, paths) {
|
|
|
3443
4479
|
autoDisableStore.markAutoDisabled(keyId, status, at);
|
|
3444
4480
|
}
|
|
3445
4481
|
);
|
|
3446
|
-
const
|
|
4482
|
+
const pricingStore = new JsonPricingStore(defaultPricingPath(paths.configPath));
|
|
4483
|
+
const pricingEngine = new PricingEngine(pricingStore, logger);
|
|
4484
|
+
const usageEventStore = new JsonlUsageEventStore(
|
|
4485
|
+
defaultUsageEventsPath(paths.configPath),
|
|
4486
|
+
async (providerId, model) => await pricingEngine.getEntry(providerId, model) !== null
|
|
4487
|
+
);
|
|
4488
|
+
const usageRecorder = new UsageRecorder(usageEventStore, pricingEngine, logger);
|
|
4489
|
+
const providerProxy = getProviderProxy({ llmConfig, apiKeyPool, usageRecorder });
|
|
3447
4490
|
llmConfig.setReloadHook(() => apiKeyPool.invalidateCache());
|
|
3448
4491
|
const outboundApiServer = getOutboundApiServer({
|
|
3449
4492
|
db: keyDb,
|
|
@@ -3489,10 +4532,23 @@ function buildDaemon(config, paths) {
|
|
|
3489
4532
|
// the multi-account append (`appendProviderAccount`, import re-encrypts at-
|
|
3490
4533
|
// rest). Confined to the export/import handlers; never reached by a GET.
|
|
3491
4534
|
migrationCredentialStore: credentialStore,
|
|
4535
|
+
// Code CLI launch (dashboard parity): the external-terminal opener + PATH probe
|
|
4536
|
+
// default to the real implementations; tests inject spies so no window spawns.
|
|
4537
|
+
cliTerminalOpener: paths.cliTerminalOpener,
|
|
4538
|
+
cliPathProbe: paths.cliPathProbe,
|
|
4539
|
+
cliCommandRunner: paths.cliCommandRunner,
|
|
4540
|
+
// Usage/pricing admin surface (usage-pricing child): stats queries go
|
|
4541
|
+
// through the recorder facade, pricing mutations through the engine, and
|
|
4542
|
+
// the row DELETE through the concrete store (delete is store-local — the
|
|
4543
|
+
// core port stays frozen). None of these can reach key material.
|
|
4544
|
+
usageRecorder,
|
|
4545
|
+
pricingEngine,
|
|
4546
|
+
pricingStore,
|
|
3492
4547
|
// Use the DECRYPTED config so `admin.token` (if stored as `enc:`) is the
|
|
3493
4548
|
// plaintext bearer the AdminServer's constant-time compare expects (D4).
|
|
3494
4549
|
getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin)
|
|
3495
4550
|
});
|
|
4551
|
+
const tokenRefreshScheduler = new TokenRefreshScheduler(credentialStore, logger);
|
|
3496
4552
|
return {
|
|
3497
4553
|
logger,
|
|
3498
4554
|
llmConfig,
|
|
@@ -3505,7 +4561,11 @@ function buildDaemon(config, paths) {
|
|
|
3505
4561
|
credentialStore,
|
|
3506
4562
|
subscriptionRegistry,
|
|
3507
4563
|
subscriptionAccounts,
|
|
3508
|
-
|
|
4564
|
+
pricingStore,
|
|
4565
|
+
pricingEngine,
|
|
4566
|
+
usageRecorder,
|
|
4567
|
+
adminServer,
|
|
4568
|
+
tokenRefreshScheduler
|
|
3509
4569
|
};
|
|
3510
4570
|
}
|
|
3511
4571
|
function resetDaemonSingletonsForTests() {
|
|
@@ -3594,7 +4654,6 @@ export {
|
|
|
3594
4654
|
AdminServer,
|
|
3595
4655
|
ConfigFileProviderConfigSource,
|
|
3596
4656
|
ConsoleLogger,
|
|
3597
|
-
DASHBOARD_HTML,
|
|
3598
4657
|
DEFAULT_ADMIN_PORT,
|
|
3599
4658
|
JsonApiServerSettingsStore,
|
|
3600
4659
|
JsonOutboundKeyDb,
|