@omnicross/daemon 0.1.2 → 0.1.4
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 +3234 -332
- package/dist/cli.js +3238 -318
- package/dist/index.cjs +3144 -266
- package/dist/index.d.cts +960 -33
- package/dist/index.d.ts +960 -33
- package/dist/index.js +3150 -256
- package/package.json +2 -2
package/dist/cli.cjs
CHANGED
|
@@ -293,6 +293,70 @@ var SecretBox = class {
|
|
|
293
293
|
};
|
|
294
294
|
|
|
295
295
|
// src/secrets/secretFields.ts
|
|
296
|
+
function urlHasInlineCredential(url) {
|
|
297
|
+
try {
|
|
298
|
+
const u = new URL(url);
|
|
299
|
+
return u.username.length > 0 || u.password.length > 0;
|
|
300
|
+
} catch {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function transformProxyConfig(cfg, fn) {
|
|
305
|
+
if ("url" in cfg) {
|
|
306
|
+
if (isEnvelope(cfg.url) || urlHasInlineCredential(cfg.url)) {
|
|
307
|
+
return { url: fn(cfg.url) };
|
|
308
|
+
}
|
|
309
|
+
return cfg;
|
|
310
|
+
}
|
|
311
|
+
if (typeof cfg.password === "string" && cfg.password.length > 0) {
|
|
312
|
+
return { ...cfg, password: fn(cfg.password) };
|
|
313
|
+
}
|
|
314
|
+
return cfg;
|
|
315
|
+
}
|
|
316
|
+
function transformOutboundProxy(proxy, fn) {
|
|
317
|
+
const next = {};
|
|
318
|
+
if (proxy.global) next.global = transformProxyConfig(proxy.global, fn);
|
|
319
|
+
if (proxy.byProvider) {
|
|
320
|
+
const byProvider = {};
|
|
321
|
+
for (const [key, value] of Object.entries(proxy.byProvider)) {
|
|
322
|
+
byProvider[key] = transformProxyConfig(value, fn);
|
|
323
|
+
}
|
|
324
|
+
next.byProvider = byProvider;
|
|
325
|
+
}
|
|
326
|
+
return next;
|
|
327
|
+
}
|
|
328
|
+
function encryptProxySegment(proxy, box) {
|
|
329
|
+
return transformOutboundProxy(proxy, (v) => box.encryptMaybe(v));
|
|
330
|
+
}
|
|
331
|
+
function decryptProxySegment(proxy, box) {
|
|
332
|
+
return transformOutboundProxy(proxy, (v) => box.decryptMaybe(v));
|
|
333
|
+
}
|
|
334
|
+
function transformWebhookSegment(webhook, fn) {
|
|
335
|
+
return {
|
|
336
|
+
...webhook,
|
|
337
|
+
destinations: webhook.destinations.map(
|
|
338
|
+
(d) => typeof d.secret === "string" && d.secret.length > 0 ? { ...d, secret: fn(d.secret) } : d
|
|
339
|
+
)
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function encryptWebhookSegment(webhook, box) {
|
|
343
|
+
return transformWebhookSegment(webhook, (v) => box.encryptMaybe(v));
|
|
344
|
+
}
|
|
345
|
+
function decryptWebhookSegment(webhook, box) {
|
|
346
|
+
return transformWebhookSegment(webhook, (v) => box.decryptMaybe(v));
|
|
347
|
+
}
|
|
348
|
+
function transformBillingSegment(billing, fn) {
|
|
349
|
+
if (typeof billing.secret === "string" && billing.secret.length > 0) {
|
|
350
|
+
return { ...billing, secret: fn(billing.secret) };
|
|
351
|
+
}
|
|
352
|
+
return billing;
|
|
353
|
+
}
|
|
354
|
+
function encryptBillingSegment(billing, box) {
|
|
355
|
+
return transformBillingSegment(billing, (v) => box.encryptMaybe(v));
|
|
356
|
+
}
|
|
357
|
+
function decryptBillingSegment(billing, box) {
|
|
358
|
+
return transformBillingSegment(billing, (v) => box.decryptMaybe(v));
|
|
359
|
+
}
|
|
296
360
|
function transformProvider(provider, fn) {
|
|
297
361
|
const next = { ...provider, apiKey: fn(provider.apiKey) };
|
|
298
362
|
if (provider.apiKeys) {
|
|
@@ -316,6 +380,17 @@ function transformConfigSecrets(cfg, fn) {
|
|
|
316
380
|
if (cfg.admin && typeof cfg.admin.token === "string" && cfg.admin.token.length > 0) {
|
|
317
381
|
next.admin = { ...cfg.admin, token: fn(cfg.admin.token) };
|
|
318
382
|
}
|
|
383
|
+
const proxy = cfg.server?.proxy;
|
|
384
|
+
const webhook = cfg.server?.webhook;
|
|
385
|
+
const billing = cfg.server?.billing;
|
|
386
|
+
if (cfg.server && (proxy?.global || proxy?.byProvider || webhook || billing?.secret)) {
|
|
387
|
+
next.server = { ...cfg.server };
|
|
388
|
+
if (proxy && (proxy.global || proxy.byProvider)) {
|
|
389
|
+
next.server.proxy = transformOutboundProxy(proxy, fn);
|
|
390
|
+
}
|
|
391
|
+
if (webhook) next.server.webhook = transformWebhookSegment(webhook, fn);
|
|
392
|
+
if (billing?.secret) next.server.billing = transformBillingSegment(billing, fn);
|
|
393
|
+
}
|
|
319
394
|
return next;
|
|
320
395
|
}
|
|
321
396
|
function encryptConfigSecrets(cfg, box) {
|
|
@@ -353,7 +428,7 @@ function transformTokens(tokens, fn) {
|
|
|
353
428
|
if (Array.isArray(accounts)) {
|
|
354
429
|
bag[accountsKey] = accounts.map((entry) => {
|
|
355
430
|
if (entry && typeof entry === "object" && "tokens" in entry && entry.tokens && typeof entry.tokens === "object") {
|
|
356
|
-
|
|
431
|
+
const nextEntry = {
|
|
357
432
|
...entry,
|
|
358
433
|
tokens: transformTokenBlock(
|
|
359
434
|
entry.tokens,
|
|
@@ -361,6 +436,11 @@ function transformTokens(tokens, fn) {
|
|
|
361
436
|
fn
|
|
362
437
|
)
|
|
363
438
|
};
|
|
439
|
+
const proxy = entry.proxy;
|
|
440
|
+
if (proxy && typeof proxy === "object") {
|
|
441
|
+
nextEntry.proxy = transformProxyConfig(proxy, fn);
|
|
442
|
+
}
|
|
443
|
+
return nextEntry;
|
|
364
444
|
}
|
|
365
445
|
return entry;
|
|
366
446
|
});
|
|
@@ -395,6 +475,17 @@ function resolveAdminConfig(admin) {
|
|
|
395
475
|
token: typeof admin?.token === "string" && admin.token.length > 0 ? admin.token : void 0
|
|
396
476
|
};
|
|
397
477
|
}
|
|
478
|
+
function validateLogging(raw) {
|
|
479
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
480
|
+
const l = raw;
|
|
481
|
+
const out = {};
|
|
482
|
+
if (l["level"] === "error" || l["level"] === "warn" || l["level"] === "info" || l["level"] === "debug") {
|
|
483
|
+
out.level = l["level"];
|
|
484
|
+
}
|
|
485
|
+
if (l["format"] === "text" || l["format"] === "json") out.format = l["format"];
|
|
486
|
+
if (typeof l["file"] === "string" && l["file"].length > 0) out.file = l["file"];
|
|
487
|
+
return out.level !== void 0 || out.format !== void 0 || out.file !== void 0 ? out : void 0;
|
|
488
|
+
}
|
|
398
489
|
var VALID_FORMATS = ["openai", "anthropic", "gemini"];
|
|
399
490
|
function validateApiKeys(raw) {
|
|
400
491
|
if (!Array.isArray(raw)) return void 0;
|
|
@@ -569,7 +660,8 @@ function validateConfig(raw) {
|
|
|
569
660
|
const providers = providersRaw.map((p, i) => validateProvider(p, i));
|
|
570
661
|
const server = obj["server"];
|
|
571
662
|
const admin = validateAdmin(obj["admin"]);
|
|
572
|
-
|
|
663
|
+
const logging = validateLogging(obj["logging"]);
|
|
664
|
+
return { providers, server, admin, logging };
|
|
573
665
|
}
|
|
574
666
|
var secretBox = null;
|
|
575
667
|
function setSecretBox(box) {
|
|
@@ -601,6 +693,9 @@ var import_node_path2 = require("path");
|
|
|
601
693
|
function defaultKeysPath(configPath) {
|
|
602
694
|
return (0, import_node_path2.join)((0, import_node_path2.dirname)(configPath), "keys.json");
|
|
603
695
|
}
|
|
696
|
+
function defaultVouchersPath(configPath) {
|
|
697
|
+
return (0, import_node_path2.join)((0, import_node_path2.dirname)(configPath), "vouchers.json");
|
|
698
|
+
}
|
|
604
699
|
function defaultTokensPath(configPath) {
|
|
605
700
|
return (0, import_node_path2.join)((0, import_node_path2.dirname)(configPath), "tokens.json");
|
|
606
701
|
}
|
|
@@ -610,6 +705,12 @@ function defaultPricingPath(configPath) {
|
|
|
610
705
|
function defaultUsageEventsPath(configPath) {
|
|
611
706
|
return (0, import_node_path2.join)((0, import_node_path2.dirname)(configPath), "usage-events.jsonl");
|
|
612
707
|
}
|
|
708
|
+
function defaultAuditDir(configPath) {
|
|
709
|
+
return (0, import_node_path2.join)((0, import_node_path2.dirname)(configPath), "audit");
|
|
710
|
+
}
|
|
711
|
+
function defaultBillingDir(configPath) {
|
|
712
|
+
return (0, import_node_path2.join)((0, import_node_path2.dirname)(configPath), "billing");
|
|
713
|
+
}
|
|
613
714
|
function resolveSecretBox(masterKeyFilePath) {
|
|
614
715
|
return new SecretBox(() => resolveMasterKey({ keyFilePath: masterKeyFilePath }));
|
|
615
716
|
}
|
|
@@ -709,6 +810,45 @@ var JsonOutboundKeyDb = class {
|
|
|
709
810
|
return true;
|
|
710
811
|
});
|
|
711
812
|
}
|
|
813
|
+
async outboundApiKeysSetMaxConcurrency(id, maxConcurrency) {
|
|
814
|
+
return this.mutateRow(id, (row) => {
|
|
815
|
+
if (row.revokedAt !== null) return false;
|
|
816
|
+
if (maxConcurrency === null) delete row.maxConcurrency;
|
|
817
|
+
else row.maxConcurrency = maxConcurrency;
|
|
818
|
+
return true;
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
async outboundApiKeysSetPolicy(id, policy) {
|
|
822
|
+
return this.mutateRow(id, (row) => {
|
|
823
|
+
if (row.revokedAt !== null) return false;
|
|
824
|
+
applyPolicyField(row, "expiresAt", policy.expiresAt);
|
|
825
|
+
applyPolicyField(row, "activationDays", policy.activationDays);
|
|
826
|
+
applyPolicyField(row, "dailyCostLimitUsd", policy.dailyCostLimitUsd);
|
|
827
|
+
applyPolicyField(row, "totalCostLimitUsd", policy.totalCostLimitUsd);
|
|
828
|
+
applyPolicyField(row, "weeklyCostLimitUsd", policy.weeklyCostLimitUsd);
|
|
829
|
+
applyPolicyField(row, "rateLimitMaxRequests", policy.rateLimitMaxRequests);
|
|
830
|
+
applyPolicyField(row, "rateLimitWindowMs", policy.rateLimitWindowMs);
|
|
831
|
+
if (policy.activationMode === null) delete row.activationMode;
|
|
832
|
+
else if (policy.activationMode !== void 0) row.activationMode = policy.activationMode;
|
|
833
|
+
if (policy.enableModelRestriction === null) delete row.enableModelRestriction;
|
|
834
|
+
else if (policy.enableModelRestriction !== void 0) {
|
|
835
|
+
row.enableModelRestriction = policy.enableModelRestriction;
|
|
836
|
+
}
|
|
837
|
+
if (policy.restrictionMode === null) delete row.restrictionMode;
|
|
838
|
+
else if (policy.restrictionMode !== void 0) row.restrictionMode = policy.restrictionMode;
|
|
839
|
+
if (policy.restrictedModels === null) delete row.restrictedModels;
|
|
840
|
+
else if (policy.restrictedModels !== void 0) row.restrictedModels = policy.restrictedModels;
|
|
841
|
+
return true;
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
async outboundApiKeysMarkActivated(id, activatedAt) {
|
|
845
|
+
return this.mutateRow(id, (row) => {
|
|
846
|
+
if (row.revokedAt !== null) return false;
|
|
847
|
+
if (row.activatedAt != null) return false;
|
|
848
|
+
row.activatedAt = activatedAt;
|
|
849
|
+
return true;
|
|
850
|
+
});
|
|
851
|
+
}
|
|
712
852
|
/** Apply `fn` to the row with `id`, persisting when it returns true. */
|
|
713
853
|
mutateRow(id, fn) {
|
|
714
854
|
const rows = this.readRows();
|
|
@@ -732,6 +872,11 @@ var JsonOutboundKeyDb = class {
|
|
|
732
872
|
(0, import_node_fs4.writeFileSync)(this.keysPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
|
|
733
873
|
}
|
|
734
874
|
};
|
|
875
|
+
function applyPolicyField(row, field, value) {
|
|
876
|
+
if (value === void 0) return;
|
|
877
|
+
if (value === null) delete row[field];
|
|
878
|
+
else row[field] = value;
|
|
879
|
+
}
|
|
735
880
|
|
|
736
881
|
// src/commands/keys.ts
|
|
737
882
|
async function runKeys(argv) {
|
|
@@ -788,18 +933,25 @@ async function keysRevoke(db, id) {
|
|
|
788
933
|
|
|
789
934
|
// src/commands/launch.ts
|
|
790
935
|
var import_node_child_process2 = require("child_process");
|
|
791
|
-
var
|
|
792
|
-
var
|
|
936
|
+
var import_node_fs21 = require("fs");
|
|
937
|
+
var import_node_path13 = require("path");
|
|
793
938
|
var import_node_util3 = require("util");
|
|
794
939
|
var import_cli_launcher2 = require("@omnicross/cli-launcher");
|
|
795
940
|
|
|
796
941
|
// src/bootstrap.ts
|
|
942
|
+
var import_node_fs20 = require("fs");
|
|
943
|
+
var import_audit_types = require("@omnicross/contracts/audit-types");
|
|
944
|
+
var import_billing_types = require("@omnicross/contracts/billing-types");
|
|
797
945
|
var import_GeminiCodeAssistProjectResolver = require("@omnicross/core/auth/GeminiCodeAssistProjectResolver");
|
|
798
946
|
var import_ApiKeyPoolService = require("@omnicross/core/completion/ApiKeyPoolService");
|
|
799
|
-
var
|
|
947
|
+
var import_outbound_api5 = require("@omnicross/core/outbound-api");
|
|
800
948
|
var import_subscriptionRegistryPort = require("@omnicross/core/outbound-api/subscriptionRegistryPort");
|
|
949
|
+
var import_SubscriptionAccountHealth2 = require("@omnicross/core/pipeline/SubscriptionAccountHealth");
|
|
950
|
+
var import_upstreamFetch7 = require("@omnicross/core/pipeline/upstreamFetch");
|
|
951
|
+
var import_SubscriptionIdentityStore2 = require("@omnicross/core/provider-proxy/identity/SubscriptionIdentityStore");
|
|
801
952
|
var import_gemini_code_assist_resolver = require("@omnicross/core/ports/gemini-code-assist-resolver");
|
|
802
953
|
var import_provider_proxy = require("@omnicross/core/provider-proxy");
|
|
954
|
+
var import_outbound_api6 = require("@omnicross/core/outbound-api");
|
|
803
955
|
var import_usage = require("@omnicross/core/usage");
|
|
804
956
|
var import_subscriptions4 = require("@omnicross/subscriptions");
|
|
805
957
|
|
|
@@ -814,6 +966,7 @@ var CodexOAuthSessionStore = class {
|
|
|
814
966
|
ttlMs;
|
|
815
967
|
sessions = /* @__PURE__ */ new Map();
|
|
816
968
|
activeSessionId = null;
|
|
969
|
+
aborters = /* @__PURE__ */ new Map();
|
|
817
970
|
/** Whether a codex sign-in is currently in flight (port 1455 held). */
|
|
818
971
|
isBusy() {
|
|
819
972
|
this.sweep();
|
|
@@ -825,13 +978,22 @@ var CodexOAuthSessionStore = class {
|
|
|
825
978
|
const sessionId = import_node_crypto3.default.randomBytes(24).toString("base64url");
|
|
826
979
|
this.sessions.set(sessionId, { status: "pending", createdAt: Date.now() });
|
|
827
980
|
this.activeSessionId = sessionId;
|
|
828
|
-
|
|
981
|
+
const controller = new AbortController();
|
|
982
|
+
this.aborters.set(sessionId, controller);
|
|
983
|
+
return { sessionId, signal: controller.signal };
|
|
829
984
|
}
|
|
830
985
|
/** Settle a flow (done/error) + free the active slot. */
|
|
831
986
|
settle(sessionId, status, error) {
|
|
832
987
|
const prior = this.sessions.get(sessionId);
|
|
833
988
|
this.sessions.set(sessionId, { status, error, createdAt: prior?.createdAt ?? Date.now() });
|
|
834
989
|
if (this.activeSessionId === sessionId) this.activeSessionId = null;
|
|
990
|
+
this.aborters.delete(sessionId);
|
|
991
|
+
}
|
|
992
|
+
cancel(sessionId) {
|
|
993
|
+
if (!this.sessions.has(sessionId)) return false;
|
|
994
|
+
this.aborters.get(sessionId)?.abort();
|
|
995
|
+
this.settle(sessionId, "error", "login: cancelled");
|
|
996
|
+
return true;
|
|
835
997
|
}
|
|
836
998
|
/** Read a flow's status (token-free), or null when unknown/expired. */
|
|
837
999
|
get(sessionId) {
|
|
@@ -860,13 +1022,13 @@ function handleCodexOAuthStart(deps) {
|
|
|
860
1022
|
);
|
|
861
1023
|
}
|
|
862
1024
|
const { authUrl, codeVerifier, state } = import_subscriptions.codexOAuth.generateAuthParams();
|
|
863
|
-
const sessionId = deps.codexSessions.begin();
|
|
864
|
-
void runCodexLoopback(sessionId, codeVerifier, state, deps);
|
|
1025
|
+
const { sessionId, signal } = deps.codexSessions.begin();
|
|
1026
|
+
void runCodexLoopback(sessionId, codeVerifier, state, signal, deps);
|
|
865
1027
|
return { status: 200, body: { authUrl, sessionId } };
|
|
866
1028
|
}
|
|
867
|
-
async function runCodexLoopback(sessionId, codeVerifier, state, deps) {
|
|
1029
|
+
async function runCodexLoopback(sessionId, codeVerifier, state, signal, deps) {
|
|
868
1030
|
try {
|
|
869
|
-
const code = await deps.codexAwaitLoopback(state);
|
|
1031
|
+
const code = await deps.codexAwaitLoopback(state, void 0, signal);
|
|
870
1032
|
const result = await import_subscriptions.codexOAuth.exchangeCodeForTokens(
|
|
871
1033
|
{ authorizationCode: code, codeVerifier, state },
|
|
872
1034
|
deps.oauthExchangeFetch
|
|
@@ -888,6 +1050,10 @@ async function runCodexLoopback(sessionId, codeVerifier, state, deps) {
|
|
|
888
1050
|
deps.codexSessions.settle(sessionId, "error", reason);
|
|
889
1051
|
}
|
|
890
1052
|
}
|
|
1053
|
+
function handleCodexOAuthCancel(sessionId, deps) {
|
|
1054
|
+
if (!deps.codexSessions.cancel(sessionId)) return err(404, "unknown or expired codex sign-in session");
|
|
1055
|
+
return { status: 200, body: { ok: true } };
|
|
1056
|
+
}
|
|
891
1057
|
function handleCodexOAuthStatus(sessionId, deps) {
|
|
892
1058
|
const s = deps.codexSessions.get(sessionId);
|
|
893
1059
|
if (!s) return err(404, "unknown or expired codex sign-in session");
|
|
@@ -897,10 +1063,129 @@ function handleCodexOAuthStatus(sessionId, deps) {
|
|
|
897
1063
|
// src/admin/AdminServer.ts
|
|
898
1064
|
var import_node_crypto7 = require("crypto");
|
|
899
1065
|
var import_node_http2 = __toESM(require("http"), 1);
|
|
1066
|
+
var import_health_logging_types = require("@omnicross/contracts/health-logging-types");
|
|
1067
|
+
|
|
1068
|
+
// src/admin/accountProbesApi.ts
|
|
1069
|
+
function handleAccountProbes(res, reader) {
|
|
1070
|
+
const accounts = reader ? reader.getAllHistory() : [];
|
|
1071
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1072
|
+
res.end(JSON.stringify({ accounts }));
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// src/admin/auditQueryApi.ts
|
|
1076
|
+
function intParam(value) {
|
|
1077
|
+
if (value === null || value.trim() === "") return void 0;
|
|
1078
|
+
const n = Number(value);
|
|
1079
|
+
return Number.isFinite(n) ? Math.trunc(n) : void 0;
|
|
1080
|
+
}
|
|
1081
|
+
function handleAuditQuery(req, res, reader) {
|
|
1082
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1083
|
+
const query = {};
|
|
1084
|
+
const keyId = url.searchParams.get("keyId");
|
|
1085
|
+
if (keyId && keyId.trim()) query.keyId = keyId.trim();
|
|
1086
|
+
const from = intParam(url.searchParams.get("from"));
|
|
1087
|
+
if (from !== void 0) query.from = from;
|
|
1088
|
+
const to = intParam(url.searchParams.get("to"));
|
|
1089
|
+
if (to !== void 0) query.to = to;
|
|
1090
|
+
const limit = intParam(url.searchParams.get("limit"));
|
|
1091
|
+
if (limit !== void 0) query.limit = limit;
|
|
1092
|
+
const records = reader ? reader(query) : [];
|
|
1093
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1094
|
+
res.end(JSON.stringify({ records }));
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// src/admin/billingStatusApi.ts
|
|
1098
|
+
function handleBillingStatus(res, reader) {
|
|
1099
|
+
const status = reader ? reader() : { total: 0, delivered: 0, pending: 0 };
|
|
1100
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1101
|
+
res.end(JSON.stringify({ status }));
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// src/webhook/webhookRuntime.ts
|
|
1105
|
+
var import_webhookEmit = require("@omnicross/core/pipeline/webhookEmit");
|
|
1106
|
+
var dispatcher = null;
|
|
1107
|
+
var health = null;
|
|
1108
|
+
var unsubscribers = [];
|
|
1109
|
+
var wired = false;
|
|
1110
|
+
function setWebhookRuntime(d, h) {
|
|
1111
|
+
dispatcher = d;
|
|
1112
|
+
health = h;
|
|
1113
|
+
}
|
|
1114
|
+
function applyWebhookConfig(config) {
|
|
1115
|
+
if (!dispatcher) return;
|
|
1116
|
+
dispatcher.setConfig(config);
|
|
1117
|
+
const shouldWire = config?.enabled === true && (config?.destinations.length ?? 0) > 0;
|
|
1118
|
+
if (shouldWire && !wired) {
|
|
1119
|
+
const active = dispatcher;
|
|
1120
|
+
(0, import_webhookEmit.setWebhookSink)((event) => active.emit(event));
|
|
1121
|
+
if (health) {
|
|
1122
|
+
unsubscribers.push(
|
|
1123
|
+
health.onRecovered(
|
|
1124
|
+
(e) => active.emit({ kind: "account.recovery", at: e.at, providerId: e.providerId, accountId: e.accountId })
|
|
1125
|
+
)
|
|
1126
|
+
);
|
|
1127
|
+
unsubscribers.push(
|
|
1128
|
+
health.onAnomaly(
|
|
1129
|
+
(e) => active.emit({
|
|
1130
|
+
kind: "account.anomaly",
|
|
1131
|
+
at: e.at,
|
|
1132
|
+
providerId: e.providerId,
|
|
1133
|
+
accountId: e.accountId,
|
|
1134
|
+
state: e.state
|
|
1135
|
+
})
|
|
1136
|
+
)
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
wired = true;
|
|
1140
|
+
} else if (!shouldWire && wired) {
|
|
1141
|
+
teardown();
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
async function deliverWebhookTest(destinationId) {
|
|
1145
|
+
if (!dispatcher) return { ok: false, error: "webhook dispatcher not wired" };
|
|
1146
|
+
return dispatcher.deliverTest(destinationId);
|
|
1147
|
+
}
|
|
1148
|
+
function teardown() {
|
|
1149
|
+
(0, import_webhookEmit.setWebhookSink)(null);
|
|
1150
|
+
for (const unsub of unsubscribers) unsub();
|
|
1151
|
+
unsubscribers = [];
|
|
1152
|
+
wired = false;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// src/admin/webhookTestApi.ts
|
|
1156
|
+
function readJsonBody(req) {
|
|
1157
|
+
return new Promise((resolve) => {
|
|
1158
|
+
const chunks = [];
|
|
1159
|
+
req.on("data", (c) => chunks.push(c));
|
|
1160
|
+
req.on("end", () => {
|
|
1161
|
+
try {
|
|
1162
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1163
|
+
const parsed = raw ? JSON.parse(raw) : {};
|
|
1164
|
+
resolve(parsed && typeof parsed === "object" ? parsed : {});
|
|
1165
|
+
} catch {
|
|
1166
|
+
resolve({});
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
1169
|
+
req.on("error", () => resolve({}));
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
async function handleWebhookTest(req, res) {
|
|
1173
|
+
const body = await readJsonBody(req);
|
|
1174
|
+
const destinationId = body["destinationId"];
|
|
1175
|
+
if (typeof destinationId !== "string" || !destinationId.trim()) {
|
|
1176
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1177
|
+
res.end(JSON.stringify({ error: { type: "bad_request", message: "destinationId is required" } }));
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
const result = await deliverWebhookTest(destinationId.trim());
|
|
1181
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1182
|
+
res.end(JSON.stringify({ result }));
|
|
1183
|
+
}
|
|
900
1184
|
|
|
901
1185
|
// src/admin/adminApi.ts
|
|
902
1186
|
var import_node_http = __toESM(require("http"), 1);
|
|
903
|
-
var
|
|
1187
|
+
var import_outbound_api3 = require("@omnicross/core/outbound-api");
|
|
1188
|
+
var import_upstreamFetch2 = require("@omnicross/core/pipeline/upstreamFetch");
|
|
904
1189
|
|
|
905
1190
|
// src/pool/resolveEnvKey.ts
|
|
906
1191
|
function resolveEnvKey(rawKey) {
|
|
@@ -995,6 +1280,161 @@ function listMappablePresets() {
|
|
|
995
1280
|
return { mappable, excluded };
|
|
996
1281
|
}
|
|
997
1282
|
|
|
1283
|
+
// src/proxy/sanitizeProxy.ts
|
|
1284
|
+
function sanitizeProxyConfig(cfg) {
|
|
1285
|
+
if ("url" in cfg) {
|
|
1286
|
+
let endpoint;
|
|
1287
|
+
let username;
|
|
1288
|
+
let hasPassword = false;
|
|
1289
|
+
try {
|
|
1290
|
+
const u = new URL(cfg.url);
|
|
1291
|
+
endpoint = u.port ? `${u.hostname}:${u.port}` : u.hostname;
|
|
1292
|
+
username = u.username ? decodeURIComponent(u.username) : void 0;
|
|
1293
|
+
hasPassword = u.password.length > 0;
|
|
1294
|
+
} catch {
|
|
1295
|
+
}
|
|
1296
|
+
return { kind: "url", endpoint, username, hasPassword };
|
|
1297
|
+
}
|
|
1298
|
+
return {
|
|
1299
|
+
kind: cfg.type,
|
|
1300
|
+
endpoint: `${cfg.host}:${cfg.port}`,
|
|
1301
|
+
username: cfg.username,
|
|
1302
|
+
hasPassword: typeof cfg.password === "string" && cfg.password.length > 0
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
function redactProxyConfig(cfg) {
|
|
1306
|
+
if ("url" in cfg) {
|
|
1307
|
+
try {
|
|
1308
|
+
const u = new URL(cfg.url);
|
|
1309
|
+
if (u.password) u.password = "";
|
|
1310
|
+
return { url: u.toString() };
|
|
1311
|
+
} catch {
|
|
1312
|
+
return cfg;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
const { password: _password, ...rest } = cfg;
|
|
1316
|
+
return rest;
|
|
1317
|
+
}
|
|
1318
|
+
function redactOutboundProxy(proxy) {
|
|
1319
|
+
const out = {};
|
|
1320
|
+
if (proxy.global) out.global = redactProxyConfig(proxy.global);
|
|
1321
|
+
if (proxy.byProvider) {
|
|
1322
|
+
const byProvider = {};
|
|
1323
|
+
for (const [key, value] of Object.entries(proxy.byProvider)) {
|
|
1324
|
+
byProvider[key] = redactProxyConfig(value);
|
|
1325
|
+
}
|
|
1326
|
+
out.byProvider = byProvider;
|
|
1327
|
+
}
|
|
1328
|
+
return out;
|
|
1329
|
+
}
|
|
1330
|
+
function preserveProxyConfigSecret(incoming, current) {
|
|
1331
|
+
if (!current) return incoming;
|
|
1332
|
+
if ("url" in incoming) {
|
|
1333
|
+
if ("url" in current) {
|
|
1334
|
+
try {
|
|
1335
|
+
const inU = new URL(incoming.url);
|
|
1336
|
+
const curU = new URL(current.url);
|
|
1337
|
+
if (!inU.password && curU.password) {
|
|
1338
|
+
inU.password = curU.password;
|
|
1339
|
+
return { url: inU.toString() };
|
|
1340
|
+
}
|
|
1341
|
+
} catch {
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return incoming;
|
|
1345
|
+
}
|
|
1346
|
+
if ("url" in current) return incoming;
|
|
1347
|
+
const blank = incoming.password === void 0 || incoming.password === "";
|
|
1348
|
+
if (blank && typeof current.password === "string" && current.password.length > 0) {
|
|
1349
|
+
return { ...incoming, password: current.password };
|
|
1350
|
+
}
|
|
1351
|
+
return incoming;
|
|
1352
|
+
}
|
|
1353
|
+
function preserveOutboundProxySecrets(incoming, current) {
|
|
1354
|
+
const out = {};
|
|
1355
|
+
if (incoming.global) out.global = preserveProxyConfigSecret(incoming.global, current?.global);
|
|
1356
|
+
if (incoming.byProvider) {
|
|
1357
|
+
const byProvider = {};
|
|
1358
|
+
for (const [key, value] of Object.entries(incoming.byProvider)) {
|
|
1359
|
+
byProvider[key] = preserveProxyConfigSecret(value, current?.byProvider?.[key]);
|
|
1360
|
+
}
|
|
1361
|
+
out.byProvider = byProvider;
|
|
1362
|
+
}
|
|
1363
|
+
return out;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// src/proxy/upstreamProxyResolver.ts
|
|
1367
|
+
var import_upstreamFetch = require("@omnicross/core/pipeline/upstreamFetch");
|
|
1368
|
+
var serverProxy;
|
|
1369
|
+
function setServerProxyConfig(proxy) {
|
|
1370
|
+
serverProxy = proxy;
|
|
1371
|
+
(0, import_upstreamFetch.bumpUpstreamProxyGeneration)();
|
|
1372
|
+
}
|
|
1373
|
+
function getServerProxyConfig() {
|
|
1374
|
+
return serverProxy;
|
|
1375
|
+
}
|
|
1376
|
+
var envProxyLoggedFor;
|
|
1377
|
+
function maskProxyUrl(url) {
|
|
1378
|
+
return url.replace(/\/\/[^/@]*@/, "//***@");
|
|
1379
|
+
}
|
|
1380
|
+
function hostFromCtx(ctx) {
|
|
1381
|
+
if (!ctx.url) return void 0;
|
|
1382
|
+
try {
|
|
1383
|
+
return new URL(ctx.url).hostname.toLowerCase();
|
|
1384
|
+
} catch {
|
|
1385
|
+
return void 0;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
function isLoopbackHost(host) {
|
|
1389
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]" || host.startsWith("127.") || host.endsWith(".localhost");
|
|
1390
|
+
}
|
|
1391
|
+
function noProxyMatches(noProxy, host) {
|
|
1392
|
+
if (!noProxy) return false;
|
|
1393
|
+
for (const raw of noProxy.split(",")) {
|
|
1394
|
+
const entry = raw.trim().toLowerCase();
|
|
1395
|
+
if (!entry) continue;
|
|
1396
|
+
if (entry === "*") return true;
|
|
1397
|
+
const bare = entry.startsWith(".") ? entry.slice(1) : entry;
|
|
1398
|
+
if (host === bare || host.endsWith(`.${bare}`)) return true;
|
|
1399
|
+
}
|
|
1400
|
+
return false;
|
|
1401
|
+
}
|
|
1402
|
+
function resolveEnvProxy(ctx, env = process.env) {
|
|
1403
|
+
const raw = env.HTTPS_PROXY ?? env.https_proxy ?? env.ALL_PROXY ?? env.all_proxy ?? env.HTTP_PROXY ?? env.http_proxy;
|
|
1404
|
+
if (!raw || !raw.trim()) return void 0;
|
|
1405
|
+
const host = hostFromCtx(ctx);
|
|
1406
|
+
if (host && (isLoopbackHost(host) || noProxyMatches(env.NO_PROXY ?? env.no_proxy, host))) {
|
|
1407
|
+
return void 0;
|
|
1408
|
+
}
|
|
1409
|
+
const url = raw.trim();
|
|
1410
|
+
if (envProxyLoggedFor !== url) {
|
|
1411
|
+
envProxyLoggedFor = url;
|
|
1412
|
+
console.info(`[upstream-proxy] routing upstream egress through the environment proxy: ${maskProxyUrl(url)}`);
|
|
1413
|
+
}
|
|
1414
|
+
return { url };
|
|
1415
|
+
}
|
|
1416
|
+
function createUpstreamProxyResolver(src = {}) {
|
|
1417
|
+
const readServer = src.getServerProxy ?? getServerProxyConfig;
|
|
1418
|
+
return (ctx) => {
|
|
1419
|
+
const host = hostFromCtx(ctx);
|
|
1420
|
+
if (host) {
|
|
1421
|
+
if (isLoopbackHost(host)) return void 0;
|
|
1422
|
+
const env = src.env ?? process.env;
|
|
1423
|
+
if (noProxyMatches(env.NO_PROXY ?? env.no_proxy, host)) return void 0;
|
|
1424
|
+
}
|
|
1425
|
+
if (src.getAccountProxy && ctx.providerId && ctx.accountId) {
|
|
1426
|
+
const account = src.getAccountProxy(ctx.providerId, ctx.accountId);
|
|
1427
|
+
if (account) return account;
|
|
1428
|
+
}
|
|
1429
|
+
const server = readServer();
|
|
1430
|
+
if (ctx.providerId && server?.byProvider?.[ctx.providerId]) {
|
|
1431
|
+
return server.byProvider[ctx.providerId];
|
|
1432
|
+
}
|
|
1433
|
+
if (server?.global) return server.global;
|
|
1434
|
+
return resolveEnvProxy(ctx, src.env);
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
|
|
998
1438
|
// src/admin/accountsOAuth.ts
|
|
999
1439
|
var import_subscriptions2 = require("@omnicross/subscriptions");
|
|
1000
1440
|
|
|
@@ -1117,6 +1557,24 @@ function validateTokenBody(providerId, body) {
|
|
|
1117
1557
|
return null;
|
|
1118
1558
|
}
|
|
1119
1559
|
}
|
|
1560
|
+
function validateSupportedModelsBody(raw) {
|
|
1561
|
+
if (raw === null || raw === void 0) return { ok: true, value: void 0 };
|
|
1562
|
+
if (Array.isArray(raw)) {
|
|
1563
|
+
if (raw.length === 0) return { ok: false };
|
|
1564
|
+
if (!raw.every((x) => typeof x === "string" && x.trim().length > 0)) return { ok: false };
|
|
1565
|
+
return { ok: true, value: raw };
|
|
1566
|
+
}
|
|
1567
|
+
if (typeof raw === "object") {
|
|
1568
|
+
const entries = Object.entries(raw);
|
|
1569
|
+
if (entries.length === 0) return { ok: false };
|
|
1570
|
+
const valid = entries.every(
|
|
1571
|
+
([k, v]) => k.trim().length > 0 && typeof v === "string" && v.trim().length > 0
|
|
1572
|
+
);
|
|
1573
|
+
if (!valid) return { ok: false };
|
|
1574
|
+
return { ok: true, value: Object.fromEntries(entries) };
|
|
1575
|
+
}
|
|
1576
|
+
return { ok: false };
|
|
1577
|
+
}
|
|
1120
1578
|
async function statusEntryFor(reader, providerId) {
|
|
1121
1579
|
const all = await reader.listAll();
|
|
1122
1580
|
return all.find((a) => a.providerId === providerId) ?? null;
|
|
@@ -1393,6 +1851,424 @@ async function handleCliLaunch(cli, body, ctx) {
|
|
|
1393
1851
|
return { status: 200, body: { sessionId: id, providerId: target.providerId, model: target.model } };
|
|
1394
1852
|
}
|
|
1395
1853
|
|
|
1854
|
+
// src/admin/auditConfigBody.ts
|
|
1855
|
+
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1856
|
+
function validateAuditSegment(patch) {
|
|
1857
|
+
const errors = [];
|
|
1858
|
+
const audit = patch.audit;
|
|
1859
|
+
if (audit === void 0) return errors;
|
|
1860
|
+
if (!isPlainObject(audit)) {
|
|
1861
|
+
errors.push("audit must be an object");
|
|
1862
|
+
return errors;
|
|
1863
|
+
}
|
|
1864
|
+
for (const flag of ["enabled", "captureBodies", "trustForwardedFor"]) {
|
|
1865
|
+
if (audit[flag] !== void 0 && typeof audit[flag] !== "boolean") {
|
|
1866
|
+
errors.push(`audit.${flag} must be a boolean`);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
const maxBodyBytes = audit["maxBodyBytes"];
|
|
1870
|
+
if (maxBodyBytes !== void 0 && (typeof maxBodyBytes !== "number" || !Number.isFinite(maxBodyBytes) || maxBodyBytes < 0)) {
|
|
1871
|
+
errors.push("audit.maxBodyBytes must be a non-negative number");
|
|
1872
|
+
}
|
|
1873
|
+
const retentionDays = audit["retentionDays"];
|
|
1874
|
+
if (retentionDays !== void 0 && (typeof retentionDays !== "number" || !Number.isFinite(retentionDays) || retentionDays < 0)) {
|
|
1875
|
+
errors.push("audit.retentionDays must be a non-negative number");
|
|
1876
|
+
}
|
|
1877
|
+
return errors;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
// src/admin/billingConfigBody.ts
|
|
1881
|
+
var BILLING_SECRET_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
1882
|
+
var isPlainObject2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1883
|
+
function validateBillingSegment(patch) {
|
|
1884
|
+
const errors = [];
|
|
1885
|
+
const billing = patch.billing;
|
|
1886
|
+
if (billing === void 0) return errors;
|
|
1887
|
+
if (!isPlainObject2(billing)) {
|
|
1888
|
+
errors.push("billing must be an object");
|
|
1889
|
+
return errors;
|
|
1890
|
+
}
|
|
1891
|
+
if (billing["enabled"] !== void 0 && typeof billing["enabled"] !== "boolean") {
|
|
1892
|
+
errors.push("billing.enabled must be a boolean");
|
|
1893
|
+
}
|
|
1894
|
+
if (billing["endpoint"] !== void 0 && typeof billing["endpoint"] !== "string") {
|
|
1895
|
+
errors.push("billing.endpoint must be a string");
|
|
1896
|
+
}
|
|
1897
|
+
if (billing["secret"] !== void 0 && typeof billing["secret"] !== "string") {
|
|
1898
|
+
errors.push("billing.secret must be a string");
|
|
1899
|
+
}
|
|
1900
|
+
const maxRetryAgeMs = billing["maxRetryAgeMs"];
|
|
1901
|
+
if (maxRetryAgeMs !== void 0 && (typeof maxRetryAgeMs !== "number" || !Number.isFinite(maxRetryAgeMs) || maxRetryAgeMs < 0)) {
|
|
1902
|
+
errors.push("billing.maxRetryAgeMs must be a non-negative number");
|
|
1903
|
+
}
|
|
1904
|
+
return errors;
|
|
1905
|
+
}
|
|
1906
|
+
function redactBillingConfig(billing) {
|
|
1907
|
+
if (typeof billing.secret === "string" && billing.secret.length > 0) {
|
|
1908
|
+
return { ...billing, secret: BILLING_SECRET_MASK };
|
|
1909
|
+
}
|
|
1910
|
+
return billing;
|
|
1911
|
+
}
|
|
1912
|
+
function preserveBillingSecret(incoming, current) {
|
|
1913
|
+
const isMaskedOrBlank = incoming.secret === void 0 || incoming.secret === "" || incoming.secret === BILLING_SECRET_MASK;
|
|
1914
|
+
if (isMaskedOrBlank) {
|
|
1915
|
+
if (current?.secret) return { ...incoming, secret: current.secret };
|
|
1916
|
+
const { secret: _secret, ...rest } = incoming;
|
|
1917
|
+
return rest;
|
|
1918
|
+
}
|
|
1919
|
+
return incoming;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// src/admin/dashboard.ts
|
|
1923
|
+
function startOfLocalDayMs(ts) {
|
|
1924
|
+
const d = new Date(ts);
|
|
1925
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
1926
|
+
}
|
|
1927
|
+
function accountProviderId(entry) {
|
|
1928
|
+
if (!entry || typeof entry !== "object") return null;
|
|
1929
|
+
const e = entry;
|
|
1930
|
+
if (typeof e["providerId"] === "string" && e["providerId"]) return e["providerId"];
|
|
1931
|
+
if (typeof e["provider"] === "string" && e["provider"]) return e["provider"];
|
|
1932
|
+
return null;
|
|
1933
|
+
}
|
|
1934
|
+
async function handleDashboard(deps) {
|
|
1935
|
+
const now = Date.now();
|
|
1936
|
+
const today = await deps.usageRecorder.getTotals({ startTs: startOfLocalDayMs(now), endTs: now });
|
|
1937
|
+
const total = await deps.usageRecorder.getTotals({ startTs: 0, endTs: now });
|
|
1938
|
+
const providerList = loadConfig(deps.configPath).providers;
|
|
1939
|
+
const providers = {
|
|
1940
|
+
total: providerList.length,
|
|
1941
|
+
enabled: providerList.filter((p) => p.enabled !== false).length
|
|
1942
|
+
};
|
|
1943
|
+
const keys = await deps.keyDb.outboundApiKeysList();
|
|
1944
|
+
const outboundKeys = {
|
|
1945
|
+
total: keys.length,
|
|
1946
|
+
active: keys.filter((k) => k.enabled && k.revokedAt === null).length
|
|
1947
|
+
};
|
|
1948
|
+
const accountsList = await deps.subscriptionAccounts.listAll();
|
|
1949
|
+
const byProvider = {};
|
|
1950
|
+
for (const entry of accountsList) {
|
|
1951
|
+
const providerId = accountProviderId(entry);
|
|
1952
|
+
if (providerId) byProvider[providerId] = (byProvider[providerId] ?? 0) + 1;
|
|
1953
|
+
}
|
|
1954
|
+
const accounts = { total: accountsList.length, byProvider };
|
|
1955
|
+
const status = deps.outboundApiServer.getStatus();
|
|
1956
|
+
const server = {
|
|
1957
|
+
running: status.running,
|
|
1958
|
+
port: status.port,
|
|
1959
|
+
uptimeMs: Math.round(process.uptime() * 1e3)
|
|
1960
|
+
};
|
|
1961
|
+
const summary = { today, total, providers, outboundKeys, accounts, server, generatedAt: now };
|
|
1962
|
+
return { status: 200, body: summary };
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
// src/admin/keyPolicyBody.ts
|
|
1966
|
+
function parseKeyPolicyBody(body) {
|
|
1967
|
+
const policy = {};
|
|
1968
|
+
if ("activationMode" in body) {
|
|
1969
|
+
const m = body["activationMode"];
|
|
1970
|
+
if (m === null) policy.activationMode = null;
|
|
1971
|
+
else if (m === "fixed" || m === "activation") policy.activationMode = m;
|
|
1972
|
+
else return { ok: false, message: "activationMode must be 'fixed', 'activation', or null" };
|
|
1973
|
+
}
|
|
1974
|
+
const numericFields = [
|
|
1975
|
+
{ key: "expiresAt", min: 0 },
|
|
1976
|
+
{ key: "activationDays", min: 1, integer: true },
|
|
1977
|
+
{ key: "dailyCostLimitUsd", min: 0 },
|
|
1978
|
+
{ key: "totalCostLimitUsd", min: 0 },
|
|
1979
|
+
{ key: "weeklyCostLimitUsd", min: 0 },
|
|
1980
|
+
{ key: "rateLimitMaxRequests", min: 0, integer: true },
|
|
1981
|
+
{ key: "rateLimitWindowMs", min: 1 }
|
|
1982
|
+
];
|
|
1983
|
+
for (const { key, min, integer } of numericFields) {
|
|
1984
|
+
if (!(key in body)) continue;
|
|
1985
|
+
const v = body[key];
|
|
1986
|
+
if (v === null) {
|
|
1987
|
+
policy[key] = null;
|
|
1988
|
+
continue;
|
|
1989
|
+
}
|
|
1990
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < min || integer && !Number.isInteger(v)) {
|
|
1991
|
+
return {
|
|
1992
|
+
ok: false,
|
|
1993
|
+
message: `${key} must be ${integer ? "an integer" : "a number"} >= ${min} or null`
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
policy[key] = v;
|
|
1997
|
+
}
|
|
1998
|
+
if ("enableModelRestriction" in body) {
|
|
1999
|
+
const v = body["enableModelRestriction"];
|
|
2000
|
+
if (v === null) policy.enableModelRestriction = null;
|
|
2001
|
+
else if (typeof v === "boolean") policy.enableModelRestriction = v;
|
|
2002
|
+
else return { ok: false, message: "enableModelRestriction must be a boolean or null" };
|
|
2003
|
+
}
|
|
2004
|
+
if ("restrictionMode" in body) {
|
|
2005
|
+
const v = body["restrictionMode"];
|
|
2006
|
+
if (v === null) policy.restrictionMode = null;
|
|
2007
|
+
else if (v === "blacklist" || v === "allowlist") policy.restrictionMode = v;
|
|
2008
|
+
else return { ok: false, message: "restrictionMode must be 'blacklist', 'allowlist', or null" };
|
|
2009
|
+
}
|
|
2010
|
+
if ("restrictedModels" in body) {
|
|
2011
|
+
const v = body["restrictedModels"];
|
|
2012
|
+
if (v === null) {
|
|
2013
|
+
policy.restrictedModels = null;
|
|
2014
|
+
} else if (Array.isArray(v) && v.every((e) => typeof e === "string")) {
|
|
2015
|
+
policy.restrictedModels = v.map((e) => e.trim()).filter((e) => e !== "");
|
|
2016
|
+
} else {
|
|
2017
|
+
return { ok: false, message: "restrictedModels must be an array of strings or null" };
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
return { ok: true, policy };
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
// src/admin/voucherAdmin.ts
|
|
2024
|
+
var import_outbound_api2 = require("@omnicross/core/outbound-api");
|
|
2025
|
+
function writeJson(res, status, body) {
|
|
2026
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
2027
|
+
res.end(JSON.stringify(body));
|
|
2028
|
+
}
|
|
2029
|
+
function writeErr(res, status, message) {
|
|
2030
|
+
writeJson(res, status, { error: { type: "voucher_error", message } });
|
|
2031
|
+
}
|
|
2032
|
+
function readJsonBody2(req) {
|
|
2033
|
+
return new Promise((resolve, reject) => {
|
|
2034
|
+
const chunks = [];
|
|
2035
|
+
req.on("data", (c) => chunks.push(c));
|
|
2036
|
+
req.on("end", () => {
|
|
2037
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
2038
|
+
if (!raw.trim()) return resolve({});
|
|
2039
|
+
try {
|
|
2040
|
+
const parsed = JSON.parse(raw);
|
|
2041
|
+
resolve(parsed && typeof parsed === "object" ? parsed : {});
|
|
2042
|
+
} catch {
|
|
2043
|
+
reject(new Error("invalid-json"));
|
|
2044
|
+
}
|
|
2045
|
+
});
|
|
2046
|
+
req.on("error", reject);
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
2049
|
+
function optPositive(value, integer) {
|
|
2050
|
+
if (value === void 0 || value === null) return { ok: true, value: void 0 };
|
|
2051
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return { ok: false };
|
|
2052
|
+
if (integer && !Number.isInteger(value)) return { ok: false };
|
|
2053
|
+
return { ok: true, value };
|
|
2054
|
+
}
|
|
2055
|
+
function parseVoucherCreateBody(body) {
|
|
2056
|
+
const type = body["type"];
|
|
2057
|
+
if (type !== "credit" && type !== "renewal") {
|
|
2058
|
+
return { ok: false, message: "type must be 'credit' or 'renewal'" };
|
|
2059
|
+
}
|
|
2060
|
+
const maxTotal = optPositive(body["maxTotalCostLimitUsd"], false);
|
|
2061
|
+
if (!maxTotal.ok) return { ok: false, message: "maxTotalCostLimitUsd must be a positive number" };
|
|
2062
|
+
const maxDays = optPositive(body["maxExpiryDays"], true);
|
|
2063
|
+
if (!maxDays.ok) return { ok: false, message: "maxExpiryDays must be a positive integer" };
|
|
2064
|
+
const input = { type };
|
|
2065
|
+
if (maxTotal.value !== void 0) input.maxTotalCostLimitUsd = maxTotal.value;
|
|
2066
|
+
if (maxDays.value !== void 0) input.maxExpiryDays = maxDays.value;
|
|
2067
|
+
if (type === "credit") {
|
|
2068
|
+
const credit = optPositive(body["creditUsd"], false);
|
|
2069
|
+
if (!credit.ok || credit.value === void 0) {
|
|
2070
|
+
return { ok: false, message: "creditUsd must be a positive number for a credit card" };
|
|
2071
|
+
}
|
|
2072
|
+
input.creditUsd = credit.value;
|
|
2073
|
+
} else {
|
|
2074
|
+
const days = optPositive(body["renewalDays"], true);
|
|
2075
|
+
if (!days.ok || days.value === void 0) {
|
|
2076
|
+
return { ok: false, message: "renewalDays must be a positive integer for a renewal card" };
|
|
2077
|
+
}
|
|
2078
|
+
input.renewalDays = days.value;
|
|
2079
|
+
}
|
|
2080
|
+
return { ok: true, input };
|
|
2081
|
+
}
|
|
2082
|
+
async function voucherEnabled(deps) {
|
|
2083
|
+
const config = await (0, import_outbound_api2.loadServerConfig)(deps.settingsStore);
|
|
2084
|
+
return config.voucher?.enabled === true;
|
|
2085
|
+
}
|
|
2086
|
+
async function handleVoucher(req, res, method, rest, deps) {
|
|
2087
|
+
const voucherDb = deps.voucherDb;
|
|
2088
|
+
if (!voucherDb) return writeErr(res, 501, "Voucher feature is not available");
|
|
2089
|
+
if (method === "GET" && rest.length === 0) {
|
|
2090
|
+
const rows = await voucherDb.voucherList();
|
|
2091
|
+
return writeJson(res, 200, { vouchers: rows.map(import_outbound_api2.toVoucherInfo) });
|
|
2092
|
+
}
|
|
2093
|
+
if (method === "POST" && rest.length === 0) {
|
|
2094
|
+
if (!await voucherEnabled(deps)) return writeErr(res, 403, "Voucher feature is disabled");
|
|
2095
|
+
let body;
|
|
2096
|
+
try {
|
|
2097
|
+
body = await readJsonBody2(req);
|
|
2098
|
+
} catch {
|
|
2099
|
+
return writeErr(res, 400, "Invalid JSON in request body");
|
|
2100
|
+
}
|
|
2101
|
+
const parsed = parseVoucherCreateBody(body);
|
|
2102
|
+
if (!parsed.ok) return writeErr(res, 400, parsed.message);
|
|
2103
|
+
const code = (0, import_outbound_api2.generateVoucherCode)();
|
|
2104
|
+
const created = await voucherDb.voucherCreate({
|
|
2105
|
+
id: (0, import_outbound_api2.newVoucherId)(),
|
|
2106
|
+
codeHash: (0, import_outbound_api2.hashVoucherCode)(code),
|
|
2107
|
+
codePrefix: (0, import_outbound_api2.voucherCodePrefix)(code),
|
|
2108
|
+
...parsed.input
|
|
2109
|
+
});
|
|
2110
|
+
return writeJson(res, 201, {
|
|
2111
|
+
id: created.id,
|
|
2112
|
+
codePrefix: created.codePrefix,
|
|
2113
|
+
type: created.type,
|
|
2114
|
+
createdAt: created.createdAt,
|
|
2115
|
+
// `plaintextOnce` is the ONLY place the full code crosses the wire (D3).
|
|
2116
|
+
plaintextOnce: code
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
const id = rest[0];
|
|
2120
|
+
if (method === "POST" && id && rest[1] === "revoke") {
|
|
2121
|
+
if (!await voucherEnabled(deps)) return writeErr(res, 403, "Voucher feature is disabled");
|
|
2122
|
+
const ok = await voucherDb.voucherRevokeCas(id, Date.now());
|
|
2123
|
+
return writeJson(res, ok ? 200 : 409, { ok });
|
|
2124
|
+
}
|
|
2125
|
+
return writeErr(res, 405, `method ${method} not allowed on voucher`);
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
// src/admin/webhookConfigBody.ts
|
|
2129
|
+
var import_webhook_types = require("@omnicross/contracts/webhook-types");
|
|
2130
|
+
var WEBHOOK_SECRET_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
2131
|
+
var isPlainObject3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2132
|
+
function validateWebhookSegment(patch) {
|
|
2133
|
+
const errors = [];
|
|
2134
|
+
const webhook = patch.webhook;
|
|
2135
|
+
if (webhook === void 0) return errors;
|
|
2136
|
+
if (!isPlainObject3(webhook)) {
|
|
2137
|
+
errors.push("webhook must be an object");
|
|
2138
|
+
return errors;
|
|
2139
|
+
}
|
|
2140
|
+
if (typeof webhook["enabled"] !== "boolean") {
|
|
2141
|
+
errors.push("webhook.enabled must be a boolean");
|
|
2142
|
+
}
|
|
2143
|
+
const destinations = webhook["destinations"];
|
|
2144
|
+
if (destinations !== void 0 && !Array.isArray(destinations)) {
|
|
2145
|
+
errors.push("webhook.destinations must be an array");
|
|
2146
|
+
return errors;
|
|
2147
|
+
}
|
|
2148
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
2149
|
+
for (const [i, raw] of (Array.isArray(destinations) ? destinations : []).entries()) {
|
|
2150
|
+
if (!isPlainObject3(raw)) {
|
|
2151
|
+
errors.push(`webhook.destinations[${i}] must be an object`);
|
|
2152
|
+
continue;
|
|
2153
|
+
}
|
|
2154
|
+
const id = raw["id"];
|
|
2155
|
+
if (typeof id !== "string" || !id.trim()) {
|
|
2156
|
+
errors.push(`webhook.destinations[${i}].id must be a non-empty string`);
|
|
2157
|
+
} else if (seenIds.has(id.trim())) {
|
|
2158
|
+
errors.push(`webhook.destinations[${i}].id '${id.trim()}' is duplicated`);
|
|
2159
|
+
} else {
|
|
2160
|
+
seenIds.add(id.trim());
|
|
2161
|
+
}
|
|
2162
|
+
if (typeof raw["type"] !== "string" || !import_webhook_types.WEBHOOK_DESTINATION_TYPES.includes(raw["type"])) {
|
|
2163
|
+
errors.push(`webhook.destinations[${i}].type must be one of ${import_webhook_types.WEBHOOK_DESTINATION_TYPES.join(", ")}`);
|
|
2164
|
+
}
|
|
2165
|
+
if (typeof raw["url"] !== "string" || !raw["url"].trim()) {
|
|
2166
|
+
errors.push(`webhook.destinations[${i}].url must be a non-empty string`);
|
|
2167
|
+
}
|
|
2168
|
+
if (raw["secret"] !== void 0 && typeof raw["secret"] !== "string") {
|
|
2169
|
+
errors.push(`webhook.destinations[${i}].secret must be a string`);
|
|
2170
|
+
}
|
|
2171
|
+
if (raw["enabled"] !== void 0 && typeof raw["enabled"] !== "boolean") {
|
|
2172
|
+
errors.push(`webhook.destinations[${i}].enabled must be a boolean`);
|
|
2173
|
+
}
|
|
2174
|
+
const events = raw["events"];
|
|
2175
|
+
if (events !== void 0) {
|
|
2176
|
+
if (!Array.isArray(events)) {
|
|
2177
|
+
errors.push(`webhook.destinations[${i}].events must be an array`);
|
|
2178
|
+
} else {
|
|
2179
|
+
for (const e of events) {
|
|
2180
|
+
if (typeof e !== "string" || !import_webhook_types.WEBHOOK_EVENT_KINDS.includes(e)) {
|
|
2181
|
+
errors.push(`webhook.destinations[${i}].events contains an unknown kind '${String(e)}'`);
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
return errors;
|
|
2188
|
+
}
|
|
2189
|
+
function redactWebhookConfig(webhook) {
|
|
2190
|
+
return {
|
|
2191
|
+
...webhook,
|
|
2192
|
+
destinations: webhook.destinations.map(
|
|
2193
|
+
(d) => typeof d.secret === "string" && d.secret.length > 0 ? { ...d, secret: WEBHOOK_SECRET_MASK } : d
|
|
2194
|
+
)
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2197
|
+
function preserveWebhookSecrets(incoming, current) {
|
|
2198
|
+
const currentById = /* @__PURE__ */ new Map();
|
|
2199
|
+
for (const d of current?.destinations ?? []) currentById.set(d.id, d);
|
|
2200
|
+
return {
|
|
2201
|
+
...incoming,
|
|
2202
|
+
destinations: incoming.destinations.map((d) => {
|
|
2203
|
+
const isMaskedOrBlank = d.secret === void 0 || d.secret === "" || d.secret === WEBHOOK_SECRET_MASK;
|
|
2204
|
+
if (isMaskedOrBlank) {
|
|
2205
|
+
const prev = currentById.get(d.id);
|
|
2206
|
+
if (prev?.secret) return { ...d, secret: prev.secret };
|
|
2207
|
+
const { secret: _secret, ...rest } = d;
|
|
2208
|
+
return rest;
|
|
2209
|
+
}
|
|
2210
|
+
return d;
|
|
2211
|
+
})
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
// src/audit/auditRuntime.ts
|
|
2216
|
+
var import_auditSink = require("@omnicross/core/pipeline/auditSink");
|
|
2217
|
+
var writer = null;
|
|
2218
|
+
var sweeper = null;
|
|
2219
|
+
function setAuditRuntime(w, s) {
|
|
2220
|
+
writer = w;
|
|
2221
|
+
sweeper = s;
|
|
2222
|
+
}
|
|
2223
|
+
function applyAuditConfig(config) {
|
|
2224
|
+
const enabled = config?.enabled === true && writer !== null;
|
|
2225
|
+
if (enabled && config) {
|
|
2226
|
+
(0, import_auditSink.setAuditCaptureConfig)(config);
|
|
2227
|
+
const activeWriter = writer;
|
|
2228
|
+
(0, import_auditSink.setAuditSink)((record) => activeWriter.record(record));
|
|
2229
|
+
if (sweeper) {
|
|
2230
|
+
sweeper.configure(config);
|
|
2231
|
+
sweeper.start();
|
|
2232
|
+
}
|
|
2233
|
+
} else {
|
|
2234
|
+
(0, import_auditSink.setAuditCaptureConfig)(null);
|
|
2235
|
+
(0, import_auditSink.setAuditSink)(null);
|
|
2236
|
+
if (sweeper) {
|
|
2237
|
+
if (config) sweeper.configure(config);
|
|
2238
|
+
sweeper.dispose();
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
// src/billing/billingRuntime.ts
|
|
2244
|
+
var import_billingEmit = require("@omnicross/core/pipeline/billingEmit");
|
|
2245
|
+
var publisher = null;
|
|
2246
|
+
var sweeper2 = null;
|
|
2247
|
+
function setBillingRuntime(p, s) {
|
|
2248
|
+
publisher = p;
|
|
2249
|
+
sweeper2 = s;
|
|
2250
|
+
}
|
|
2251
|
+
function applyBillingConfig(config) {
|
|
2252
|
+
const enabled = config?.enabled === true && publisher !== null;
|
|
2253
|
+
if (enabled && config) {
|
|
2254
|
+
const activePublisher = publisher;
|
|
2255
|
+
activePublisher.setConfig(config);
|
|
2256
|
+
(0, import_billingEmit.setBillingCaptureConfig)(config);
|
|
2257
|
+
(0, import_billingEmit.setBillingSink)((event) => activePublisher.record(event));
|
|
2258
|
+
if (sweeper2) {
|
|
2259
|
+
sweeper2.configure(config);
|
|
2260
|
+
sweeper2.start();
|
|
2261
|
+
}
|
|
2262
|
+
} else {
|
|
2263
|
+
(0, import_billingEmit.setBillingCaptureConfig)(null);
|
|
2264
|
+
(0, import_billingEmit.setBillingSink)(null);
|
|
2265
|
+
if (sweeper2) {
|
|
2266
|
+
if (config) sweeper2.configure(config);
|
|
2267
|
+
sweeper2.dispose();
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
|
|
1396
2272
|
// src/ports/account-multi.ts
|
|
1397
2273
|
var import_node_crypto5 = require("crypto");
|
|
1398
2274
|
var PROVIDER_KEYS = {
|
|
@@ -1504,6 +2380,9 @@ function getAccountById(config, p, id) {
|
|
|
1504
2380
|
const account = getAccounts(config, p).find((a) => a.id === id);
|
|
1505
2381
|
return account ? { id: account.id, tokens: account.tokens } : void 0;
|
|
1506
2382
|
}
|
|
2383
|
+
function getAccountProxy(config, p, id) {
|
|
2384
|
+
return getAccounts(config, p).find((a) => a.id === id)?.proxy;
|
|
2385
|
+
}
|
|
1507
2386
|
function getActiveAccount(config, p) {
|
|
1508
2387
|
const active = getAccounts(config, p).find((a) => a.id === getActiveId(config, p));
|
|
1509
2388
|
return active ? { id: active.id, tokens: active.tokens } : void 0;
|
|
@@ -1544,7 +2423,17 @@ function sanitizeAccounts(config, p) {
|
|
|
1544
2423
|
isSetupToken: t.isSetupToken,
|
|
1545
2424
|
hasAccessToken: !!(t.accessToken || t.apiKey),
|
|
1546
2425
|
isActive: a.id === activeId,
|
|
1547
|
-
|
|
2426
|
+
// Scheduling metadata (subscription-account-scheduling): editable priority
|
|
2427
|
+
// (default 50 shown when unset) + display-only lastUsedAt. Secret-free.
|
|
2428
|
+
priority: a.priority,
|
|
2429
|
+
lastUsedAt: a.lastUsedAt,
|
|
2430
|
+
syncWarning: t.syncWarning,
|
|
2431
|
+
// Per-account proxy (upstream-proxy): masked view — password → hasPassword,
|
|
2432
|
+
// userinfo stripped. The plaintext password is NEVER projected.
|
|
2433
|
+
proxy: a.proxy ? sanitizeProxyConfig(a.proxy) : void 0,
|
|
2434
|
+
// Per-account model support / remap (subscription-account-model-map): model
|
|
2435
|
+
// ids are not token material → carried through verbatim for the editor.
|
|
2436
|
+
supportedModels: a.supportedModels
|
|
1548
2437
|
};
|
|
1549
2438
|
});
|
|
1550
2439
|
}
|
|
@@ -1558,8 +2447,79 @@ function renameAccount(config, p, id, label) {
|
|
|
1558
2447
|
);
|
|
1559
2448
|
return { ok: true };
|
|
1560
2449
|
}
|
|
1561
|
-
function
|
|
1562
|
-
|
|
2450
|
+
function setAccountPriority(config, p, id, priority) {
|
|
2451
|
+
const accounts = getAccounts(config, p);
|
|
2452
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2453
|
+
setAccounts(
|
|
2454
|
+
config,
|
|
2455
|
+
p,
|
|
2456
|
+
accounts.map((a) => a.id === id ? { ...a, priority } : a)
|
|
2457
|
+
);
|
|
2458
|
+
return { ok: true };
|
|
2459
|
+
}
|
|
2460
|
+
function setAccountProxy(config, p, id, proxy) {
|
|
2461
|
+
const accounts = getAccounts(config, p);
|
|
2462
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2463
|
+
setAccounts(
|
|
2464
|
+
config,
|
|
2465
|
+
p,
|
|
2466
|
+
accounts.map((a) => {
|
|
2467
|
+
if (a.id !== id) return a;
|
|
2468
|
+
if (!proxy) {
|
|
2469
|
+
const { proxy: _drop, ...rest } = a;
|
|
2470
|
+
return rest;
|
|
2471
|
+
}
|
|
2472
|
+
return { ...a, proxy };
|
|
2473
|
+
})
|
|
2474
|
+
);
|
|
2475
|
+
return { ok: true };
|
|
2476
|
+
}
|
|
2477
|
+
function setAccountSupportedModels(config, p, id, supportedModels) {
|
|
2478
|
+
const accounts = getAccounts(config, p);
|
|
2479
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2480
|
+
setAccounts(
|
|
2481
|
+
config,
|
|
2482
|
+
p,
|
|
2483
|
+
accounts.map((a) => {
|
|
2484
|
+
if (a.id !== id) return a;
|
|
2485
|
+
if (supportedModels === void 0) {
|
|
2486
|
+
const { supportedModels: _drop, ...rest } = a;
|
|
2487
|
+
return rest;
|
|
2488
|
+
}
|
|
2489
|
+
return { ...a, supportedModels };
|
|
2490
|
+
})
|
|
2491
|
+
);
|
|
2492
|
+
return { ok: true };
|
|
2493
|
+
}
|
|
2494
|
+
function setAccountLastUsed(config, p, id, iso) {
|
|
2495
|
+
const accounts = getAccounts(config, p);
|
|
2496
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2497
|
+
setAccounts(
|
|
2498
|
+
config,
|
|
2499
|
+
p,
|
|
2500
|
+
accounts.map((a) => a.id === id ? { ...a, lastUsedAt: iso } : a)
|
|
2501
|
+
);
|
|
2502
|
+
return { ok: true };
|
|
2503
|
+
}
|
|
2504
|
+
function setAccountIdentity(config, p, id, identity) {
|
|
2505
|
+
const accounts = getAccounts(config, p);
|
|
2506
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2507
|
+
setAccounts(
|
|
2508
|
+
config,
|
|
2509
|
+
p,
|
|
2510
|
+
accounts.map((a) => {
|
|
2511
|
+
if (a.id !== id) return a;
|
|
2512
|
+
if (identity === void 0) {
|
|
2513
|
+
const { identity: _drop, ...rest } = a;
|
|
2514
|
+
return rest;
|
|
2515
|
+
}
|
|
2516
|
+
return { ...a, identity };
|
|
2517
|
+
})
|
|
2518
|
+
);
|
|
2519
|
+
return { ok: true };
|
|
2520
|
+
}
|
|
2521
|
+
function clearProvider(config, p) {
|
|
2522
|
+
setBlock(config, p, void 0);
|
|
1563
2523
|
setAccounts(config, p, void 0);
|
|
1564
2524
|
setActiveId(config, p, void 0);
|
|
1565
2525
|
}
|
|
@@ -1823,6 +2783,12 @@ function parseRange(query) {
|
|
|
1823
2783
|
return { startTs, endTs };
|
|
1824
2784
|
}
|
|
1825
2785
|
var isRange = (v) => v.startTs !== void 0 && !("status" in v);
|
|
2786
|
+
var BUCKET_SPAN_MS = {
|
|
2787
|
+
hour: 36e5,
|
|
2788
|
+
day: 864e5,
|
|
2789
|
+
month: 28 * 864e5
|
|
2790
|
+
};
|
|
2791
|
+
var MAX_TIMESERIES_BUCKETS = 2e3;
|
|
1826
2792
|
async function handleUsageGet(view, query, deps) {
|
|
1827
2793
|
const range = parseRange(query);
|
|
1828
2794
|
if (!isRange(range)) return range;
|
|
@@ -1831,6 +2797,24 @@ async function handleUsageGet(view, query, deps) {
|
|
|
1831
2797
|
return { status: 200, body: await deps.usageRecorder.getTotals(range) };
|
|
1832
2798
|
case "by-model":
|
|
1833
2799
|
return { status: 200, body: await deps.usageRecorder.getByModel(range) };
|
|
2800
|
+
case "timeseries": {
|
|
2801
|
+
const bucket = query.get("bucket");
|
|
2802
|
+
if (bucket !== "hour" && bucket !== "day" && bucket !== "month") {
|
|
2803
|
+
return err4(400, "bucket must be one of 'hour', 'day', 'month'");
|
|
2804
|
+
}
|
|
2805
|
+
const now = Date.now();
|
|
2806
|
+
const clamped = { startTs: range.startTs, endTs: Math.min(range.endTs, now) };
|
|
2807
|
+
if (clamped.startTs < clamped.endTs) {
|
|
2808
|
+
const projected = Math.ceil((clamped.endTs - clamped.startTs) / BUCKET_SPAN_MS[bucket]) + 1;
|
|
2809
|
+
if (projected > MAX_TIMESERIES_BUCKETS) {
|
|
2810
|
+
return err4(
|
|
2811
|
+
400,
|
|
2812
|
+
`requested range projects ~${projected} '${bucket}' buckets (max ${MAX_TIMESERIES_BUCKETS}); narrow the range or use a coarser bucket`
|
|
2813
|
+
);
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
return { status: 200, body: await deps.usageRecorder.getTimeSeries(clamped, bucket) };
|
|
2817
|
+
}
|
|
1834
2818
|
case "by-api-key": {
|
|
1835
2819
|
const rows = await deps.usageRecorder.getByApiKey(range);
|
|
1836
2820
|
const labels = poolKeyLabels(loadConfig(deps.configPath));
|
|
@@ -1976,7 +2960,7 @@ function readBody(req) {
|
|
|
1976
2960
|
req.on("error", reject);
|
|
1977
2961
|
});
|
|
1978
2962
|
}
|
|
1979
|
-
async function
|
|
2963
|
+
async function readJsonBody3(req) {
|
|
1980
2964
|
const raw = await readBody(req);
|
|
1981
2965
|
if (!raw.trim()) return {};
|
|
1982
2966
|
try {
|
|
@@ -1986,12 +2970,12 @@ async function readJsonBody(req) {
|
|
|
1986
2970
|
return {};
|
|
1987
2971
|
}
|
|
1988
2972
|
}
|
|
1989
|
-
function
|
|
2973
|
+
function writeJson2(res, status, body) {
|
|
1990
2974
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1991
2975
|
res.end(JSON.stringify(body));
|
|
1992
2976
|
}
|
|
1993
2977
|
function writeJsonError(res, status, message) {
|
|
1994
|
-
|
|
2978
|
+
writeJson2(res, status, { error: { type: "admin_api_error", message } });
|
|
1995
2979
|
}
|
|
1996
2980
|
function maskProviderApiKey(apiKey) {
|
|
1997
2981
|
if (!apiKey) return "";
|
|
@@ -2007,7 +2991,23 @@ function toKeyInfo(row) {
|
|
|
2007
2991
|
enabled: row.enabled,
|
|
2008
2992
|
createdAt: row.createdAt,
|
|
2009
2993
|
lastUsedAt: row.lastUsedAt,
|
|
2010
|
-
revoked: row.revokedAt !== null
|
|
2994
|
+
revoked: row.revokedAt !== null,
|
|
2995
|
+
maxConcurrency: row.maxConcurrency,
|
|
2996
|
+
// Key-policy envelope (outbound-key-policy) — all secret-free scalar fields;
|
|
2997
|
+
// the UI reads them to render + pre-fill the policy editor.
|
|
2998
|
+
expiresAt: row.expiresAt,
|
|
2999
|
+
activationMode: row.activationMode,
|
|
3000
|
+
activationDays: row.activationDays,
|
|
3001
|
+
activatedAt: row.activatedAt,
|
|
3002
|
+
dailyCostLimitUsd: row.dailyCostLimitUsd,
|
|
3003
|
+
totalCostLimitUsd: row.totalCostLimitUsd,
|
|
3004
|
+
weeklyCostLimitUsd: row.weeklyCostLimitUsd,
|
|
3005
|
+
rateLimitMaxRequests: row.rateLimitMaxRequests,
|
|
3006
|
+
rateLimitWindowMs: row.rateLimitWindowMs,
|
|
3007
|
+
// Per-key model restriction (#6) — secret-free scalars the UI pre-fills.
|
|
3008
|
+
enableModelRestriction: row.enableModelRestriction,
|
|
3009
|
+
restrictionMode: row.restrictionMode,
|
|
3010
|
+
restrictedModels: row.restrictedModels
|
|
2011
3011
|
};
|
|
2012
3012
|
}
|
|
2013
3013
|
function toProviderView(row) {
|
|
@@ -2069,6 +3069,8 @@ async function handleAdminApi(req, res, path2, deps) {
|
|
|
2069
3069
|
return handlePresets(res, method);
|
|
2070
3070
|
case "keys":
|
|
2071
3071
|
return await handleKeys(req, res, method, rest, deps);
|
|
3072
|
+
case "voucher":
|
|
3073
|
+
return await handleVoucher(req, res, method, rest, deps);
|
|
2072
3074
|
case "server":
|
|
2073
3075
|
return await handleServer(req, res, method, deps);
|
|
2074
3076
|
case "accounts":
|
|
@@ -2085,6 +3087,8 @@ async function handleAdminApi(req, res, path2, deps) {
|
|
|
2085
3087
|
return await handleMigrationImport(req, res, method, deps);
|
|
2086
3088
|
case "usage":
|
|
2087
3089
|
return await handleUsage(req, res, method, rest, deps);
|
|
3090
|
+
case "dashboard":
|
|
3091
|
+
return await handleDashboardRoute(res, method, deps);
|
|
2088
3092
|
case "pricing":
|
|
2089
3093
|
return await handlePricing(req, res, method, rest, deps);
|
|
2090
3094
|
default:
|
|
@@ -2100,17 +3104,22 @@ function requestQuery(req) {
|
|
|
2100
3104
|
return new URLSearchParams(qIdx >= 0 ? raw.slice(qIdx + 1) : "");
|
|
2101
3105
|
}
|
|
2102
3106
|
function writeResult(res, result) {
|
|
2103
|
-
|
|
3107
|
+
writeJson2(res, result.status, result.body);
|
|
2104
3108
|
}
|
|
2105
3109
|
async function handleUsage(req, res, method, rest, deps) {
|
|
2106
3110
|
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on usage`);
|
|
2107
3111
|
return writeResult(res, await handleUsageGet(rest[0], requestQuery(req), deps));
|
|
2108
3112
|
}
|
|
3113
|
+
async function handleDashboardRoute(res, method, deps) {
|
|
3114
|
+
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on dashboard`);
|
|
3115
|
+
const result = await handleDashboard(deps);
|
|
3116
|
+
return writeJson2(res, result.status, result.body);
|
|
3117
|
+
}
|
|
2109
3118
|
async function handlePricing(req, res, method, rest, deps) {
|
|
2110
3119
|
if (rest.length === 0) {
|
|
2111
3120
|
if (method === "GET") return writeResult(res, await handlePricingList(deps));
|
|
2112
3121
|
if (method === "PUT") {
|
|
2113
|
-
return writeResult(res, await handlePricingUpsert(await
|
|
3122
|
+
return writeResult(res, await handlePricingUpsert(await readJsonBody3(req), deps));
|
|
2114
3123
|
}
|
|
2115
3124
|
if (method === "DELETE") {
|
|
2116
3125
|
return writeResult(res, await handlePricingDelete(requestQuery(req), deps));
|
|
@@ -2121,7 +3130,7 @@ async function handlePricing(req, res, method, rest, deps) {
|
|
|
2121
3130
|
return writeResult(res, await handlePricingFetchLatest(deps));
|
|
2122
3131
|
}
|
|
2123
3132
|
if (method === "POST" && rest.length === 1 && rest[0] === "resolve-conflicts") {
|
|
2124
|
-
return writeResult(res, await handlePricingResolveConflicts(await
|
|
3133
|
+
return writeResult(res, await handlePricingResolveConflicts(await readJsonBody3(req), deps));
|
|
2125
3134
|
}
|
|
2126
3135
|
return writeJsonError(res, 404, `unknown pricing route '${rest.join("/")}'`);
|
|
2127
3136
|
}
|
|
@@ -2135,15 +3144,15 @@ function migrationDeps(deps) {
|
|
|
2135
3144
|
}
|
|
2136
3145
|
async function handleMigrationExport(req, res, method, deps) {
|
|
2137
3146
|
if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on export`);
|
|
2138
|
-
const body = await
|
|
3147
|
+
const body = await readJsonBody3(req);
|
|
2139
3148
|
const result = await handleExport(body, migrationDeps(deps));
|
|
2140
|
-
return
|
|
3149
|
+
return writeJson2(res, result.status, result.body);
|
|
2141
3150
|
}
|
|
2142
3151
|
async function handleMigrationImport(req, res, method, deps) {
|
|
2143
3152
|
if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on import`);
|
|
2144
|
-
const body = await
|
|
3153
|
+
const body = await readJsonBody3(req);
|
|
2145
3154
|
const result = await handleImport(body, migrationDeps(deps));
|
|
2146
|
-
return
|
|
3155
|
+
return writeJson2(res, result.status, result.body);
|
|
2147
3156
|
}
|
|
2148
3157
|
async function handleProviders(req, res, method, rest, deps) {
|
|
2149
3158
|
const cfg = loadConfig(deps.configPath);
|
|
@@ -2174,13 +3183,13 @@ async function handleProviders(req, res, method, rest, deps) {
|
|
|
2174
3183
|
if (method === "GET" && rest.length === 2 && rest[1] === "reveal-key") {
|
|
2175
3184
|
const row = cfg.providers.find((p) => p.id === rest[0]);
|
|
2176
3185
|
if (!row) return writeJsonError(res, 404, `provider '${rest[0]}' not found`);
|
|
2177
|
-
return
|
|
3186
|
+
return writeJson2(res, 200, { apiKey: row.apiKey ?? "" });
|
|
2178
3187
|
}
|
|
2179
3188
|
if (method === "GET") {
|
|
2180
|
-
return
|
|
3189
|
+
return writeJson2(res, 200, { providers: cfg.providers.map(toProviderView) });
|
|
2181
3190
|
}
|
|
2182
3191
|
if (method === "POST") {
|
|
2183
|
-
const body = await
|
|
3192
|
+
const body = await readJsonBody3(req);
|
|
2184
3193
|
const provider = parseProviderInput(body, void 0);
|
|
2185
3194
|
if (!provider) return writeJsonError(res, 400, "invalid provider (id, apiFormat, baseUrl required)");
|
|
2186
3195
|
if (cfg.providers.some((p) => p.id === provider.id)) {
|
|
@@ -2188,25 +3197,25 @@ async function handleProviders(req, res, method, rest, deps) {
|
|
|
2188
3197
|
}
|
|
2189
3198
|
cfg.providers.push(provider);
|
|
2190
3199
|
persistProviders(cfg, deps);
|
|
2191
|
-
return
|
|
3200
|
+
return writeJson2(res, 201, { provider: toProviderView(provider) });
|
|
2192
3201
|
}
|
|
2193
3202
|
const id = rest[0];
|
|
2194
3203
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
2195
3204
|
const idx = cfg.providers.findIndex((p) => p.id === id);
|
|
2196
3205
|
if (idx < 0) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
2197
3206
|
if (method === "PUT") {
|
|
2198
|
-
const body = await
|
|
3207
|
+
const body = await readJsonBody3(req);
|
|
2199
3208
|
const existing = cfg.providers[idx];
|
|
2200
3209
|
const updated = parseProviderInput(body, existing);
|
|
2201
3210
|
if (!updated) return writeJsonError(res, 400, "invalid provider (apiFormat, baseUrl required)");
|
|
2202
3211
|
cfg.providers[idx] = updated;
|
|
2203
3212
|
persistProviders(cfg, deps);
|
|
2204
|
-
return
|
|
3213
|
+
return writeJson2(res, 200, { provider: toProviderView(updated) });
|
|
2205
3214
|
}
|
|
2206
3215
|
if (method === "DELETE") {
|
|
2207
3216
|
cfg.providers.splice(idx, 1);
|
|
2208
3217
|
persistProviders(cfg, deps);
|
|
2209
|
-
return
|
|
3218
|
+
return writeJson2(res, 200, { ok: true });
|
|
2210
3219
|
}
|
|
2211
3220
|
return writeJsonError(res, 405, `method ${method} not allowed on providers`);
|
|
2212
3221
|
}
|
|
@@ -2215,7 +3224,7 @@ function persistProviders(cfg, deps) {
|
|
|
2215
3224
|
deps.llmConfig.reload(cfg);
|
|
2216
3225
|
}
|
|
2217
3226
|
async function handleProviderReorder(req, res, cfg, deps) {
|
|
2218
|
-
const body = await
|
|
3227
|
+
const body = await readJsonBody3(req);
|
|
2219
3228
|
const rawOrder = body["order"];
|
|
2220
3229
|
if (!Array.isArray(rawOrder)) {
|
|
2221
3230
|
return writeJsonError(res, 400, "reorder requires { order: string[] }");
|
|
@@ -2239,14 +3248,14 @@ async function handleProviderReorder(req, res, cfg, deps) {
|
|
|
2239
3248
|
}
|
|
2240
3249
|
cfg.providers = reordered;
|
|
2241
3250
|
persistProviders(cfg, deps);
|
|
2242
|
-
return
|
|
3251
|
+
return writeJson2(res, 200, { ok: true, providers: cfg.providers.map(toProviderView) });
|
|
2243
3252
|
}
|
|
2244
3253
|
async function handleDiscoverModels(res, id, cfg) {
|
|
2245
3254
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
2246
3255
|
const row = cfg.providers.find((p) => p.id === id);
|
|
2247
3256
|
if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
2248
3257
|
if (row.apiFormat !== "openai") {
|
|
2249
|
-
return
|
|
3258
|
+
return writeJson2(res, 200, { models: [], unsupportedFormat: true });
|
|
2250
3259
|
}
|
|
2251
3260
|
const resolvedKey = resolveEnvKey(row.apiKey);
|
|
2252
3261
|
const base = row.baseUrl.replace(/\/+$/, "");
|
|
@@ -2254,7 +3263,7 @@ async function handleDiscoverModels(res, id, cfg) {
|
|
|
2254
3263
|
try {
|
|
2255
3264
|
const headers = { Accept: "application/json" };
|
|
2256
3265
|
if (resolvedKey) headers["Authorization"] = `Bearer ${resolvedKey}`;
|
|
2257
|
-
const response = await
|
|
3266
|
+
const response = await (0, import_upstreamFetch2.fetchUpstream)(url, { method: "GET", headers }, { providerId: "byo" });
|
|
2258
3267
|
if (!response.ok) {
|
|
2259
3268
|
const text = await response.text().catch(() => "");
|
|
2260
3269
|
let message = text.slice(0, 300);
|
|
@@ -2263,32 +3272,32 @@ async function handleDiscoverModels(res, id, cfg) {
|
|
|
2263
3272
|
message = parsed?.error?.message || parsed?.message || message;
|
|
2264
3273
|
} catch {
|
|
2265
3274
|
}
|
|
2266
|
-
return
|
|
3275
|
+
return writeJson2(res, 200, {
|
|
2267
3276
|
models: [],
|
|
2268
3277
|
error: `discovery failed (${response.status})${message ? `: ${message}` : ""}`
|
|
2269
3278
|
});
|
|
2270
3279
|
}
|
|
2271
3280
|
const data = await response.json();
|
|
2272
3281
|
const models = Array.isArray(data?.data) ? data.data.map((m) => typeof m?.id === "string" ? m.id : "").filter((m) => m.length > 0) : [];
|
|
2273
|
-
return
|
|
3282
|
+
return writeJson2(res, 200, { models });
|
|
2274
3283
|
} catch (err5) {
|
|
2275
3284
|
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
2276
|
-
return
|
|
3285
|
+
return writeJson2(res, 200, { models: [], error: `discovery failed: ${message}` });
|
|
2277
3286
|
}
|
|
2278
3287
|
}
|
|
2279
3288
|
async function handleTestModel(req, res, id, cfg) {
|
|
2280
3289
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
2281
3290
|
const row = cfg.providers.find((p) => p.id === id);
|
|
2282
3291
|
if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
2283
|
-
const body = await
|
|
3292
|
+
const body = await readJsonBody3(req);
|
|
2284
3293
|
const model = typeof body["model"] === "string" ? body["model"].trim() : "";
|
|
2285
3294
|
if (!model) return writeJsonError(res, 400, "test requires a { model } string");
|
|
2286
3295
|
if (row.apiFormat === "gemini") {
|
|
2287
|
-
return
|
|
3296
|
+
return writeJson2(res, 200, { ok: false, unsupportedFormat: true });
|
|
2288
3297
|
}
|
|
2289
3298
|
const resolvedKey = resolveEnvKey(row.apiKey);
|
|
2290
3299
|
if (!resolvedKey) {
|
|
2291
|
-
return
|
|
3300
|
+
return writeJson2(res, 200, { ok: false, message: "no API key configured for this provider" });
|
|
2292
3301
|
}
|
|
2293
3302
|
const url = row.baseUrl.replace(/\/+$/, "");
|
|
2294
3303
|
const prompt = "Reply with the single word: OK.";
|
|
@@ -2309,11 +3318,11 @@ async function handleTestModel(req, res, id, cfg) {
|
|
|
2309
3318
|
}
|
|
2310
3319
|
const startedAt = Date.now();
|
|
2311
3320
|
try {
|
|
2312
|
-
const response = await
|
|
2313
|
-
|
|
2314
|
-
headers,
|
|
2315
|
-
|
|
2316
|
-
|
|
3321
|
+
const response = await (0, import_upstreamFetch2.fetchUpstream)(
|
|
3322
|
+
url,
|
|
3323
|
+
{ method: "POST", headers, body: JSON.stringify(payload) },
|
|
3324
|
+
{ providerId: "byo" }
|
|
3325
|
+
);
|
|
2317
3326
|
const latencyMs = Date.now() - startedAt;
|
|
2318
3327
|
const text = await response.text().catch(() => "");
|
|
2319
3328
|
if (!response.ok) {
|
|
@@ -2323,9 +3332,9 @@ async function handleTestModel(req, res, id, cfg) {
|
|
|
2323
3332
|
message = parsed?.error?.message || parsed?.message || message;
|
|
2324
3333
|
} catch {
|
|
2325
3334
|
}
|
|
2326
|
-
return
|
|
3335
|
+
return writeJson2(res, 200, { ok: false, status: response.status, latencyMs, message });
|
|
2327
3336
|
}
|
|
2328
|
-
return
|
|
3337
|
+
return writeJson2(res, 200, {
|
|
2329
3338
|
ok: true,
|
|
2330
3339
|
status: response.status,
|
|
2331
3340
|
latencyMs,
|
|
@@ -2333,7 +3342,7 @@ async function handleTestModel(req, res, id, cfg) {
|
|
|
2333
3342
|
});
|
|
2334
3343
|
} catch (err5) {
|
|
2335
3344
|
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
2336
|
-
return
|
|
3345
|
+
return writeJson2(res, 200, { ok: false, latencyMs: Date.now() - startedAt, message });
|
|
2337
3346
|
}
|
|
2338
3347
|
}
|
|
2339
3348
|
function extractSampleText(text, apiFormat) {
|
|
@@ -2355,9 +3364,9 @@ function toPoolKeyView(row, cooldown, deps) {
|
|
|
2355
3364
|
return entries.map((e) => {
|
|
2356
3365
|
const auto = deps.autoDisableStore.get(e.id);
|
|
2357
3366
|
const cd = cooldown[e.id];
|
|
2358
|
-
const
|
|
2359
|
-
if (cd)
|
|
2360
|
-
if (auto)
|
|
3367
|
+
const health2 = {};
|
|
3368
|
+
if (cd) health2.cooldown = cd;
|
|
3369
|
+
if (auto) health2.autoDisabled = { status: auto.status, at: auto.at, reason: auto.reason };
|
|
2361
3370
|
return {
|
|
2362
3371
|
id: e.id,
|
|
2363
3372
|
label: e.label && e.label.length > 0 ? e.label : e.id,
|
|
@@ -2365,7 +3374,7 @@ function toPoolKeyView(row, cooldown, deps) {
|
|
|
2365
3374
|
enabled: e.enabled !== false && !deps.autoDisableStore.isDisabled(e.id),
|
|
2366
3375
|
weight: typeof e.weight === "number" && Number.isFinite(e.weight) ? e.weight : 1,
|
|
2367
3376
|
apiKeyMasked: maskProviderApiKey(e.apiKey),
|
|
2368
|
-
...Object.keys(
|
|
3377
|
+
...Object.keys(health2).length > 0 ? { health: health2 } : {}
|
|
2369
3378
|
};
|
|
2370
3379
|
});
|
|
2371
3380
|
}
|
|
@@ -2374,7 +3383,7 @@ async function handleProviderKeys(res, id, cfg, deps) {
|
|
|
2374
3383
|
const row = cfg.providers.find((p) => p.id === id);
|
|
2375
3384
|
if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
2376
3385
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2377
|
-
return
|
|
3386
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2378
3387
|
}
|
|
2379
3388
|
function parsePoolKeyInput(body, existing) {
|
|
2380
3389
|
const out = {};
|
|
@@ -2393,7 +3402,7 @@ async function handleAddProviderKey(req, res, id, cfg, deps) {
|
|
|
2393
3402
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
2394
3403
|
const idx = cfg.providers.findIndex((p) => p.id === id);
|
|
2395
3404
|
if (idx < 0) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
2396
|
-
const body = await
|
|
3405
|
+
const body = await readJsonBody3(req);
|
|
2397
3406
|
const parsed = parsePoolKeyInput(body);
|
|
2398
3407
|
if (!parsed.apiKey) return writeJsonError(res, 400, "add requires a non-empty apiKey");
|
|
2399
3408
|
const keyId = `key-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -2405,7 +3414,7 @@ async function handleAddProviderKey(req, res, id, cfg, deps) {
|
|
|
2405
3414
|
row.apiKeys = [...row.apiKeys ?? [], entry];
|
|
2406
3415
|
persistProviders(cfg, deps);
|
|
2407
3416
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2408
|
-
return
|
|
3417
|
+
return writeJson2(res, 201, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2409
3418
|
}
|
|
2410
3419
|
async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
|
|
2411
3420
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
@@ -2415,7 +3424,7 @@ async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
|
|
|
2415
3424
|
const row = cfg.providers[idx];
|
|
2416
3425
|
const keyIdx = (row.apiKeys ?? []).findIndex((k) => k.id === keyId);
|
|
2417
3426
|
if (keyIdx < 0) return writeJsonError(res, 404, `pool key '${keyId}' not found`);
|
|
2418
|
-
const body = await
|
|
3427
|
+
const body = await readJsonBody3(req);
|
|
2419
3428
|
const existing = row.apiKeys[keyIdx];
|
|
2420
3429
|
const parsed = parsePoolKeyInput(body, existing);
|
|
2421
3430
|
const entry = { id: keyId, apiKey: parsed.apiKey || existing.apiKey };
|
|
@@ -2425,7 +3434,7 @@ async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
|
|
|
2425
3434
|
row.apiKeys[keyIdx] = entry;
|
|
2426
3435
|
persistProviders(cfg, deps);
|
|
2427
3436
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2428
|
-
return
|
|
3437
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2429
3438
|
}
|
|
2430
3439
|
async function handleDeleteProviderKey(res, id, keyId, cfg, deps) {
|
|
2431
3440
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
@@ -2439,7 +3448,7 @@ async function handleDeleteProviderKey(res, id, keyId, cfg, deps) {
|
|
|
2439
3448
|
if (row.apiKeys.length === 0) row.apiKeys = void 0;
|
|
2440
3449
|
persistProviders(cfg, deps);
|
|
2441
3450
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2442
|
-
return
|
|
3451
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2443
3452
|
}
|
|
2444
3453
|
async function handleToggleProviderKey(req, res, id, keyId, cfg, deps) {
|
|
2445
3454
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
@@ -2449,11 +3458,11 @@ async function handleToggleProviderKey(req, res, id, keyId, cfg, deps) {
|
|
|
2449
3458
|
const row = cfg.providers[idx];
|
|
2450
3459
|
const keyIdx = (row.apiKeys ?? []).findIndex((k) => k.id === keyId);
|
|
2451
3460
|
if (keyIdx < 0) return writeJsonError(res, 404, `pool key '${keyId}' not found`);
|
|
2452
|
-
const body = await
|
|
3461
|
+
const body = await readJsonBody3(req);
|
|
2453
3462
|
row.apiKeys[keyIdx].enabled = Boolean(body["enabled"]);
|
|
2454
3463
|
persistProviders(cfg, deps);
|
|
2455
3464
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2456
|
-
return
|
|
3465
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2457
3466
|
}
|
|
2458
3467
|
function parseApiKeysInput(raw, existing) {
|
|
2459
3468
|
if (!Array.isArray(raw)) return existing;
|
|
@@ -2624,18 +3633,31 @@ function handlePresets(res, method) {
|
|
|
2624
3633
|
baseUrl: p.baseUrl,
|
|
2625
3634
|
models: p.models
|
|
2626
3635
|
}));
|
|
2627
|
-
return
|
|
3636
|
+
return writeJson2(res, 200, { presets, excluded });
|
|
2628
3637
|
}
|
|
2629
3638
|
async function handleKeys(req, res, method, rest, deps) {
|
|
2630
3639
|
if (method === "GET" && rest.length === 0) {
|
|
2631
3640
|
const rows = await deps.keyDb.outboundApiKeysList();
|
|
2632
|
-
|
|
3641
|
+
const reader = deps.keySpendReader;
|
|
3642
|
+
if (!reader) return writeJson2(res, 200, { keys: rows.map(toKeyInfo) });
|
|
3643
|
+
const now = Date.now();
|
|
3644
|
+
const keys = await Promise.all(
|
|
3645
|
+
rows.map(async (row) => {
|
|
3646
|
+
const info = toKeyInfo(row);
|
|
3647
|
+
if (row.revokedAt === null) {
|
|
3648
|
+
const s = await reader.getSpend(row.id, now);
|
|
3649
|
+
info.spend = { dailyUsd: s.dailyUsd, weeklyUsd: s.weeklyUsd, totalUsd: s.totalUsd };
|
|
3650
|
+
}
|
|
3651
|
+
return info;
|
|
3652
|
+
})
|
|
3653
|
+
);
|
|
3654
|
+
return writeJson2(res, 200, { keys });
|
|
2633
3655
|
}
|
|
2634
3656
|
if (method === "POST" && rest.length === 0) {
|
|
2635
|
-
const body = await
|
|
3657
|
+
const body = await readJsonBody3(req);
|
|
2636
3658
|
const name = typeof body["name"] === "string" && body["name"].trim() ? body["name"].trim() : "key";
|
|
2637
|
-
const created = await (0,
|
|
2638
|
-
return
|
|
3659
|
+
const created = await (0, import_outbound_api3.createNamedKey)(deps.keyDb, name);
|
|
3660
|
+
return writeJson2(res, 201, {
|
|
2639
3661
|
id: created.id,
|
|
2640
3662
|
name: created.name,
|
|
2641
3663
|
keyPrefix: created.keyPrefix,
|
|
@@ -2647,46 +3669,185 @@ async function handleKeys(req, res, method, rest, deps) {
|
|
|
2647
3669
|
const action = rest[1];
|
|
2648
3670
|
if (method === "POST" && id && action === "revoke") {
|
|
2649
3671
|
const ok = await deps.keyDb.outboundApiKeysRevoke(id);
|
|
2650
|
-
return
|
|
3672
|
+
return writeJson2(res, ok ? 200 : 404, { ok });
|
|
2651
3673
|
}
|
|
2652
3674
|
if (method === "POST" && id && action === "enabled") {
|
|
2653
|
-
const body = await
|
|
3675
|
+
const body = await readJsonBody3(req);
|
|
2654
3676
|
const enabled = body["enabled"] === true;
|
|
2655
3677
|
const ok = await deps.keyDb.outboundApiKeysSetEnabled(id, enabled);
|
|
2656
|
-
return
|
|
3678
|
+
return writeJson2(res, ok ? 200 : 404, { ok, enabled });
|
|
3679
|
+
}
|
|
3680
|
+
if (method === "POST" && id && action === "max-concurrency") {
|
|
3681
|
+
const body = await readJsonBody3(req);
|
|
3682
|
+
const raw = body["maxConcurrency"];
|
|
3683
|
+
let value;
|
|
3684
|
+
if (raw === null) {
|
|
3685
|
+
value = null;
|
|
3686
|
+
} else if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 1e3) {
|
|
3687
|
+
value = raw;
|
|
3688
|
+
} else {
|
|
3689
|
+
return writeJsonError(
|
|
3690
|
+
res,
|
|
3691
|
+
400,
|
|
3692
|
+
"maxConcurrency must be an integer 1..1000 or null"
|
|
3693
|
+
);
|
|
3694
|
+
}
|
|
3695
|
+
const ok = await deps.keyDb.outboundApiKeysSetMaxConcurrency(id, value);
|
|
3696
|
+
return writeJson2(res, ok ? 200 : 404, { ok, maxConcurrency: value });
|
|
3697
|
+
}
|
|
3698
|
+
if (method === "POST" && id && action === "policy") {
|
|
3699
|
+
const body = await readJsonBody3(req);
|
|
3700
|
+
const parsed = parseKeyPolicyBody(body);
|
|
3701
|
+
if (!parsed.ok) return writeJsonError(res, 400, parsed.message);
|
|
3702
|
+
const ok = await deps.keyDb.outboundApiKeysSetPolicy(id, parsed.policy);
|
|
3703
|
+
return writeJson2(res, ok ? 200 : 404, { ok });
|
|
2657
3704
|
}
|
|
2658
3705
|
return writeJsonError(res, 405, `method ${method} not allowed on keys`);
|
|
2659
3706
|
}
|
|
3707
|
+
function validateQueueSegments(patch) {
|
|
3708
|
+
const errors = [];
|
|
3709
|
+
const checkNum = (label, value, min, max) => {
|
|
3710
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
|
|
3711
|
+
errors.push(`${label} must be a number ${min}..${max}`);
|
|
3712
|
+
}
|
|
3713
|
+
};
|
|
3714
|
+
const isPlainObject4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3715
|
+
const umq = patch.userMessageQueue;
|
|
3716
|
+
if (umq !== void 0) {
|
|
3717
|
+
if (!isPlainObject4(umq)) {
|
|
3718
|
+
errors.push("userMessageQueue must be an object");
|
|
3719
|
+
} else {
|
|
3720
|
+
if (typeof umq.enabled !== "boolean") {
|
|
3721
|
+
errors.push("userMessageQueue.enabled must be a boolean");
|
|
3722
|
+
}
|
|
3723
|
+
checkNum("userMessageQueue.delayMs", umq.delayMs, 0, 1e4);
|
|
3724
|
+
checkNum("userMessageQueue.waitTimeoutMs", umq.waitTimeoutMs, 1e3, 3e5);
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
const cq = patch.concurrencyQueue;
|
|
3728
|
+
if (cq !== void 0) {
|
|
3729
|
+
if (!isPlainObject4(cq)) {
|
|
3730
|
+
errors.push("concurrencyQueue must be an object");
|
|
3731
|
+
} else {
|
|
3732
|
+
checkNum("concurrencyQueue.maxQueueSizeFactor", cq.maxQueueSizeFactor, 1, 10);
|
|
3733
|
+
checkNum("concurrencyQueue.minQueueSize", cq.minQueueSize, 1, 100);
|
|
3734
|
+
checkNum("concurrencyQueue.waitTimeoutMs", cq.waitTimeoutMs, 1e3, 3e5);
|
|
3735
|
+
}
|
|
3736
|
+
}
|
|
3737
|
+
const ah = patch.accountHealth;
|
|
3738
|
+
if (ah !== void 0) {
|
|
3739
|
+
if (!isPlainObject4(ah)) {
|
|
3740
|
+
errors.push("accountHealth must be an object");
|
|
3741
|
+
} else {
|
|
3742
|
+
if (typeof ah.overloadCooldownEnabled !== "boolean") {
|
|
3743
|
+
errors.push("accountHealth.overloadCooldownEnabled must be a boolean");
|
|
3744
|
+
}
|
|
3745
|
+
checkNum("accountHealth.overloadCooldownMs", ah.overloadCooldownMs, 6e4, 36e5);
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
return errors;
|
|
3749
|
+
}
|
|
2660
3750
|
async function handleServer(req, res, method, deps) {
|
|
2661
3751
|
if (method === "GET") {
|
|
2662
|
-
const config = await (0,
|
|
2663
|
-
|
|
3752
|
+
const config = await (0, import_outbound_api3.loadServerConfig)(deps.settingsStore);
|
|
3753
|
+
let server = config;
|
|
3754
|
+
if (config.proxy) server = { ...server, proxy: redactOutboundProxy(config.proxy) };
|
|
3755
|
+
if (config.webhook) server = { ...server, webhook: redactWebhookConfig(config.webhook) };
|
|
3756
|
+
if (config.billing) server = { ...server, billing: redactBillingConfig(config.billing) };
|
|
3757
|
+
return writeJson2(res, 200, { server });
|
|
2664
3758
|
}
|
|
2665
3759
|
if (method === "PUT") {
|
|
2666
|
-
const patch = await
|
|
2667
|
-
const
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
3760
|
+
const patch = await readJsonBody3(req);
|
|
3761
|
+
const queueErrors = validateQueueSegments(patch);
|
|
3762
|
+
if (queueErrors.length > 0) {
|
|
3763
|
+
return writeJsonError(res, 400, `invalid queue config: ${queueErrors.join("; ")}`);
|
|
3764
|
+
}
|
|
3765
|
+
const webhookErrors = validateWebhookSegment(patch);
|
|
3766
|
+
if (webhookErrors.length > 0) {
|
|
3767
|
+
return writeJsonError(res, 400, `invalid webhook config: ${webhookErrors.join("; ")}`);
|
|
3768
|
+
}
|
|
3769
|
+
const auditErrors = validateAuditSegment(patch);
|
|
3770
|
+
if (auditErrors.length > 0) {
|
|
3771
|
+
return writeJsonError(res, 400, `invalid audit config: ${auditErrors.join("; ")}`);
|
|
3772
|
+
}
|
|
3773
|
+
const billingErrors = validateBillingSegment(patch);
|
|
3774
|
+
if (billingErrors.length > 0) {
|
|
3775
|
+
return writeJsonError(res, 400, `invalid billing config: ${billingErrors.join("; ")}`);
|
|
3776
|
+
}
|
|
3777
|
+
const current = await (0, import_outbound_api3.loadServerConfig)(deps.settingsStore);
|
|
3778
|
+
let effectivePatch = patch;
|
|
3779
|
+
if (patch.proxy) {
|
|
3780
|
+
effectivePatch = { ...effectivePatch, proxy: preserveOutboundProxySecrets(patch.proxy, current.proxy) };
|
|
3781
|
+
}
|
|
3782
|
+
if (patch.webhook) {
|
|
3783
|
+
effectivePatch = { ...effectivePatch, webhook: preserveWebhookSecrets(patch.webhook, current.webhook) };
|
|
3784
|
+
}
|
|
3785
|
+
if (patch.billing) {
|
|
3786
|
+
effectivePatch = { ...effectivePatch, billing: preserveBillingSecret(patch.billing, current.billing) };
|
|
3787
|
+
}
|
|
3788
|
+
const merged = (0, import_outbound_api3.mergeServerConfig)(current, effectivePatch);
|
|
3789
|
+
await (0, import_outbound_api3.saveServerConfig)(deps.settingsStore, merged);
|
|
3790
|
+
setServerProxyConfig(merged.proxy);
|
|
3791
|
+
applyWebhookConfig(merged.webhook);
|
|
3792
|
+
applyAuditConfig(merged.audit);
|
|
3793
|
+
applyBillingConfig(merged.billing);
|
|
3794
|
+
if (merged.enabled) {
|
|
3795
|
+
const missing = (0, import_outbound_api3.validateServerModelConfig)(merged);
|
|
3796
|
+
if (missing.length > 0) {
|
|
3797
|
+
if (deps.outboundApiServer.getStatus().running) {
|
|
3798
|
+
await deps.outboundApiServer.stop();
|
|
3799
|
+
}
|
|
3800
|
+
return writeJson2(res, 200, {
|
|
3801
|
+
server: merged,
|
|
3802
|
+
error: { code: "incomplete-model-config", missing }
|
|
3803
|
+
});
|
|
3804
|
+
}
|
|
3805
|
+
}
|
|
3806
|
+
try {
|
|
3807
|
+
await deps.outboundApiServer.applyConfig({
|
|
3808
|
+
enabled: merged.enabled,
|
|
3809
|
+
networkBinding: merged.networkBinding,
|
|
3810
|
+
endpoints: merged.endpoints,
|
|
3811
|
+
port: merged.port,
|
|
3812
|
+
userMessageQueue: merged.userMessageQueue,
|
|
3813
|
+
concurrencyQueue: merged.concurrencyQueue,
|
|
3814
|
+
// voucher-redemption #9: hot-apply the voucher flag so enabling the product
|
|
3815
|
+
// takes effect without a restart.
|
|
3816
|
+
voucher: merged.voucher
|
|
3817
|
+
});
|
|
3818
|
+
} catch (err5) {
|
|
3819
|
+
const missing = incompleteConfigMissing(err5);
|
|
3820
|
+
if (missing) {
|
|
3821
|
+
return writeJson2(res, 200, {
|
|
3822
|
+
server: merged,
|
|
3823
|
+
error: { code: "incomplete-model-config", missing }
|
|
3824
|
+
});
|
|
3825
|
+
}
|
|
3826
|
+
throw err5;
|
|
3827
|
+
}
|
|
3828
|
+
return writeJson2(res, 200, { server: merged });
|
|
2677
3829
|
}
|
|
2678
3830
|
return writeJsonError(res, 405, `method ${method} not allowed on server`);
|
|
2679
3831
|
}
|
|
3832
|
+
function incompleteConfigMissing(err5) {
|
|
3833
|
+
if (typeof err5 !== "object" || err5 === null) return null;
|
|
3834
|
+
const missing = err5.missing;
|
|
3835
|
+
return Array.isArray(missing) ? missing : null;
|
|
3836
|
+
}
|
|
2680
3837
|
async function handleAccounts(req, res, method, rest, deps) {
|
|
2681
3838
|
if (method === "GET" && rest.length === 0) {
|
|
2682
3839
|
const accounts = await deps.subscriptionAccounts.listAll();
|
|
2683
3840
|
const providerAccounts = await deps.subscriptionTokenWriter.listSanitizedAccounts();
|
|
2684
3841
|
const externalCli = await deps.subscriptionTokenWriter.listExternalCliAvailability();
|
|
2685
|
-
return
|
|
3842
|
+
return writeJson2(res, 200, { accounts, providerAccounts, externalCli });
|
|
2686
3843
|
}
|
|
2687
3844
|
if (method === "GET" && rest[0] === "codex" && rest[1] === "oauth" && rest[3] === "status") {
|
|
2688
3845
|
const result = handleCodexOAuthStatus(rest[2], deps);
|
|
2689
|
-
return
|
|
3846
|
+
return writeJson2(res, result.status, result.body);
|
|
3847
|
+
}
|
|
3848
|
+
if (method === "DELETE" && rest[0] === "codex" && rest[1] === "oauth" && rest[2]) {
|
|
3849
|
+
const result = handleCodexOAuthCancel(rest[2], deps);
|
|
3850
|
+
return writeJson2(res, result.status, result.body);
|
|
2690
3851
|
}
|
|
2691
3852
|
if (method === "PUT" || method === "POST" || method === "DELETE") {
|
|
2692
3853
|
const providerId = asSubscriptionProviderId(rest[0]);
|
|
@@ -2695,15 +3856,15 @@ async function handleAccounts(req, res, method, rest, deps) {
|
|
|
2695
3856
|
}
|
|
2696
3857
|
if (method === "POST" && rest[1] === "oauth" && rest[2] === "start") {
|
|
2697
3858
|
const result = providerId === "codex" ? handleCodexOAuthStart(deps) : handleOAuthStart(providerId, deps);
|
|
2698
|
-
return
|
|
3859
|
+
return writeJson2(res, result.status, result.body);
|
|
2699
3860
|
}
|
|
2700
3861
|
if (method === "POST" && rest[1] === "oauth" && rest[2] === "complete") {
|
|
2701
|
-
const body2 = await
|
|
3862
|
+
const body2 = await readJsonBody3(req);
|
|
2702
3863
|
const result = await handleOAuthComplete(providerId, body2, deps);
|
|
2703
|
-
return
|
|
3864
|
+
return writeJson2(res, result.status, result.body);
|
|
2704
3865
|
}
|
|
2705
3866
|
if (method === "POST" && rest[1] === "accounts") {
|
|
2706
|
-
const body2 = await
|
|
3867
|
+
const body2 = await readJsonBody3(req);
|
|
2707
3868
|
const block = validateTokenBody(providerId, body2);
|
|
2708
3869
|
if (!block) {
|
|
2709
3870
|
return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
|
|
@@ -2711,79 +3872,113 @@ async function handleAccounts(req, res, method, rest, deps) {
|
|
|
2711
3872
|
const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
|
|
2712
3873
|
await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block, label);
|
|
2713
3874
|
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2714
|
-
return
|
|
3875
|
+
return writeJson2(res, 200, status2 ? { account: status2 } : { ok: true });
|
|
2715
3876
|
}
|
|
2716
3877
|
if (method === "POST" && rest[1] === "import-external") {
|
|
2717
3878
|
if (providerId !== "claude" && providerId !== "codex") {
|
|
2718
3879
|
return writeJsonError(res, 400, `provider '${providerId}' has no external CLI store`);
|
|
2719
3880
|
}
|
|
2720
|
-
const body2 = await
|
|
3881
|
+
const body2 = await readJsonBody3(req);
|
|
2721
3882
|
const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
|
|
2722
3883
|
const result = await deps.subscriptionTokenWriter.importExternalCliAccount(providerId, label);
|
|
2723
3884
|
if (!result.ok) {
|
|
2724
3885
|
return writeJsonError(res, 409, `no usable external ${providerId} CLI credential found`);
|
|
2725
3886
|
}
|
|
2726
3887
|
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2727
|
-
return
|
|
3888
|
+
return writeJson2(res, 200, { ok: true, account: status2 ?? void 0 });
|
|
2728
3889
|
}
|
|
2729
3890
|
if (method === "POST" && rest[1] === "refresh") {
|
|
2730
3891
|
if (providerId === "opencodego") {
|
|
2731
3892
|
return writeJsonError(res, 400, "opencodego credentials are not refreshable");
|
|
2732
3893
|
}
|
|
2733
|
-
const
|
|
2734
|
-
const ok = providerId === "claude" ? await
|
|
3894
|
+
const writer2 = deps.subscriptionTokenWriter;
|
|
3895
|
+
const ok = providerId === "claude" ? await writer2.refreshClaudeToken() : providerId === "codex" ? await writer2.refreshCodexToken() : await writer2.refreshGeminiToken();
|
|
2735
3896
|
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2736
|
-
return
|
|
3897
|
+
return writeJson2(res, 200, { ok, account: status2 ?? void 0 });
|
|
2737
3898
|
}
|
|
2738
3899
|
if (method === "POST" && rest[2] === "label") {
|
|
2739
3900
|
const accountId = rest[1];
|
|
2740
|
-
const body2 = await
|
|
3901
|
+
const body2 = await readJsonBody3(req);
|
|
2741
3902
|
const label = typeof body2["label"] === "string" ? body2["label"].trim() : "";
|
|
2742
3903
|
const result = await deps.subscriptionTokenWriter.renameAccount(providerId, accountId, label);
|
|
2743
3904
|
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
2744
|
-
return
|
|
3905
|
+
return writeJson2(res, 200, { ok: true });
|
|
3906
|
+
}
|
|
3907
|
+
if (method === "POST" && rest[2] === "priority") {
|
|
3908
|
+
const accountId = rest[1];
|
|
3909
|
+
const body2 = await readJsonBody3(req);
|
|
3910
|
+
const raw = body2["priority"];
|
|
3911
|
+
const priority = typeof raw === "number" ? raw : Number(raw);
|
|
3912
|
+
if (!Number.isFinite(priority)) {
|
|
3913
|
+
return writeJsonError(res, 400, "priority must be a finite number");
|
|
3914
|
+
}
|
|
3915
|
+
const result = await deps.subscriptionTokenWriter.setAccountPriority(providerId, accountId, priority);
|
|
3916
|
+
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
3917
|
+
return writeJson2(res, 200, { ok: true });
|
|
3918
|
+
}
|
|
3919
|
+
if (method === "POST" && rest[2] === "proxy") {
|
|
3920
|
+
const accountId = rest[1];
|
|
3921
|
+
const body2 = await readJsonBody3(req);
|
|
3922
|
+
const rawProxy = body2["proxy"];
|
|
3923
|
+
let proxy;
|
|
3924
|
+
if (rawProxy !== null && rawProxy !== void 0) {
|
|
3925
|
+
proxy = (0, import_outbound_api3.normalizeProxyConfig)(rawProxy);
|
|
3926
|
+
if (!proxy) return writeJsonError(res, 400, "invalid proxy config");
|
|
3927
|
+
}
|
|
3928
|
+
const result = await deps.subscriptionTokenWriter.setAccountProxy(providerId, accountId, proxy);
|
|
3929
|
+
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
3930
|
+
return writeJson2(res, 200, { ok: true });
|
|
3931
|
+
}
|
|
3932
|
+
if (method === "POST" && rest[2] === "supported-models") {
|
|
3933
|
+
const accountId = rest[1];
|
|
3934
|
+
const body2 = await readJsonBody3(req);
|
|
3935
|
+
const parsed = validateSupportedModelsBody(body2["supportedModels"]);
|
|
3936
|
+
if (!parsed.ok) return writeJsonError(res, 400, "invalid supportedModels");
|
|
3937
|
+
const result = await deps.subscriptionTokenWriter.setAccountSupportedModels(providerId, accountId, parsed.value);
|
|
3938
|
+
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
3939
|
+
return writeJson2(res, 200, { ok: true });
|
|
2745
3940
|
}
|
|
2746
3941
|
if (method === "PUT" && rest[1] === "active") {
|
|
2747
|
-
const body2 = await
|
|
3942
|
+
const body2 = await readJsonBody3(req);
|
|
2748
3943
|
const id = typeof body2["id"] === "string" ? body2["id"] : "";
|
|
2749
3944
|
if (!id) return writeJsonError(res, 400, "active switch requires { id }");
|
|
2750
3945
|
const result = await deps.subscriptionTokenWriter.setActiveAccount(providerId, id);
|
|
2751
3946
|
if (!result.ok) return writeJsonError(res, 404, `account '${id}' not found`);
|
|
2752
|
-
return
|
|
3947
|
+
return writeJson2(res, 200, { ok: true });
|
|
2753
3948
|
}
|
|
2754
3949
|
if (method === "DELETE" && rest.length >= 2) {
|
|
2755
3950
|
const accountId = rest[1];
|
|
2756
3951
|
const result = await deps.subscriptionTokenWriter.removeAccount(providerId, accountId);
|
|
2757
3952
|
if (!result.removed) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
2758
|
-
return
|
|
3953
|
+
return writeJson2(res, 200, { ok: true });
|
|
2759
3954
|
}
|
|
2760
3955
|
if (method === "DELETE") {
|
|
2761
3956
|
await deps.subscriptionTokenWriter.clearProvider(providerId);
|
|
2762
|
-
return
|
|
3957
|
+
return writeJson2(res, 200, { ok: true });
|
|
2763
3958
|
}
|
|
2764
|
-
const body = await
|
|
3959
|
+
const body = await readJsonBody3(req);
|
|
2765
3960
|
const config = validateTokenBody(providerId, body);
|
|
2766
3961
|
if (!config) {
|
|
2767
3962
|
return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
|
|
2768
3963
|
}
|
|
2769
3964
|
await deps.subscriptionTokenWriter.writeProviderTokens(providerId, config);
|
|
2770
3965
|
const status = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2771
|
-
return
|
|
3966
|
+
return writeJson2(res, 200, status ? { account: status } : { ok: true });
|
|
2772
3967
|
}
|
|
2773
3968
|
return writeJsonError(res, 405, `method ${method} not allowed on accounts`);
|
|
2774
3969
|
}
|
|
2775
3970
|
async function handleCli(req, res, method, rest, deps) {
|
|
2776
3971
|
if (method === "GET" && rest.length === 0) {
|
|
2777
3972
|
const result = handleCliList(process.platform, deps.cliPathProbe);
|
|
2778
|
-
return
|
|
3973
|
+
return writeJson2(res, result.status, result.body);
|
|
2779
3974
|
}
|
|
2780
3975
|
if (method === "GET" && rest[0] === "sessions") {
|
|
2781
3976
|
const result = handleCliSessions();
|
|
2782
|
-
return
|
|
3977
|
+
return writeJson2(res, result.status, result.body);
|
|
2783
3978
|
}
|
|
2784
3979
|
if (method === "DELETE" && rest[0] === "sessions" && rest[1]) {
|
|
2785
3980
|
const result = handleCliStop(rest[1]);
|
|
2786
|
-
return
|
|
3981
|
+
return writeJson2(res, result.status, result.body);
|
|
2787
3982
|
}
|
|
2788
3983
|
if (method === "POST" && rest[1] === "install") {
|
|
2789
3984
|
const cli = rest[0];
|
|
@@ -2791,14 +3986,14 @@ async function handleCli(req, res, method, rest, deps) {
|
|
|
2791
3986
|
return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
|
|
2792
3987
|
}
|
|
2793
3988
|
const result = await handleCliInstall(cli, deps.cliCommandRunner);
|
|
2794
|
-
return
|
|
3989
|
+
return writeJson2(res, result.status, result.body);
|
|
2795
3990
|
}
|
|
2796
3991
|
if (method === "POST" && rest[1] === "launch") {
|
|
2797
3992
|
const cli = rest[0];
|
|
2798
3993
|
if (!isLaunchCliId(cli)) {
|
|
2799
3994
|
return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
|
|
2800
3995
|
}
|
|
2801
|
-
const body = await
|
|
3996
|
+
const body = await readJsonBody3(req);
|
|
2802
3997
|
const providers = loadConfig(deps.configPath).providers ?? [];
|
|
2803
3998
|
const result = await handleCliLaunch(cli, body, {
|
|
2804
3999
|
llmConfig: deps.llmConfig,
|
|
@@ -2806,20 +4001,28 @@ async function handleCli(req, res, method, rest, deps) {
|
|
|
2806
4001
|
opener: deps.cliTerminalOpener,
|
|
2807
4002
|
probe: deps.cliPathProbe
|
|
2808
4003
|
});
|
|
2809
|
-
return
|
|
4004
|
+
return writeJson2(res, result.status, result.body);
|
|
2810
4005
|
}
|
|
2811
4006
|
return writeJsonError(res, 405, `method ${method} not allowed on cli`);
|
|
2812
4007
|
}
|
|
2813
4008
|
async function handleStatus(res, method, deps) {
|
|
2814
4009
|
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on status`);
|
|
2815
4010
|
const status = deps.outboundApiServer.getStatus();
|
|
2816
|
-
const serverConfig = await (0,
|
|
2817
|
-
const endpoints = serverConfig.endpoints.map((e) =>
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
4011
|
+
const serverConfig = await (0, import_outbound_api3.loadServerConfig)(deps.settingsStore);
|
|
4012
|
+
const endpoints = serverConfig.endpoints.map((e) => {
|
|
4013
|
+
if ((0, import_outbound_api3.isKindMappedEndpoint)(e.endpoint)) {
|
|
4014
|
+
return { endpoint: e.endpoint, kinds: e.modelMap ?? {}, useSubscription: e.useSubscription };
|
|
4015
|
+
}
|
|
4016
|
+
if (e.endpoint === "chat") {
|
|
4017
|
+
return { endpoint: e.endpoint, models: e.models ?? [], useSubscription: e.useSubscription };
|
|
4018
|
+
}
|
|
4019
|
+
return { endpoint: e.endpoint, model: e.defaultModel ?? "", useSubscription: e.useSubscription };
|
|
4020
|
+
});
|
|
4021
|
+
if (status.running) {
|
|
4022
|
+
const queueStatus = deps.outboundApiServer.getQueueStatus();
|
|
4023
|
+
return writeJson2(res, 200, { ...status, endpoints, queueStatus });
|
|
4024
|
+
}
|
|
4025
|
+
return writeJson2(res, 200, { ...status, endpoints });
|
|
2823
4026
|
}
|
|
2824
4027
|
function resolvePlaygroundPath(endpoint, body) {
|
|
2825
4028
|
switch (endpoint) {
|
|
@@ -2839,7 +4042,7 @@ function resolvePlaygroundPath(endpoint, body) {
|
|
|
2839
4042
|
}
|
|
2840
4043
|
async function handlePlayground(req, res, method, deps) {
|
|
2841
4044
|
if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on playground`);
|
|
2842
|
-
const body = await
|
|
4045
|
+
const body = await readJsonBody3(req);
|
|
2843
4046
|
const endpoint = typeof body["endpoint"] === "string" ? body["endpoint"] : "";
|
|
2844
4047
|
const key = typeof body["key"] === "string" ? body["key"] : "";
|
|
2845
4048
|
const payload = body["body"];
|
|
@@ -2985,10 +4188,12 @@ async function handleUiStatic(req, res, urlPath, uiDist) {
|
|
|
2985
4188
|
return true;
|
|
2986
4189
|
}
|
|
2987
4190
|
|
|
4191
|
+
// src/admin/version.ts
|
|
4192
|
+
var DAEMON_VERSION = true ? "0.1.4" : "0.0.0-dev";
|
|
4193
|
+
|
|
2988
4194
|
// src/admin/AdminServer.ts
|
|
2989
4195
|
var LOOPBACK_ADDR = "127.0.0.1";
|
|
2990
4196
|
var LAN_ADDR = "0.0.0.0";
|
|
2991
|
-
var DAEMON_VERSION = true ? "0.1.2" : "0.0.0-dev";
|
|
2992
4197
|
var AdminServer = class {
|
|
2993
4198
|
constructor(deps) {
|
|
2994
4199
|
this.deps = deps;
|
|
@@ -3009,7 +4214,7 @@ var AdminServer = class {
|
|
|
3009
4214
|
const cfg = this.deps.getAdminConfig();
|
|
3010
4215
|
if (!cfg.enabled) return 0;
|
|
3011
4216
|
if (cfg.networkBinding && !cfg.token) {
|
|
3012
|
-
|
|
4217
|
+
this.deps.logger.error(
|
|
3013
4218
|
"[AdminServer] REFUSING to bind: admin.networkBinding (LAN/0.0.0.0) requires a non-empty admin.token. Set admin.token in config.json or disable networkBinding. Dashboard stays DOWN (fail closed)."
|
|
3014
4219
|
);
|
|
3015
4220
|
return 0;
|
|
@@ -3018,7 +4223,7 @@ var AdminServer = class {
|
|
|
3018
4223
|
const actualPort = await this.listen(bindAddr, cfg.port);
|
|
3019
4224
|
this.boundAddr = bindAddr;
|
|
3020
4225
|
this.boundPort = actualPort;
|
|
3021
|
-
|
|
4226
|
+
this.deps.logger.info(`[AdminServer] Dashboard listening on ${bindAddr}:${actualPort}`);
|
|
3022
4227
|
return actualPort;
|
|
3023
4228
|
}
|
|
3024
4229
|
/** Bind once; on EADDRINUSE retry with an ephemeral port (port 0). */
|
|
@@ -3040,7 +4245,7 @@ var AdminServer = class {
|
|
|
3040
4245
|
const addr = server.address();
|
|
3041
4246
|
if (addr && typeof addr === "object") {
|
|
3042
4247
|
server.removeListener("error", onError);
|
|
3043
|
-
server.on("error", (e) =>
|
|
4248
|
+
server.on("error", (e) => this.deps.logger.error("[AdminServer] server error", e));
|
|
3044
4249
|
this.server = server;
|
|
3045
4250
|
resolve(addr.port);
|
|
3046
4251
|
} else {
|
|
@@ -3053,7 +4258,7 @@ var AdminServer = class {
|
|
|
3053
4258
|
onRequest(req, res) {
|
|
3054
4259
|
void this.dispatch(req, res).catch((err5) => {
|
|
3055
4260
|
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
3056
|
-
|
|
4261
|
+
this.deps.logger.error("[AdminServer] unhandled error:", message);
|
|
3057
4262
|
if (!res.headersSent) {
|
|
3058
4263
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3059
4264
|
res.end(JSON.stringify({ error: { type: "admin_error", message } }));
|
|
@@ -3064,18 +4269,42 @@ var AdminServer = class {
|
|
|
3064
4269
|
const cfg = this.deps.getAdminConfig();
|
|
3065
4270
|
res.setHeader("x-omnicross-daemon", DAEMON_VERSION);
|
|
3066
4271
|
res.setHeader("x-omnicross-pid", String(process.pid));
|
|
4272
|
+
const url = req.url ?? "/";
|
|
4273
|
+
const path2 = url.split("?")[0];
|
|
4274
|
+
const healthPath = path2.replace(/\/+$/, "") || "/";
|
|
4275
|
+
if ((req.method === "GET" || req.method === "HEAD") && (healthPath === "/health" || healthPath === "/healthz")) {
|
|
4276
|
+
const report = this.deps.getHealthReport();
|
|
4277
|
+
const code = (0, import_health_logging_types.healthHttpStatus)(report.status);
|
|
4278
|
+
res.writeHead(code, { "Content-Type": "application/json" });
|
|
4279
|
+
res.end(req.method === "HEAD" ? void 0 : JSON.stringify(report));
|
|
4280
|
+
return;
|
|
4281
|
+
}
|
|
3067
4282
|
if (cfg.token && !this.isAuthorized(req, cfg.token)) {
|
|
3068
4283
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
3069
4284
|
res.end(JSON.stringify({ error: { type: "unauthorized", message: "admin token required" } }));
|
|
3070
4285
|
return;
|
|
3071
4286
|
}
|
|
3072
|
-
const url = req.url ?? "/";
|
|
3073
|
-
const path2 = url.split("?")[0];
|
|
3074
4287
|
if ((req.method === "GET" || req.method === "HEAD") && (path2 === "/" || path2 === "/admin")) {
|
|
3075
4288
|
res.writeHead(302, { Location: "/ui/" });
|
|
3076
4289
|
res.end();
|
|
3077
4290
|
return;
|
|
3078
4291
|
}
|
|
4292
|
+
if (path2 === "/admin/api/account-probes" && (req.method === "GET" || req.method === "HEAD")) {
|
|
4293
|
+
handleAccountProbes(res, this.deps.probeHistoryReader);
|
|
4294
|
+
return;
|
|
4295
|
+
}
|
|
4296
|
+
if (path2 === "/admin/api/audit" && (req.method === "GET" || req.method === "HEAD")) {
|
|
4297
|
+
handleAuditQuery(req, res, this.deps.auditReader);
|
|
4298
|
+
return;
|
|
4299
|
+
}
|
|
4300
|
+
if (path2 === "/admin/api/billing-status" && (req.method === "GET" || req.method === "HEAD")) {
|
|
4301
|
+
handleBillingStatus(res, this.deps.billingStatusReader);
|
|
4302
|
+
return;
|
|
4303
|
+
}
|
|
4304
|
+
if (path2 === "/admin/api/webhook-test" && req.method === "POST") {
|
|
4305
|
+
await handleWebhookTest(req, res);
|
|
4306
|
+
return;
|
|
4307
|
+
}
|
|
3079
4308
|
if (path2.startsWith("/admin/api/")) {
|
|
3080
4309
|
await handleAdminApi(req, res, path2, this.deps);
|
|
3081
4310
|
return;
|
|
@@ -3119,6 +4348,51 @@ function constantTimeEquals(a, b) {
|
|
|
3119
4348
|
return (0, import_node_crypto7.timingSafeEqual)(bufA, bufB);
|
|
3120
4349
|
}
|
|
3121
4350
|
|
|
4351
|
+
// src/admin/health.ts
|
|
4352
|
+
var CRITICAL_CHECKS = ["config", "credentialStore"];
|
|
4353
|
+
var READINESS_CHECKS = ["outboundServer"];
|
|
4354
|
+
function safeBool(fn) {
|
|
4355
|
+
try {
|
|
4356
|
+
return fn() === true;
|
|
4357
|
+
} catch {
|
|
4358
|
+
return false;
|
|
4359
|
+
}
|
|
4360
|
+
}
|
|
4361
|
+
function toMb(bytes) {
|
|
4362
|
+
return Math.round(bytes / (1024 * 1024) * 10) / 10;
|
|
4363
|
+
}
|
|
4364
|
+
function buildHealthReport(deps) {
|
|
4365
|
+
const checks = {
|
|
4366
|
+
config: safeBool(deps.configPresent),
|
|
4367
|
+
credentialStore: safeBool(deps.credentialStoreReadable),
|
|
4368
|
+
outboundServer: safeBool(deps.outboundServerRunning),
|
|
4369
|
+
adminServer: safeBool(deps.adminServerRunning)
|
|
4370
|
+
};
|
|
4371
|
+
if (deps.subscriptionAccountsHealthy) {
|
|
4372
|
+
let probeHealthy;
|
|
4373
|
+
try {
|
|
4374
|
+
probeHealthy = deps.subscriptionAccountsHealthy();
|
|
4375
|
+
} catch {
|
|
4376
|
+
probeHealthy = false;
|
|
4377
|
+
}
|
|
4378
|
+
if (probeHealthy !== void 0) checks.subscriptionAccountsHealthy = probeHealthy;
|
|
4379
|
+
}
|
|
4380
|
+
const criticalOk = CRITICAL_CHECKS.every((k) => checks[k]);
|
|
4381
|
+
const readinessOk = READINESS_CHECKS.every((k) => checks[k]);
|
|
4382
|
+
const status = !criticalOk ? "error" : readinessOk ? "ok" : "degraded";
|
|
4383
|
+
const mem = (deps.memoryUsage ?? process.memoryUsage)();
|
|
4384
|
+
const uptime = (deps.uptimeSeconds ?? process.uptime)();
|
|
4385
|
+
const nowMs = (deps.now ?? Date.now)();
|
|
4386
|
+
return {
|
|
4387
|
+
status,
|
|
4388
|
+
version: deps.version,
|
|
4389
|
+
uptimeSeconds: Math.floor(uptime),
|
|
4390
|
+
timestamp: new Date(nowMs).toISOString(),
|
|
4391
|
+
memory: { rssMb: toMb(mem.rss), heapUsedMb: toMb(mem.heapUsed) },
|
|
4392
|
+
checks
|
|
4393
|
+
};
|
|
4394
|
+
}
|
|
4395
|
+
|
|
3122
4396
|
// src/admin/oauthSessions.ts
|
|
3123
4397
|
var import_node_crypto8 = __toESM(require("crypto"), 1);
|
|
3124
4398
|
var DEFAULT_OAUTH_SESSION_TTL_MS = 10 * 60 * 1e3;
|
|
@@ -3170,7 +4444,7 @@ var DEFAULT_TIMEOUT_MS = 5 * 6e4;
|
|
|
3170
4444
|
function pageHtml(message) {
|
|
3171
4445
|
return `<!doctype html><meta charset="utf-8"><title>omnicross login</title><body style="font-family:sans-serif;padding:2rem"><h2>${message}</h2><p>You can close this window and return to the terminal.</p></body>`;
|
|
3172
4446
|
}
|
|
3173
|
-
function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
4447
|
+
function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS, signal) {
|
|
3174
4448
|
return new Promise((resolve, reject) => {
|
|
3175
4449
|
let settled = false;
|
|
3176
4450
|
const finish = (server2, fn) => {
|
|
@@ -3204,6 +4478,12 @@ function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
3204
4478
|
res.end(pageHtml("Login complete."));
|
|
3205
4479
|
finish(server, () => resolve(code));
|
|
3206
4480
|
});
|
|
4481
|
+
const abort = () => finish(server, () => reject(new Error("login: cancelled")));
|
|
4482
|
+
if (signal?.aborted) {
|
|
4483
|
+
abort();
|
|
4484
|
+
return;
|
|
4485
|
+
}
|
|
4486
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
3207
4487
|
server.on("error", (err5) => {
|
|
3208
4488
|
if (settled) return;
|
|
3209
4489
|
settled = true;
|
|
@@ -3447,50 +4727,203 @@ function toLLMProvider(row) {
|
|
|
3447
4727
|
};
|
|
3448
4728
|
}
|
|
3449
4729
|
|
|
3450
|
-
// src/ports/
|
|
3451
|
-
var
|
|
4730
|
+
// src/ports/ConfigurableLogger.ts
|
|
4731
|
+
var import_node_fs7 = require("fs");
|
|
4732
|
+
var LEVEL_ORDER = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
4733
|
+
var RESERVED_JSON_KEYS = /* @__PURE__ */ new Set(["ts", "level", "msg", "error"]);
|
|
4734
|
+
var ConfigurableLogger = class {
|
|
4735
|
+
threshold;
|
|
4736
|
+
format;
|
|
4737
|
+
filePath;
|
|
4738
|
+
fileStream = null;
|
|
4739
|
+
fileDisabled = false;
|
|
4740
|
+
constructor(cfg) {
|
|
4741
|
+
this.threshold = LEVEL_ORDER[cfg?.level ?? "debug"];
|
|
4742
|
+
this.format = cfg?.format ?? "text";
|
|
4743
|
+
this.filePath = cfg?.file && cfg.file.length > 0 ? cfg.file : void 0;
|
|
4744
|
+
}
|
|
3452
4745
|
info(message, meta) {
|
|
3453
|
-
|
|
3454
|
-
else console.info(message, meta);
|
|
4746
|
+
this.emit("info", message, void 0, meta);
|
|
3455
4747
|
}
|
|
3456
4748
|
warn(message, meta) {
|
|
3457
|
-
|
|
3458
|
-
else console.warn(message, meta);
|
|
4749
|
+
this.emit("warn", message, void 0, meta);
|
|
3459
4750
|
}
|
|
3460
4751
|
error(message, error, meta) {
|
|
3461
|
-
|
|
3462
|
-
else if (meta === void 0) console.error(message, error);
|
|
3463
|
-
else console.error(message, error, meta);
|
|
4752
|
+
this.emit("error", message, error, meta);
|
|
3464
4753
|
}
|
|
3465
4754
|
debug(message, meta) {
|
|
3466
|
-
|
|
3467
|
-
|
|
4755
|
+
this.emit("debug", message, void 0, meta);
|
|
4756
|
+
}
|
|
4757
|
+
/**
|
|
4758
|
+
* Flush + close the file sink (tests / graceful shutdown). Resolves once the
|
|
4759
|
+
* append stream has finished flushing to disk. No-op when no file sink is open.
|
|
4760
|
+
*/
|
|
4761
|
+
close() {
|
|
4762
|
+
const stream = this.fileStream;
|
|
4763
|
+
this.fileStream = null;
|
|
4764
|
+
if (!stream) return Promise.resolve();
|
|
4765
|
+
return new Promise((resolve) => stream.end(() => resolve()));
|
|
4766
|
+
}
|
|
4767
|
+
emit(level, message, error, meta) {
|
|
4768
|
+
if (LEVEL_ORDER[level] > this.threshold) return;
|
|
4769
|
+
this.writeConsole(level, message, error, meta);
|
|
4770
|
+
if (this.filePath) this.writeFile(level, message, error, meta);
|
|
4771
|
+
}
|
|
4772
|
+
/**
|
|
4773
|
+
* Console sink. In `text` format this reproduces the legacy `ConsoleLogger`
|
|
4774
|
+
* EXACTLY (same method + arg shape) so the unconfigured default is a byte-for-
|
|
4775
|
+
* byte drop-in; in `json` format it prints the structured line.
|
|
4776
|
+
*/
|
|
4777
|
+
writeConsole(level, message, error, meta) {
|
|
4778
|
+
if (this.format === "json") {
|
|
4779
|
+
this.consoleFn(level)(this.jsonLine(level, message, error, meta));
|
|
4780
|
+
return;
|
|
4781
|
+
}
|
|
4782
|
+
if (level === "error") {
|
|
4783
|
+
if (error === void 0 && meta === void 0) console.error(message);
|
|
4784
|
+
else if (meta === void 0) console.error(message, error);
|
|
4785
|
+
else console.error(message, error, meta);
|
|
4786
|
+
return;
|
|
4787
|
+
}
|
|
4788
|
+
const fn = this.consoleFn(level);
|
|
4789
|
+
if (meta === void 0) fn(message);
|
|
4790
|
+
else fn(message, meta);
|
|
4791
|
+
}
|
|
4792
|
+
/** Append one line to the file sink; a failure disables the sink (swallowed). */
|
|
4793
|
+
writeFile(level, message, error, meta) {
|
|
4794
|
+
const stream = this.getFileStream();
|
|
4795
|
+
if (!stream) return;
|
|
4796
|
+
try {
|
|
4797
|
+
const line = this.format === "json" ? this.jsonLine(level, message, error, meta) : this.textLine(level, message, error, meta);
|
|
4798
|
+
stream.write(line + "\n");
|
|
4799
|
+
} catch {
|
|
4800
|
+
}
|
|
4801
|
+
}
|
|
4802
|
+
/** Lazily open the append-only file stream; disable the sink on any error. */
|
|
4803
|
+
getFileStream() {
|
|
4804
|
+
if (this.fileDisabled || !this.filePath) return null;
|
|
4805
|
+
if (this.fileStream) return this.fileStream;
|
|
4806
|
+
try {
|
|
4807
|
+
const stream = (0, import_node_fs7.createWriteStream)(this.filePath, { flags: "a" });
|
|
4808
|
+
stream.on("error", () => {
|
|
4809
|
+
this.fileDisabled = true;
|
|
4810
|
+
this.fileStream = null;
|
|
4811
|
+
});
|
|
4812
|
+
this.fileStream = stream;
|
|
4813
|
+
return stream;
|
|
4814
|
+
} catch {
|
|
4815
|
+
this.fileDisabled = true;
|
|
4816
|
+
return null;
|
|
4817
|
+
}
|
|
4818
|
+
}
|
|
4819
|
+
consoleFn(level) {
|
|
4820
|
+
switch (level) {
|
|
4821
|
+
case "error":
|
|
4822
|
+
return console.error;
|
|
4823
|
+
case "warn":
|
|
4824
|
+
return console.warn;
|
|
4825
|
+
case "info":
|
|
4826
|
+
return console.info;
|
|
4827
|
+
case "debug":
|
|
4828
|
+
return console.debug;
|
|
4829
|
+
}
|
|
4830
|
+
}
|
|
4831
|
+
/** `{ ts, level, msg, ...meta }` (+ `error` when present) as a single line. */
|
|
4832
|
+
jsonLine(level, message, error, meta) {
|
|
4833
|
+
const obj = {
|
|
4834
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4835
|
+
level,
|
|
4836
|
+
msg: message
|
|
4837
|
+
};
|
|
4838
|
+
if (error !== void 0) obj["error"] = reduceError(error);
|
|
4839
|
+
if (meta !== void 0) {
|
|
4840
|
+
if (meta instanceof Error) obj["meta"] = reduceError(meta);
|
|
4841
|
+
else if (meta && typeof meta === "object") {
|
|
4842
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
4843
|
+
if (!RESERVED_JSON_KEYS.has(k)) obj[k] = v;
|
|
4844
|
+
}
|
|
4845
|
+
} else obj["meta"] = meta;
|
|
4846
|
+
}
|
|
4847
|
+
try {
|
|
4848
|
+
return JSON.stringify(obj);
|
|
4849
|
+
} catch {
|
|
4850
|
+
return JSON.stringify({ ts: obj["ts"], level, msg: message });
|
|
4851
|
+
}
|
|
4852
|
+
}
|
|
4853
|
+
/** Human-readable file line: `ISO [level] message {metaJson}`. */
|
|
4854
|
+
textLine(level, message, error, meta) {
|
|
4855
|
+
const parts = [`${(/* @__PURE__ */ new Date()).toISOString()} [${level}] ${message}`];
|
|
4856
|
+
if (error !== void 0) parts.push(safeStringify(reduceError(error)));
|
|
4857
|
+
if (meta !== void 0) parts.push(safeStringify(meta instanceof Error ? reduceError(meta) : meta));
|
|
4858
|
+
return parts.join(" ");
|
|
3468
4859
|
}
|
|
3469
4860
|
};
|
|
4861
|
+
function reduceError(error) {
|
|
4862
|
+
if (error instanceof Error) {
|
|
4863
|
+
return error.stack ? { message: error.message, stack: error.stack } : { message: error.message };
|
|
4864
|
+
}
|
|
4865
|
+
return { value: String(error) };
|
|
4866
|
+
}
|
|
4867
|
+
function safeStringify(value) {
|
|
4868
|
+
try {
|
|
4869
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
4870
|
+
} catch {
|
|
4871
|
+
return "[unserializable]";
|
|
4872
|
+
}
|
|
4873
|
+
}
|
|
3470
4874
|
|
|
3471
4875
|
// src/ports/JsonApiServerSettingsStore.ts
|
|
3472
|
-
var
|
|
3473
|
-
var
|
|
4876
|
+
var import_node_fs8 = require("fs");
|
|
4877
|
+
var import_outbound_api4 = require("@omnicross/core/outbound-api");
|
|
3474
4878
|
var JsonApiServerSettingsStore = class {
|
|
3475
|
-
|
|
4879
|
+
/**
|
|
4880
|
+
* @param configPath the daemon config.json whose `server` field is backed.
|
|
4881
|
+
* @param box OPTIONAL at-rest `SecretBox` (upstream-proxy). When set, the
|
|
4882
|
+
* `server.proxy.*` passwords are encrypted-on-`set` /
|
|
4883
|
+
* decrypted-on-`get` (the settings-store path is otherwise not
|
|
4884
|
+
* secret-aware — every OTHER server field is non-secret). Null
|
|
4885
|
+
* ⇒ passthrough (legacy/pure tests unchanged).
|
|
4886
|
+
*/
|
|
4887
|
+
constructor(configPath, box = null) {
|
|
3476
4888
|
this.configPath = configPath;
|
|
4889
|
+
this.box = box;
|
|
3477
4890
|
}
|
|
3478
4891
|
configPath;
|
|
4892
|
+
box;
|
|
3479
4893
|
async get(key) {
|
|
3480
|
-
if (key !==
|
|
4894
|
+
if (key !== import_outbound_api4.OUTBOUND_API_SERVER_CONFIG_KEY) return void 0;
|
|
3481
4895
|
const file = this.readFile();
|
|
3482
|
-
|
|
4896
|
+
if (file.server === void 0) return void 0;
|
|
4897
|
+
return this.decryptSecrets(file.server);
|
|
3483
4898
|
}
|
|
3484
4899
|
async set(key, value) {
|
|
3485
|
-
if (key !==
|
|
4900
|
+
if (key !== import_outbound_api4.OUTBOUND_API_SERVER_CONFIG_KEY) return;
|
|
3486
4901
|
const file = this.readFile();
|
|
3487
|
-
file.server = value;
|
|
3488
|
-
(0,
|
|
4902
|
+
file.server = this.encryptSecrets(value);
|
|
4903
|
+
(0, import_node_fs8.writeFileSync)(this.configPath, JSON.stringify(file, null, 2) + "\n", "utf8");
|
|
4904
|
+
}
|
|
4905
|
+
/** Encrypt the proxy passwords + webhook + billing secrets before persisting (no-op without a box). */
|
|
4906
|
+
encryptSecrets(config) {
|
|
4907
|
+
if (!this.box) return config;
|
|
4908
|
+
let out = config;
|
|
4909
|
+
if (out?.proxy) out = { ...out, proxy: encryptProxySegment(out.proxy, this.box) };
|
|
4910
|
+
if (out?.webhook) out = { ...out, webhook: encryptWebhookSegment(out.webhook, this.box) };
|
|
4911
|
+
if (out?.billing) out = { ...out, billing: encryptBillingSegment(out.billing, this.box) };
|
|
4912
|
+
return out;
|
|
4913
|
+
}
|
|
4914
|
+
/** Decrypt the proxy passwords + webhook + billing secrets on read (no-op without a box). */
|
|
4915
|
+
decryptSecrets(config) {
|
|
4916
|
+
if (!this.box) return config;
|
|
4917
|
+
let out = config;
|
|
4918
|
+
if (out?.proxy) out = { ...out, proxy: decryptProxySegment(out.proxy, this.box) };
|
|
4919
|
+
if (out?.webhook) out = { ...out, webhook: decryptWebhookSegment(out.webhook, this.box) };
|
|
4920
|
+
if (out?.billing) out = { ...out, billing: decryptBillingSegment(out.billing, this.box) };
|
|
4921
|
+
return out;
|
|
3489
4922
|
}
|
|
3490
4923
|
/** Read the config.json, tolerating a missing/corrupt file (→ empty shape). */
|
|
3491
4924
|
readFile() {
|
|
3492
4925
|
try {
|
|
3493
|
-
const raw = (0,
|
|
4926
|
+
const raw = (0, import_node_fs8.readFileSync)(this.configPath, "utf8");
|
|
3494
4927
|
const parsed = JSON.parse(raw);
|
|
3495
4928
|
if (parsed && typeof parsed === "object") return parsed;
|
|
3496
4929
|
} catch {
|
|
@@ -3501,7 +4934,7 @@ var JsonApiServerSettingsStore = class {
|
|
|
3501
4934
|
|
|
3502
4935
|
// src/ports/JsonlUsageEventStore.ts
|
|
3503
4936
|
var import_node_crypto9 = require("crypto");
|
|
3504
|
-
var
|
|
4937
|
+
var import_node_fs9 = require("fs");
|
|
3505
4938
|
var JsonlUsageEventStore = class {
|
|
3506
4939
|
constructor(eventsPath, isPriced) {
|
|
3507
4940
|
this.eventsPath = eventsPath;
|
|
@@ -3516,7 +4949,7 @@ var JsonlUsageEventStore = class {
|
|
|
3516
4949
|
id: (0, import_node_crypto9.randomUUID)(),
|
|
3517
4950
|
ts: input.ts ?? Date.now()
|
|
3518
4951
|
};
|
|
3519
|
-
(0,
|
|
4952
|
+
(0, import_node_fs9.appendFileSync)(this.eventsPath, JSON.stringify(row) + "\n", "utf8");
|
|
3520
4953
|
return row.id;
|
|
3521
4954
|
}
|
|
3522
4955
|
async getTotals(range) {
|
|
@@ -3605,6 +5038,57 @@ var JsonlUsageEventStore = class {
|
|
|
3605
5038
|
}
|
|
3606
5039
|
return Array.from(groups.values());
|
|
3607
5040
|
}
|
|
5041
|
+
/**
|
|
5042
|
+
* ONE pass over a single key's events (`ts < endTs`) summing its `costUsd` into
|
|
5043
|
+
* `totalUsd` / `dailyUsd` (`ts >= dayStartTs`) / `weeklyUsd` (`ts >= weekStartTs`).
|
|
5044
|
+
* Used to lazily seed the outbound key-policy spend tracker (once per key). A
|
|
5045
|
+
* key with no attributed events yields all zeros.
|
|
5046
|
+
*/
|
|
5047
|
+
async getSpendByKey(query) {
|
|
5048
|
+
let totalUsd = 0;
|
|
5049
|
+
let dailyUsd = 0;
|
|
5050
|
+
let weeklyUsd = 0;
|
|
5051
|
+
for (const row of this.readRows({ startTs: 0, endTs: query.endTs })) {
|
|
5052
|
+
if (row.apiKeyId !== query.apiKeyId) continue;
|
|
5053
|
+
totalUsd += row.costUsd;
|
|
5054
|
+
if (row.ts >= query.dayStartTs) dailyUsd += row.costUsd;
|
|
5055
|
+
if (row.ts >= query.weekStartTs) weeklyUsd += row.costUsd;
|
|
5056
|
+
}
|
|
5057
|
+
return { totalUsd, dailyUsd, weeklyUsd };
|
|
5058
|
+
}
|
|
5059
|
+
/**
|
|
5060
|
+
* Time-series aggregation over LOCAL-time bucket boundaries. Every bucket in
|
|
5061
|
+
* `[floor(startTs), endTs)` is present (empty ones zero-filled), ascending by
|
|
5062
|
+
* `bucketStartTs`; an empty range (`startTs >= endTs`) returns `[]`. Reuses
|
|
5063
|
+
* `readRows` so malformed lines are skipped and only in-range rows contribute.
|
|
5064
|
+
*/
|
|
5065
|
+
async getTimeSeries(range, bucket) {
|
|
5066
|
+
if (range.startTs >= range.endTs) return [];
|
|
5067
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
5068
|
+
for (let b = floorToBucket(range.startTs, bucket); b < range.endTs; b = nextBoundary(b, bucket)) {
|
|
5069
|
+
buckets.set(b, {
|
|
5070
|
+
bucketStartTs: b,
|
|
5071
|
+
label: bucketLabel(b, bucket),
|
|
5072
|
+
requests: 0,
|
|
5073
|
+
inputTokens: 0,
|
|
5074
|
+
outputTokens: 0,
|
|
5075
|
+
cacheReadTokens: 0,
|
|
5076
|
+
cacheCreationTokens: 0,
|
|
5077
|
+
costUsd: 0
|
|
5078
|
+
});
|
|
5079
|
+
}
|
|
5080
|
+
for (const row of this.readRows(range)) {
|
|
5081
|
+
const g = buckets.get(floorToBucket(row.ts, bucket));
|
|
5082
|
+
if (!g) continue;
|
|
5083
|
+
g.requests += 1;
|
|
5084
|
+
g.inputTokens += row.inputTokens;
|
|
5085
|
+
g.outputTokens += row.outputTokens;
|
|
5086
|
+
g.cacheReadTokens += row.cacheReadTokens;
|
|
5087
|
+
g.cacheCreationTokens += row.cacheCreationTokens;
|
|
5088
|
+
g.costUsd += row.costUsd;
|
|
5089
|
+
}
|
|
5090
|
+
return Array.from(buckets.values());
|
|
5091
|
+
}
|
|
3608
5092
|
async getMessagesForSession(sessionId) {
|
|
3609
5093
|
return this.readAllRows().filter((r) => r.sessionId === sessionId).sort((a, b) => a.ts - b.ts).map((r) => ({
|
|
3610
5094
|
id: r.id,
|
|
@@ -3653,10 +5137,10 @@ var JsonlUsageEventStore = class {
|
|
|
3653
5137
|
}
|
|
3654
5138
|
/** Parse every line, skipping malformed/torn lines defensively. */
|
|
3655
5139
|
readAllRows() {
|
|
3656
|
-
if (!(0,
|
|
5140
|
+
if (!(0, import_node_fs9.existsSync)(this.eventsPath)) return [];
|
|
3657
5141
|
let raw;
|
|
3658
5142
|
try {
|
|
3659
|
-
raw = (0,
|
|
5143
|
+
raw = (0, import_node_fs9.readFileSync)(this.eventsPath, "utf8");
|
|
3660
5144
|
} catch {
|
|
3661
5145
|
return [];
|
|
3662
5146
|
}
|
|
@@ -3673,6 +5157,43 @@ var JsonlUsageEventStore = class {
|
|
|
3673
5157
|
return rows;
|
|
3674
5158
|
}
|
|
3675
5159
|
};
|
|
5160
|
+
function floorToBucket(ts, bucket) {
|
|
5161
|
+
const d = new Date(ts);
|
|
5162
|
+
switch (bucket) {
|
|
5163
|
+
case "hour":
|
|
5164
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()).getTime();
|
|
5165
|
+
case "day":
|
|
5166
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
5167
|
+
case "month":
|
|
5168
|
+
return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
|
|
5169
|
+
}
|
|
5170
|
+
}
|
|
5171
|
+
function nextBoundary(ts, bucket) {
|
|
5172
|
+
const d = new Date(ts);
|
|
5173
|
+
switch (bucket) {
|
|
5174
|
+
case "hour":
|
|
5175
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours() + 1).getTime();
|
|
5176
|
+
case "day":
|
|
5177
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime();
|
|
5178
|
+
case "month":
|
|
5179
|
+
return new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
|
|
5180
|
+
}
|
|
5181
|
+
}
|
|
5182
|
+
var pad2 = (n) => String(n).padStart(2, "0");
|
|
5183
|
+
function bucketLabel(bucketStartTs, bucket) {
|
|
5184
|
+
const d = new Date(bucketStartTs);
|
|
5185
|
+
const y = d.getFullYear();
|
|
5186
|
+
const mo = pad2(d.getMonth() + 1);
|
|
5187
|
+
const day = pad2(d.getDate());
|
|
5188
|
+
switch (bucket) {
|
|
5189
|
+
case "hour":
|
|
5190
|
+
return `${mo}-${day} ${pad2(d.getHours())}:00`;
|
|
5191
|
+
case "day":
|
|
5192
|
+
return `${y}-${mo}-${day}`;
|
|
5193
|
+
case "month":
|
|
5194
|
+
return `${y}-${mo}`;
|
|
5195
|
+
}
|
|
5196
|
+
}
|
|
3676
5197
|
var NUMERIC_FIELDS = [
|
|
3677
5198
|
"ts",
|
|
3678
5199
|
"inputTokens",
|
|
@@ -3703,7 +5224,7 @@ function isUsageEventRecord(parsed) {
|
|
|
3703
5224
|
}
|
|
3704
5225
|
|
|
3705
5226
|
// src/ports/JsonPricingStore.ts
|
|
3706
|
-
var
|
|
5227
|
+
var import_node_fs10 = require("fs");
|
|
3707
5228
|
var JsonPricingStore = class {
|
|
3708
5229
|
constructor(pricingPath) {
|
|
3709
5230
|
this.pricingPath = pricingPath;
|
|
@@ -3814,52 +5335,147 @@ var JsonPricingStore = class {
|
|
|
3814
5335
|
}
|
|
3815
5336
|
/** Read the pricing rows, tolerating a missing/corrupt file (→ empty list). */
|
|
3816
5337
|
readRows() {
|
|
3817
|
-
if (!(0,
|
|
5338
|
+
if (!(0, import_node_fs10.existsSync)(this.pricingPath)) return [];
|
|
3818
5339
|
try {
|
|
3819
|
-
const parsed = JSON.parse((0,
|
|
5340
|
+
const parsed = JSON.parse((0, import_node_fs10.readFileSync)(this.pricingPath, "utf8"));
|
|
3820
5341
|
return Array.isArray(parsed) ? parsed : [];
|
|
3821
5342
|
} catch {
|
|
3822
5343
|
return [];
|
|
3823
5344
|
}
|
|
3824
5345
|
}
|
|
3825
5346
|
writeRows(rows) {
|
|
3826
|
-
(0,
|
|
5347
|
+
(0, import_node_fs10.writeFileSync)(this.pricingPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
|
|
3827
5348
|
}
|
|
3828
5349
|
};
|
|
3829
5350
|
|
|
3830
|
-
// src/ports/
|
|
3831
|
-
var
|
|
3832
|
-
var
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
}
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
5351
|
+
// src/ports/JsonVoucherDb.ts
|
|
5352
|
+
var import_node_fs11 = require("fs");
|
|
5353
|
+
var JsonVoucherDb = class {
|
|
5354
|
+
constructor(vouchersPath) {
|
|
5355
|
+
this.vouchersPath = vouchersPath;
|
|
5356
|
+
}
|
|
5357
|
+
vouchersPath;
|
|
5358
|
+
async voucherCreate(input) {
|
|
5359
|
+
const rows = this.readRows();
|
|
5360
|
+
const row = {
|
|
5361
|
+
id: input.id,
|
|
5362
|
+
codeHash: input.codeHash,
|
|
5363
|
+
codePrefix: input.codePrefix,
|
|
5364
|
+
type: input.type,
|
|
5365
|
+
status: "unredeemed",
|
|
5366
|
+
createdAt: input.createdAt ?? Date.now()
|
|
5367
|
+
};
|
|
5368
|
+
if (input.creditUsd != null) row.creditUsd = input.creditUsd;
|
|
5369
|
+
if (input.renewalDays != null) row.renewalDays = input.renewalDays;
|
|
5370
|
+
if (input.maxTotalCostLimitUsd != null) row.maxTotalCostLimitUsd = input.maxTotalCostLimitUsd;
|
|
5371
|
+
if (input.maxExpiryDays != null) row.maxExpiryDays = input.maxExpiryDays;
|
|
5372
|
+
rows.push(row);
|
|
5373
|
+
this.writeRows(rows);
|
|
5374
|
+
return row;
|
|
5375
|
+
}
|
|
5376
|
+
async voucherGetByHash(codeHash) {
|
|
5377
|
+
const rows = this.readRows();
|
|
5378
|
+
return rows.find((r) => r.codeHash === codeHash) ?? null;
|
|
5379
|
+
}
|
|
5380
|
+
async voucherRedeemCas(id, keyId, granted, now) {
|
|
5381
|
+
const rows = this.readRows();
|
|
5382
|
+
const row = rows.find((r) => r.id === id);
|
|
5383
|
+
if (!row || row.status !== "unredeemed") return false;
|
|
5384
|
+
row.status = "redeemed";
|
|
5385
|
+
row.redeemedAt = now;
|
|
5386
|
+
row.redeemedByKeyId = keyId;
|
|
5387
|
+
row.grantApplied = false;
|
|
5388
|
+
if (granted.totalCostLimitUsd != null) row.grantedTotalCostLimitUsd = granted.totalCostLimitUsd;
|
|
5389
|
+
if (granted.expiresAt != null) row.grantedExpiresAt = granted.expiresAt;
|
|
5390
|
+
this.writeRows(rows);
|
|
5391
|
+
return true;
|
|
5392
|
+
}
|
|
5393
|
+
async voucherMarkGrantApplied(id) {
|
|
5394
|
+
const rows = this.readRows();
|
|
5395
|
+
const row = rows.find((r) => r.id === id);
|
|
5396
|
+
if (!row || row.status !== "redeemed") return false;
|
|
5397
|
+
if (row.grantApplied === true) return true;
|
|
5398
|
+
row.grantApplied = true;
|
|
5399
|
+
this.writeRows(rows);
|
|
5400
|
+
return true;
|
|
5401
|
+
}
|
|
5402
|
+
async voucherRevertRedeem(id, keyId) {
|
|
5403
|
+
const rows = this.readRows();
|
|
5404
|
+
const row = rows.find((r) => r.id === id);
|
|
5405
|
+
if (!row || row.status !== "redeemed" || row.grantApplied === true) return false;
|
|
5406
|
+
if (row.redeemedByKeyId !== keyId) return false;
|
|
5407
|
+
row.status = "unredeemed";
|
|
5408
|
+
delete row.redeemedAt;
|
|
5409
|
+
delete row.redeemedByKeyId;
|
|
5410
|
+
delete row.grantApplied;
|
|
5411
|
+
delete row.grantedTotalCostLimitUsd;
|
|
5412
|
+
delete row.grantedExpiresAt;
|
|
5413
|
+
this.writeRows(rows);
|
|
5414
|
+
return true;
|
|
5415
|
+
}
|
|
5416
|
+
async voucherRevokeCas(id, now) {
|
|
5417
|
+
const rows = this.readRows();
|
|
5418
|
+
const row = rows.find((r) => r.id === id);
|
|
5419
|
+
if (!row || row.status !== "unredeemed") return false;
|
|
5420
|
+
row.status = "revoked";
|
|
5421
|
+
row.revokedAt = now;
|
|
5422
|
+
this.writeRows(rows);
|
|
5423
|
+
return true;
|
|
5424
|
+
}
|
|
5425
|
+
async voucherList() {
|
|
5426
|
+
return this.readRows();
|
|
5427
|
+
}
|
|
5428
|
+
/** Read the voucher rows, tolerating a missing/corrupt file (→ empty list). */
|
|
5429
|
+
readRows() {
|
|
5430
|
+
if (!(0, import_node_fs11.existsSync)(this.vouchersPath)) return [];
|
|
5431
|
+
try {
|
|
5432
|
+
const parsed = JSON.parse((0, import_node_fs11.readFileSync)(this.vouchersPath, "utf8"));
|
|
5433
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
5434
|
+
} catch {
|
|
5435
|
+
return [];
|
|
5436
|
+
}
|
|
5437
|
+
}
|
|
5438
|
+
writeRows(rows) {
|
|
5439
|
+
(0, import_node_fs11.writeFileSync)(this.vouchersPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
|
|
5440
|
+
}
|
|
5441
|
+
};
|
|
5442
|
+
|
|
5443
|
+
// src/ports/JsonSubscriptionCredentialStore.ts
|
|
5444
|
+
var import_node_fs14 = require("fs");
|
|
5445
|
+
var import_node_path7 = require("path");
|
|
5446
|
+
var import_SubscriptionAccountHealth = require("@omnicross/core/pipeline/SubscriptionAccountHealth");
|
|
5447
|
+
var import_upstreamFetch3 = require("@omnicross/core/pipeline/upstreamFetch");
|
|
5448
|
+
var import_SubscriptionIdentityStore = require("@omnicross/core/provider-proxy/identity/SubscriptionIdentityStore");
|
|
5449
|
+
var import_subscriptions3 = require("@omnicross/subscriptions");
|
|
5450
|
+
|
|
5451
|
+
// src/ports/account-sync.ts
|
|
5452
|
+
var IMPORT_EXPIRY_MARGIN_MS = 6e4;
|
|
5453
|
+
function viewOf(tokens) {
|
|
5454
|
+
return tokens;
|
|
5455
|
+
}
|
|
5456
|
+
function decideExternalImport(captured, external, now = Date.now()) {
|
|
5457
|
+
if (!external?.accessToken) return "no-credential";
|
|
5458
|
+
const capturedRt = viewOf(captured).refreshToken;
|
|
5459
|
+
const hasNewRefresh = Boolean(external.refreshToken && external.refreshToken !== capturedRt);
|
|
5460
|
+
const accessStillValid = external.expiresAt ? Date.parse(external.expiresAt) > now + IMPORT_EXPIRY_MARGIN_MS : true;
|
|
5461
|
+
return hasNewRefresh || accessStillValid ? "import" : "not-rotated";
|
|
5462
|
+
}
|
|
5463
|
+
function buildImportedTokens(captured, external) {
|
|
5464
|
+
const imported = {
|
|
5465
|
+
...captured,
|
|
5466
|
+
accessToken: external.accessToken,
|
|
5467
|
+
status: "authorized",
|
|
5468
|
+
errorMessage: void 0,
|
|
5469
|
+
syncWarning: void 0,
|
|
5470
|
+
lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5471
|
+
};
|
|
5472
|
+
if (external.refreshToken) imported.refreshToken = external.refreshToken;
|
|
5473
|
+
if (external.expiresAt) imported.expiresAt = external.expiresAt;
|
|
5474
|
+
else delete imported.expiresAt;
|
|
5475
|
+
if (external.idToken) imported.idToken = external.idToken;
|
|
5476
|
+
if (external.scopes) imported.scopes = external.scopes;
|
|
5477
|
+
return imported;
|
|
5478
|
+
}
|
|
3863
5479
|
function buildTokensFromExternal(provider, external) {
|
|
3864
5480
|
const base = {
|
|
3865
5481
|
authMethod: "oauth",
|
|
@@ -3906,7 +5522,7 @@ function findDuplicateCredentialIds(accounts) {
|
|
|
3906
5522
|
}
|
|
3907
5523
|
|
|
3908
5524
|
// src/ports/external-cli-credentials.ts
|
|
3909
|
-
var
|
|
5525
|
+
var import_node_fs12 = require("fs");
|
|
3910
5526
|
var import_node_os2 = require("os");
|
|
3911
5527
|
var import_node_path5 = require("path");
|
|
3912
5528
|
function externalStorePath(provider, home = (0, import_node_os2.homedir)()) {
|
|
@@ -3959,10 +5575,10 @@ function parseCodexTokensEnvelope(raw) {
|
|
|
3959
5575
|
}
|
|
3960
5576
|
function readExternalCliCredentials(provider, home = (0, import_node_os2.homedir)()) {
|
|
3961
5577
|
const path2 = externalStorePath(provider, home);
|
|
3962
|
-
if (!(0,
|
|
5578
|
+
if (!(0, import_node_fs12.existsSync)(path2)) return null;
|
|
3963
5579
|
let raw;
|
|
3964
5580
|
try {
|
|
3965
|
-
const parsed = JSON.parse((0,
|
|
5581
|
+
const parsed = JSON.parse((0, import_node_fs12.readFileSync)(path2, "utf8"));
|
|
3966
5582
|
raw = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
|
|
3967
5583
|
} catch {
|
|
3968
5584
|
return null;
|
|
@@ -3971,7 +5587,7 @@ function readExternalCliCredentials(provider, home = (0, import_node_os2.homedir
|
|
|
3971
5587
|
}
|
|
3972
5588
|
|
|
3973
5589
|
// src/ports/external-cli-store.ts
|
|
3974
|
-
var
|
|
5590
|
+
var import_node_fs13 = require("fs");
|
|
3975
5591
|
var import_node_os3 = require("os");
|
|
3976
5592
|
var import_node_path6 = require("path");
|
|
3977
5593
|
function markerPath(provider, home) {
|
|
@@ -3999,27 +5615,27 @@ function buildCodexTokensEnvelope(tokens) {
|
|
|
3999
5615
|
return envelope;
|
|
4000
5616
|
}
|
|
4001
5617
|
function readExistingObject(path2) {
|
|
4002
|
-
if (!(0,
|
|
5618
|
+
if (!(0, import_node_fs13.existsSync)(path2)) return {};
|
|
4003
5619
|
try {
|
|
4004
|
-
const parsed = JSON.parse((0,
|
|
5620
|
+
const parsed = JSON.parse((0, import_node_fs13.readFileSync)(path2, "utf8"));
|
|
4005
5621
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
4006
5622
|
} catch {
|
|
4007
5623
|
return {};
|
|
4008
5624
|
}
|
|
4009
5625
|
}
|
|
4010
5626
|
function writeAtomic(path2, content) {
|
|
4011
|
-
(0,
|
|
5627
|
+
(0, import_node_fs13.mkdirSync)((0, import_node_path6.dirname)(path2), { recursive: true });
|
|
4012
5628
|
const temp = `${path2}.omnicross-tmp`;
|
|
4013
|
-
(0,
|
|
4014
|
-
(0,
|
|
5629
|
+
(0, import_node_fs13.writeFileSync)(temp, content, "utf8");
|
|
5630
|
+
(0, import_node_fs13.renameSync)(temp, path2);
|
|
4015
5631
|
}
|
|
4016
5632
|
function createExternalCliStore(home = (0, import_node_os3.homedir)()) {
|
|
4017
5633
|
return {
|
|
4018
5634
|
readMarkerAccountId(provider) {
|
|
4019
5635
|
const path2 = markerPath(provider, home);
|
|
4020
|
-
if (!(0,
|
|
5636
|
+
if (!(0, import_node_fs13.existsSync)(path2)) return void 0;
|
|
4021
5637
|
try {
|
|
4022
|
-
const parsed = JSON.parse((0,
|
|
5638
|
+
const parsed = JSON.parse((0, import_node_fs13.readFileSync)(path2, "utf8"));
|
|
4023
5639
|
return typeof parsed.accountId === "string" && parsed.accountId ? parsed.accountId : void 0;
|
|
4024
5640
|
} catch {
|
|
4025
5641
|
return void 0;
|
|
@@ -4037,8 +5653,8 @@ function createExternalCliStore(home = (0, import_node_os3.homedir)()) {
|
|
|
4037
5653
|
const envelope = provider === "claude" ? buildClaudeOAuthEnvelope(tokens) : buildCodexTokensEnvelope(tokens);
|
|
4038
5654
|
if (!envelope) return false;
|
|
4039
5655
|
const storePath = externalStorePath(provider, home);
|
|
4040
|
-
if ((0,
|
|
4041
|
-
(0,
|
|
5656
|
+
if ((0, import_node_fs13.existsSync)(storePath) && !(0, import_node_fs13.existsSync)(backupPath(provider, home))) {
|
|
5657
|
+
(0, import_node_fs13.copyFileSync)(storePath, backupPath(provider, home));
|
|
4042
5658
|
}
|
|
4043
5659
|
const existing = readExistingObject(storePath);
|
|
4044
5660
|
const merged = provider === "claude" ? { ...existing, claudeAiOauth: envelope } : { ...existing, tokens: envelope };
|
|
@@ -4049,16 +5665,21 @@ function createExternalCliStore(home = (0, import_node_os3.homedir)()) {
|
|
|
4049
5665
|
}
|
|
4050
5666
|
|
|
4051
5667
|
// src/ports/JsonSubscriptionCredentialStore.ts
|
|
5668
|
+
var ACCOUNT_REFRESH_LEAD_MS = 5 * 6e4;
|
|
4052
5669
|
var JsonSubscriptionCredentialStore = class {
|
|
4053
5670
|
/**
|
|
4054
5671
|
* @param tokensPath on-disk `tokens.json` location.
|
|
4055
5672
|
* @param box at-rest `SecretBox` (encrypt-on-write / decrypt-on-read).
|
|
4056
|
-
* @param fetchImpl injectable HTTP port for the OAuth refresh
|
|
4057
|
-
* (oauth design D4).
|
|
4058
|
-
*
|
|
4059
|
-
*
|
|
5673
|
+
* @param fetchImpl OPTIONAL injectable HTTP port for the OAuth refresh
|
|
5674
|
+
* round-trips (oauth design D4). A TEST-injected transport is
|
|
5675
|
+
* used verbatim. When ABSENT (production), each refresh uses a
|
|
5676
|
+
* proxy-aware {@link fetchUpstream} that threads the
|
|
5677
|
+
* `{ providerId, accountId }` ctx (upstream-proxy M1) so a
|
|
5678
|
+
* per-account/per-provider proxy is honored on refresh exactly
|
|
5679
|
+
* as on relay — refresh egresses from the SAME proxy IP as the
|
|
5680
|
+
* account's traffic. NOT used by any read/write path.
|
|
4060
5681
|
*/
|
|
4061
|
-
constructor(tokensPath, box, fetchImpl =
|
|
5682
|
+
constructor(tokensPath, box, fetchImpl = void 0, externalCliReader = readExternalCliCredentials, externalCliStore = createExternalCliStore()) {
|
|
4062
5683
|
this.tokensPath = tokensPath;
|
|
4063
5684
|
this.box = box;
|
|
4064
5685
|
this.fetchImpl = fetchImpl;
|
|
@@ -4070,6 +5691,15 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4070
5691
|
fetchImpl;
|
|
4071
5692
|
externalCliReader;
|
|
4072
5693
|
externalCliStore;
|
|
5694
|
+
/**
|
|
5695
|
+
* The proxy-aware `FetchLike` for one refresh round-trip (upstream-proxy M1). A
|
|
5696
|
+
* TEST-injected `fetchImpl` is returned verbatim; otherwise the refresh routes
|
|
5697
|
+
* through {@link fetchUpstream} with the account's `{ providerId, accountId }`
|
|
5698
|
+
* ctx so the per-account/provider proxy applies. `@internal` — also a test seam.
|
|
5699
|
+
*/
|
|
5700
|
+
buildRefreshFetch(providerId, accountId) {
|
|
5701
|
+
return this.fetchImpl ?? ((url, init) => (0, import_upstreamFetch3.fetchUpstream)(url, init, { providerId, accountId }));
|
|
5702
|
+
}
|
|
4073
5703
|
/**
|
|
4074
5704
|
* In-flight refresh coalescing (external-cli-sync). OAuth refresh tokens are
|
|
4075
5705
|
* SINGLE-USE: two concurrent refreshes of one account each spend the same
|
|
@@ -4101,6 +5731,19 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4101
5731
|
async getValidOpenCodeGoApiKey() {
|
|
4102
5732
|
return this.readConfig().opencodego?.apiKey ?? null;
|
|
4103
5733
|
}
|
|
5734
|
+
/**
|
|
5735
|
+
* DAEMON-ONLY per-account proxy lookup by id (upstream-proxy). Returns the
|
|
5736
|
+
* DECRYPTED `ProxyConfig` for the account (`readConfig` decrypts on read), or
|
|
5737
|
+
* `undefined` for an unknown provider/account or no per-account proxy. Feeds the
|
|
5738
|
+
* winning per-account layer of the upstream-proxy resolver. Synchronous like the
|
|
5739
|
+
* other hot reads. Never returns token material.
|
|
5740
|
+
*/
|
|
5741
|
+
getAccountProxy(providerId, accountId) {
|
|
5742
|
+
if (providerId !== "claude" && providerId !== "codex" && providerId !== "gemini" && providerId !== "opencodego") {
|
|
5743
|
+
return void 0;
|
|
5744
|
+
}
|
|
5745
|
+
return getAccountProxy(this.readConfig(), providerId, accountId);
|
|
5746
|
+
}
|
|
4104
5747
|
/**
|
|
4105
5748
|
* DAEMON-ONLY sanitized accounts list (design D8, NOT on the port). Projects
|
|
4106
5749
|
* each provider's accounts to the secret-free `SubscriptionAccountSanitized`
|
|
@@ -4109,10 +5752,25 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4109
5752
|
*/
|
|
4110
5753
|
async listSanitizedAccounts() {
|
|
4111
5754
|
const config = this.readConfig();
|
|
5755
|
+
const health2 = (0, import_SubscriptionAccountHealth.getSharedAccountHealth)();
|
|
5756
|
+
const identityStore = (0, import_SubscriptionIdentityStore.getSharedIdentityStore)();
|
|
5757
|
+
const fingerprintOn = identityStore.isEnabled();
|
|
5758
|
+
const now = Date.now();
|
|
4112
5759
|
const out = {};
|
|
4113
5760
|
for (const provider of ["claude", "codex", "gemini", "opencodego"]) {
|
|
4114
5761
|
const sanitized = sanitizeAccounts(config, provider);
|
|
4115
|
-
if (sanitized.length
|
|
5762
|
+
if (sanitized.length === 0) continue;
|
|
5763
|
+
for (const account of sanitized) {
|
|
5764
|
+
const status = health2.getStatus(provider, account.id, now);
|
|
5765
|
+
account.health = status.state;
|
|
5766
|
+
account.cooldownUntil = status.cooldownUntil !== void 0 ? new Date(status.cooldownUntil).toISOString() : void 0;
|
|
5767
|
+
if (fingerprintOn && provider === "claude") {
|
|
5768
|
+
account.identityCaptured = identityStore.hasIdentity(provider, account.id);
|
|
5769
|
+
const capturedAt = identityStore.capturedAt(provider, account.id);
|
|
5770
|
+
account.identityCapturedAt = capturedAt !== void 0 ? new Date(capturedAt).toISOString() : void 0;
|
|
5771
|
+
}
|
|
5772
|
+
}
|
|
5773
|
+
out[provider] = this.attachSyncWarnings(config, provider, sanitized);
|
|
4116
5774
|
}
|
|
4117
5775
|
return out;
|
|
4118
5776
|
}
|
|
@@ -4163,8 +5821,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4163
5821
|
if (!active || !claude?.refreshToken) return false;
|
|
4164
5822
|
const capturedId = active.id;
|
|
4165
5823
|
this.materializeMigration(config);
|
|
5824
|
+
const refreshFetch = this.buildRefreshFetch("claude", capturedId);
|
|
4166
5825
|
try {
|
|
4167
|
-
const result = await import_subscriptions3.claudeOAuth.refreshAccessToken(claude.refreshToken,
|
|
5826
|
+
const result = await import_subscriptions3.claudeOAuth.refreshAccessToken(claude.refreshToken, refreshFetch);
|
|
4168
5827
|
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
4169
5828
|
const next = {
|
|
4170
5829
|
...claude,
|
|
@@ -4181,7 +5840,7 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4181
5840
|
return true;
|
|
4182
5841
|
} catch (error) {
|
|
4183
5842
|
if (await this.tryExternalImport("claude", capturedId, claude, async (rt) => {
|
|
4184
|
-
const r = await import_subscriptions3.claudeOAuth.refreshAccessToken(rt,
|
|
5843
|
+
const r = await import_subscriptions3.claudeOAuth.refreshAccessToken(rt, refreshFetch);
|
|
4185
5844
|
return {
|
|
4186
5845
|
accessToken: r.accessToken,
|
|
4187
5846
|
refreshToken: r.refreshToken,
|
|
@@ -4208,8 +5867,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4208
5867
|
if (!active || !codex?.refreshToken) return false;
|
|
4209
5868
|
const capturedId = active.id;
|
|
4210
5869
|
this.materializeMigration(config);
|
|
5870
|
+
const refreshFetch = this.buildRefreshFetch("codex", capturedId);
|
|
4211
5871
|
try {
|
|
4212
|
-
const result = await import_subscriptions3.codexOAuth.refreshAccessToken(codex.refreshToken,
|
|
5872
|
+
const result = await import_subscriptions3.codexOAuth.refreshAccessToken(codex.refreshToken, refreshFetch);
|
|
4213
5873
|
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
4214
5874
|
const next = {
|
|
4215
5875
|
...codex,
|
|
@@ -4227,7 +5887,7 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4227
5887
|
return true;
|
|
4228
5888
|
} catch (error) {
|
|
4229
5889
|
if (await this.tryExternalImport("codex", capturedId, codex, async (rt) => {
|
|
4230
|
-
const r = await import_subscriptions3.codexOAuth.refreshAccessToken(rt,
|
|
5890
|
+
const r = await import_subscriptions3.codexOAuth.refreshAccessToken(rt, refreshFetch);
|
|
4231
5891
|
return {
|
|
4232
5892
|
accessToken: r.accessToken,
|
|
4233
5893
|
refreshToken: r.refreshToken,
|
|
@@ -4257,8 +5917,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4257
5917
|
if (!active || !gemini?.refreshToken) return false;
|
|
4258
5918
|
const capturedId = active.id;
|
|
4259
5919
|
this.materializeMigration(config);
|
|
5920
|
+
const refreshFetch = this.buildRefreshFetch("gemini", capturedId);
|
|
4260
5921
|
try {
|
|
4261
|
-
const result = await import_subscriptions3.geminiOAuth.refreshAccessToken(gemini.refreshToken,
|
|
5922
|
+
const result = await import_subscriptions3.geminiOAuth.refreshAccessToken(gemini.refreshToken, refreshFetch);
|
|
4262
5923
|
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
4263
5924
|
const next = {
|
|
4264
5925
|
...gemini,
|
|
@@ -4292,7 +5953,7 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4292
5953
|
if (!account || !captured?.refreshToken) return false;
|
|
4293
5954
|
this.materializeMigration(config);
|
|
4294
5955
|
try {
|
|
4295
|
-
const refreshed = await this.refreshUpstream(provider, captured.refreshToken);
|
|
5956
|
+
const refreshed = await this.refreshUpstream(provider, captured.refreshToken, id);
|
|
4296
5957
|
const next = {
|
|
4297
5958
|
...captured,
|
|
4298
5959
|
accessToken: refreshed.accessToken,
|
|
@@ -4314,10 +5975,114 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4314
5975
|
}
|
|
4315
5976
|
});
|
|
4316
5977
|
}
|
|
5978
|
+
// ── By-id account-pool surface (subscription-account-scheduling, design D6) ──
|
|
5979
|
+
/**
|
|
5980
|
+
* Resolve a SPECIFIC account's access token by id (design D6). Mirrors each
|
|
5981
|
+
* provider's ACTIVE-getter policy, keyed by id: claude returns the stored token
|
|
5982
|
+
* (refresh is 401-driven, like `getValidClaudeAccessToken`); codex/gemini refresh
|
|
5983
|
+
* a near-expiry token via `refreshAccountById` (like `resolveAccessToken`);
|
|
5984
|
+
* opencodego returns the account's static key. `null` when unknown/expired/
|
|
5985
|
+
* tokenless.
|
|
5986
|
+
*/
|
|
5987
|
+
async getAccessTokenForAccount(providerId, accountId) {
|
|
5988
|
+
const account = getAccountById(this.readConfig(), providerId, accountId);
|
|
5989
|
+
if (!account) return null;
|
|
5990
|
+
if (providerId === "opencodego") {
|
|
5991
|
+
return account.tokens.apiKey ?? null;
|
|
5992
|
+
}
|
|
5993
|
+
const oauth = account.tokens;
|
|
5994
|
+
if (!oauth.accessToken) return null;
|
|
5995
|
+
if (providerId === "codex" || providerId === "gemini") {
|
|
5996
|
+
const expiresAtMs = oauth.expiresAt ? Date.parse(oauth.expiresAt) : 0;
|
|
5997
|
+
const expiringSoon = expiresAtMs > 0 && Date.now() >= expiresAtMs - ACCOUNT_REFRESH_LEAD_MS;
|
|
5998
|
+
if (expiringSoon && oauth.refreshToken) {
|
|
5999
|
+
const ok = await this.refreshAccountById(providerId, accountId);
|
|
6000
|
+
if (!ok) return null;
|
|
6001
|
+
const fresh = getAccountById(this.readConfig(), providerId, accountId);
|
|
6002
|
+
return fresh?.tokens?.accessToken ?? null;
|
|
6003
|
+
}
|
|
6004
|
+
}
|
|
6005
|
+
if (oauth.status === "expired") return null;
|
|
6006
|
+
return oauth.accessToken;
|
|
6007
|
+
}
|
|
6008
|
+
/**
|
|
6009
|
+
* Refresh a SPECIFIC account's OAuth token by id (design D6/D7). Delegates to
|
|
6010
|
+
* `refreshAccountById` (coalesced per `provider:id`); opencodego is a static key
|
|
6011
|
+
* → `false` (no refresh affordance).
|
|
6012
|
+
*/
|
|
6013
|
+
async refreshAccountToken(providerId, accountId) {
|
|
6014
|
+
if (providerId === "opencodego") return false;
|
|
6015
|
+
return this.refreshAccountById(providerId, accountId);
|
|
6016
|
+
}
|
|
6017
|
+
/**
|
|
6018
|
+
* Best-effort record of a selection time onto the account's `lastUsedAt` by id
|
|
6019
|
+
* (design D4). Entry-metadata only (the token mirror is untouched); a no-op for
|
|
6020
|
+
* an unknown id. The selector throttles the call frequency, so this stays cheap.
|
|
6021
|
+
*/
|
|
6022
|
+
async touchAccountLastUsed(providerId, accountId, iso) {
|
|
6023
|
+
const config = this.readConfig();
|
|
6024
|
+
const result = setAccountLastUsed(config, providerId, accountId, iso);
|
|
6025
|
+
if (!result.ok) return;
|
|
6026
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6027
|
+
}
|
|
6028
|
+
/**
|
|
6029
|
+
* Best-effort write-through of a per-account client `identity`
|
|
6030
|
+
* (subscription-client-fingerprint #7, P2). Entry-metadata only (NON-secret
|
|
6031
|
+
* whitelisted fingerprint headers; the token mirror is untouched); a no-op for
|
|
6032
|
+
* an unknown id. Called by the identity store's persistence port on a first-seen
|
|
6033
|
+
* freeze / TTL refresh, so it stays infrequent. Never throws to the caller — the
|
|
6034
|
+
* store's port wrapper swallows a rejection so the relay hot path is unaffected.
|
|
6035
|
+
*/
|
|
6036
|
+
async setAccountIdentity(providerId, accountId, identity) {
|
|
6037
|
+
const config = this.readConfig();
|
|
6038
|
+
const result = setAccountIdentity(config, providerId, accountId, identity);
|
|
6039
|
+
if (!result.ok) return;
|
|
6040
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6041
|
+
}
|
|
6042
|
+
/**
|
|
6043
|
+
* DAEMON-ONLY set-priority (subscription-account-scheduling, admin write, NOT on
|
|
6044
|
+
* the port). Set one account's scheduling `priority` by id. Secret-free
|
|
6045
|
+
* (entry-metadata only; the mirror invariant is untouched). Rejects an unknown id.
|
|
6046
|
+
*/
|
|
6047
|
+
async setAccountPriority(providerId, accountId, priority) {
|
|
6048
|
+
const config = this.readConfig();
|
|
6049
|
+
const result = setAccountPriority(config, providerId, accountId, priority);
|
|
6050
|
+
if (!result.ok) return result;
|
|
6051
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6052
|
+
return result;
|
|
6053
|
+
}
|
|
6054
|
+
/**
|
|
6055
|
+
* DAEMON-ONLY set/clear per-account proxy (upstream-proxy, admin write, NOT on
|
|
6056
|
+
* the port). Passing `undefined` clears the override. Write-only password: when
|
|
6057
|
+
* the incoming structured proxy omits the password but the account already had
|
|
6058
|
+
* one, the current (decrypted) password is preserved — editing host/port never
|
|
6059
|
+
* wipes the secret. Persist re-encrypts `proxy.password` via the tokens SecretBox.
|
|
6060
|
+
*/
|
|
6061
|
+
async setAccountProxy(providerId, accountId, proxy) {
|
|
6062
|
+
const config = this.readConfig();
|
|
6063
|
+
const merged = proxy ? preserveProxyConfigSecret(proxy, getAccountProxy(config, providerId, accountId)) : void 0;
|
|
6064
|
+
const result = setAccountProxy(config, providerId, accountId, merged);
|
|
6065
|
+
if (!result.ok) return result;
|
|
6066
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6067
|
+
return result;
|
|
6068
|
+
}
|
|
6069
|
+
/**
|
|
6070
|
+
* DAEMON-ONLY set/clear per-account `supportedModels` (subscription-account-
|
|
6071
|
+
* model-map, admin write, NOT on the port). Passing `undefined` clears it.
|
|
6072
|
+
* Secret-free (model ids only; the mirror invariant is untouched). Rejects an
|
|
6073
|
+
* unknown id.
|
|
6074
|
+
*/
|
|
6075
|
+
async setAccountSupportedModels(providerId, accountId, supportedModels) {
|
|
6076
|
+
const config = this.readConfig();
|
|
6077
|
+
const result = setAccountSupportedModels(config, providerId, accountId, supportedModels);
|
|
6078
|
+
if (!result.ok) return result;
|
|
6079
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6080
|
+
return result;
|
|
6081
|
+
}
|
|
4317
6082
|
/** Dispatch one OAuth refresh round-trip to the provider's shared flow. */
|
|
4318
|
-
async refreshUpstream(provider, refreshToken) {
|
|
6083
|
+
async refreshUpstream(provider, refreshToken, accountId) {
|
|
4319
6084
|
const flow = provider === "claude" ? import_subscriptions3.claudeOAuth : provider === "codex" ? import_subscriptions3.codexOAuth : import_subscriptions3.geminiOAuth;
|
|
4320
|
-
const r = await flow.refreshAccessToken(refreshToken, this.
|
|
6085
|
+
const r = await flow.refreshAccessToken(refreshToken, this.buildRefreshFetch(provider, accountId));
|
|
4321
6086
|
return {
|
|
4322
6087
|
accessToken: r.accessToken,
|
|
4323
6088
|
refreshToken: r.refreshToken,
|
|
@@ -4455,119 +6220,907 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4455
6220
|
});
|
|
4456
6221
|
}
|
|
4457
6222
|
/**
|
|
4458
|
-
* DAEMON-ONLY WRITE (design D1, NOT on the port). Read-merge the given
|
|
4459
|
-
* provider's token block into the current `AccountTokensConfig`, stamp a fresh
|
|
4460
|
-
* `updatedAt`, and re-persist `tokens.json` as pretty JSON. Preserves every
|
|
4461
|
-
* OTHER provider's existing block (read-merge-write, not overwrite). Reuses the
|
|
4462
|
-
* tolerate-on-read base (`{ updatedAt: '' }` when the file is absent/corrupt),
|
|
4463
|
-
* so a first-ever write still produces a valid config. No cache → the next read
|
|
4464
|
-
* sees this write.
|
|
6223
|
+
* DAEMON-ONLY WRITE (design D1, NOT on the port). Read-merge the given
|
|
6224
|
+
* provider's token block into the current `AccountTokensConfig`, stamp a fresh
|
|
6225
|
+
* `updatedAt`, and re-persist `tokens.json` as pretty JSON. Preserves every
|
|
6226
|
+
* OTHER provider's existing block (read-merge-write, not overwrite). Reuses the
|
|
6227
|
+
* tolerate-on-read base (`{ updatedAt: '' }` when the file is absent/corrupt),
|
|
6228
|
+
* so a first-ever write still produces a valid config. No cache → the next read
|
|
6229
|
+
* sees this write.
|
|
6230
|
+
*/
|
|
6231
|
+
async writeProviderTokens(providerId, config) {
|
|
6232
|
+
const current = this.readConfig();
|
|
6233
|
+
writeActiveTokens(current, providerId, config);
|
|
6234
|
+
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6235
|
+
}
|
|
6236
|
+
/**
|
|
6237
|
+
* DAEMON-ONLY login append (design D5, NOT on the port). Append a NEW account
|
|
6238
|
+
* (optional label) and set it active, then re-derive the mirror — used by
|
|
6239
|
+
* `omnicross login <provider> --label` to add an account instead of overwriting.
|
|
6240
|
+
*/
|
|
6241
|
+
async appendProviderAccount(providerId, config, label) {
|
|
6242
|
+
const current = this.readConfig();
|
|
6243
|
+
const result = addAccount(current, providerId, config, label);
|
|
6244
|
+
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6245
|
+
return result;
|
|
6246
|
+
}
|
|
6247
|
+
/**
|
|
6248
|
+
* DAEMON-ONLY active switch (design D5, NOT on the port). Switch the active
|
|
6249
|
+
* account for a provider; rejects an unknown id. Re-derives the mirror.
|
|
6250
|
+
*/
|
|
6251
|
+
async setActiveAccount(providerId, id) {
|
|
6252
|
+
const current = this.readConfig();
|
|
6253
|
+
const result = setActiveAccount(current, providerId, id);
|
|
6254
|
+
if (!result.ok) return result;
|
|
6255
|
+
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6256
|
+
return result;
|
|
6257
|
+
}
|
|
6258
|
+
/**
|
|
6259
|
+
* DAEMON-ONLY per-account remove (design D5, NOT on the port). Remove one
|
|
6260
|
+
* account; promote the most-recent remaining on active-removal (or clear the
|
|
6261
|
+
* mirror when none remain). Re-derives the mirror.
|
|
6262
|
+
*/
|
|
6263
|
+
async removeAccount(providerId, id) {
|
|
6264
|
+
const current = this.readConfig();
|
|
6265
|
+
const result = removeAccount(current, providerId, id);
|
|
6266
|
+
if (!result.removed) return result;
|
|
6267
|
+
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6268
|
+
return result;
|
|
6269
|
+
}
|
|
6270
|
+
/**
|
|
6271
|
+
* DAEMON-ONLY per-account rename (NOT on the port). Update one account's label;
|
|
6272
|
+
* rejects an unknown id. Label-only — no token material is read or written
|
|
6273
|
+
* (the secret-free invariant holds).
|
|
6274
|
+
*/
|
|
6275
|
+
async renameAccount(providerId, id, label) {
|
|
6276
|
+
const current = this.readConfig();
|
|
6277
|
+
const result = renameAccount(current, providerId, id, label);
|
|
6278
|
+
if (!result.ok) return result;
|
|
6279
|
+
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6280
|
+
return result;
|
|
6281
|
+
}
|
|
6282
|
+
/**
|
|
6283
|
+
* DAEMON-ONLY CLEAR (design D1/D3, NOT on the port). Remove a single provider's
|
|
6284
|
+
* block from `tokens.json` and re-persist (the strategies already tolerate an
|
|
6285
|
+
* absent block). Stamps a fresh `updatedAt`. A no-op-shaped write when the
|
|
6286
|
+
* provider was already absent (still re-stamps + persists).
|
|
6287
|
+
*/
|
|
6288
|
+
async clearProvider(providerId) {
|
|
6289
|
+
const current = this.readConfig();
|
|
6290
|
+
clearProvider(current, providerId);
|
|
6291
|
+
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6292
|
+
}
|
|
6293
|
+
/** Write the merged config to disk as pretty JSON (mkdir parent if needed).
|
|
6294
|
+
* Encrypt-on-write: the token-material fields are encrypted (legacy plaintext
|
|
6295
|
+
* → `enc:v1:`; already-`enc:`/`$ENV` untouched) before serializing, so any
|
|
6296
|
+
* write — incl. child 4's future refresh writes — lands encrypted. */
|
|
6297
|
+
persist(config) {
|
|
6298
|
+
(0, import_node_fs14.mkdirSync)((0, import_node_path7.dirname)(this.tokensPath), { recursive: true });
|
|
6299
|
+
const encrypted = encryptTokens(config, this.box);
|
|
6300
|
+
(0, import_node_fs14.writeFileSync)(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
|
|
6301
|
+
}
|
|
6302
|
+
/**
|
|
6303
|
+
* Read + parse `tokens.json`, tolerating a missing/corrupt file, then DECRYPT
|
|
6304
|
+
* the token-material fields so every getter returns plaintext (the
|
|
6305
|
+
* subscription bearer path is byte-identical).
|
|
6306
|
+
*
|
|
6307
|
+
* The fs-read + JSON-parse tolerance is INSIDE the try (a missing or corrupt
|
|
6308
|
+
* file → empty `{ updatedAt: '' }`). The DECRYPT runs OUTSIDE the try, so a
|
|
6309
|
+
* wrong/missing master key or a tampered `enc:` envelope FAILS FAST with the
|
|
6310
|
+
* box's clear, secret-free error (secrets spec "错误密钥 / 篡改的解密失败 UX":
|
|
6311
|
+
* SHALL fail-fast, SHALL NOT 静默降级 — a swallowed decrypt would report "no
|
|
6312
|
+
* tokens" and silently send the WRONG bearer upstream → 401). Mirrors
|
|
6313
|
+
* `config.ts loadConfig`, which decrypts outside its parse try.
|
|
6314
|
+
*/
|
|
6315
|
+
readConfig() {
|
|
6316
|
+
if (!(0, import_node_fs14.existsSync)(this.tokensPath)) return { updatedAt: "" };
|
|
6317
|
+
let parsed;
|
|
6318
|
+
try {
|
|
6319
|
+
const raw = JSON.parse((0, import_node_fs14.readFileSync)(this.tokensPath, "utf8"));
|
|
6320
|
+
parsed = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
|
|
6321
|
+
} catch {
|
|
6322
|
+
parsed = null;
|
|
6323
|
+
}
|
|
6324
|
+
if (!parsed) return { updatedAt: "" };
|
|
6325
|
+
const decrypted = decryptTokens(parsed, this.box);
|
|
6326
|
+
return migrateLazily(decrypted);
|
|
6327
|
+
}
|
|
6328
|
+
};
|
|
6329
|
+
|
|
6330
|
+
// src/AccountHealthProbeScheduler.ts
|
|
6331
|
+
var import_upstreamFetch4 = require("@omnicross/core/pipeline/upstreamFetch");
|
|
6332
|
+
|
|
6333
|
+
// src/probe/ProbeStrategy.ts
|
|
6334
|
+
var PROVIDER_PROBE_PLANS = {
|
|
6335
|
+
claude: {
|
|
6336
|
+
kind: "upstream",
|
|
6337
|
+
// VERIFIED free authed list endpoint (no tokens billed). The anthropic OAuth
|
|
6338
|
+
// bearer is accepted here exactly as on the relay path.
|
|
6339
|
+
url: "https://api.anthropic.com/v1/models",
|
|
6340
|
+
buildInit: (token) => ({
|
|
6341
|
+
method: "GET",
|
|
6342
|
+
headers: {
|
|
6343
|
+
Authorization: `Bearer ${token}`,
|
|
6344
|
+
"anthropic-version": "2023-06-01"
|
|
6345
|
+
}
|
|
6346
|
+
})
|
|
6347
|
+
},
|
|
6348
|
+
// UNVERIFIED cheap authed GET — Phase 1 local-only (LEAD OQ1: do not guess a
|
|
6349
|
+
// billable/wrong endpoint). Upgrade to `{ kind:'upstream' }` once verified.
|
|
6350
|
+
codex: { kind: "local" },
|
|
6351
|
+
gemini: { kind: "local" },
|
|
6352
|
+
opencodego: { kind: "local" }
|
|
6353
|
+
};
|
|
6354
|
+
function probePlanFor(providerId) {
|
|
6355
|
+
return PROVIDER_PROBE_PLANS[providerId] ?? { kind: "local" };
|
|
6356
|
+
}
|
|
6357
|
+
|
|
6358
|
+
// src/AccountHealthProbeScheduler.ts
|
|
6359
|
+
var KEY_SEP = "\0";
|
|
6360
|
+
var MAX_BODY_SNIFF = 2048;
|
|
6361
|
+
var PROBE_PROVIDERS = [
|
|
6362
|
+
"claude",
|
|
6363
|
+
"codex",
|
|
6364
|
+
"gemini",
|
|
6365
|
+
"opencodego"
|
|
6366
|
+
];
|
|
6367
|
+
var AccountHealthProbeScheduler = class {
|
|
6368
|
+
constructor(store, health2, logger, config, opts = {}) {
|
|
6369
|
+
this.store = store;
|
|
6370
|
+
this.health = health2;
|
|
6371
|
+
this.logger = logger;
|
|
6372
|
+
this.config = config;
|
|
6373
|
+
this.now = opts.now ?? Date.now;
|
|
6374
|
+
this.fetchImpl = opts.fetchImpl ?? import_upstreamFetch4.fetchUpstream;
|
|
6375
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
6376
|
+
this.planFor = opts.planFor ?? probePlanFor;
|
|
6377
|
+
}
|
|
6378
|
+
store;
|
|
6379
|
+
health;
|
|
6380
|
+
logger;
|
|
6381
|
+
config;
|
|
6382
|
+
timer = null;
|
|
6383
|
+
sweeping = false;
|
|
6384
|
+
history = /* @__PURE__ */ new Map();
|
|
6385
|
+
now;
|
|
6386
|
+
fetchImpl;
|
|
6387
|
+
sleep;
|
|
6388
|
+
planFor;
|
|
6389
|
+
/** Whether probing is enabled by the current config. */
|
|
6390
|
+
get enabled() {
|
|
6391
|
+
return this.config.enabled;
|
|
6392
|
+
}
|
|
6393
|
+
/**
|
|
6394
|
+
* Re-apply config to the live instance (the async `start.ts` loads the persisted
|
|
6395
|
+
* `accountProbe` segment after `buildDaemon`). Call BEFORE `start()`.
|
|
6396
|
+
*/
|
|
6397
|
+
configure(config) {
|
|
6398
|
+
this.config = config;
|
|
6399
|
+
}
|
|
6400
|
+
/** Arm the probe interval. No-op when disabled (zero regression). Idempotent. */
|
|
6401
|
+
start() {
|
|
6402
|
+
if (this.timer || !this.config.enabled) return;
|
|
6403
|
+
this.timer = setInterval(() => void this.sweep(), this.config.intervalMs);
|
|
6404
|
+
this.timer.unref?.();
|
|
6405
|
+
}
|
|
6406
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6407
|
+
dispose() {
|
|
6408
|
+
if (this.timer) {
|
|
6409
|
+
clearInterval(this.timer);
|
|
6410
|
+
this.timer = null;
|
|
6411
|
+
}
|
|
6412
|
+
}
|
|
6413
|
+
/**
|
|
6414
|
+
* One sweep: probe every ELIGIBLE account SEQUENTIALLY with a `staggerMs` gap.
|
|
6415
|
+
* Disabled ⇒ no-op. `onlyMultiAccount` skips single-account providers. Exposed
|
|
6416
|
+
* for tests; never throws.
|
|
6417
|
+
*/
|
|
6418
|
+
async sweep() {
|
|
6419
|
+
if (!this.config.enabled || this.sweeping) return;
|
|
6420
|
+
this.sweeping = true;
|
|
6421
|
+
try {
|
|
6422
|
+
const config = await this.store.getFullConfig();
|
|
6423
|
+
let probed = 0;
|
|
6424
|
+
let marked = 0;
|
|
6425
|
+
for (const providerId of PROBE_PROVIDERS) {
|
|
6426
|
+
const accounts = listAccounts(config, providerId);
|
|
6427
|
+
if (this.config.onlyMultiAccount && accounts.length < 2) continue;
|
|
6428
|
+
for (const account of accounts) {
|
|
6429
|
+
if (probed > 0 && this.config.staggerMs > 0) await this.sleep(this.config.staggerMs);
|
|
6430
|
+
const outcome = await this.probeAccount(providerId, account.id);
|
|
6431
|
+
probed += 1;
|
|
6432
|
+
if (outcome.marked) marked += 1;
|
|
6433
|
+
}
|
|
6434
|
+
}
|
|
6435
|
+
this.logger.debug("account-probe sweep complete", { probed, marked });
|
|
6436
|
+
} catch (error) {
|
|
6437
|
+
this.logger.warn("account-probe sweep failed", {
|
|
6438
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6439
|
+
});
|
|
6440
|
+
} finally {
|
|
6441
|
+
this.sweeping = false;
|
|
6442
|
+
}
|
|
6443
|
+
}
|
|
6444
|
+
/**
|
|
6445
|
+
* Probe ONE account (design D1). Local tier first (dead token → synthesized 401,
|
|
6446
|
+
* no upstream); else the upstream tier when a verified endpoint exists. Records
|
|
6447
|
+
* the rolling history entry either way; returns whether the tracker was MARKED.
|
|
6448
|
+
*/
|
|
6449
|
+
async probeAccount(providerId, accountId) {
|
|
6450
|
+
const now = this.now();
|
|
6451
|
+
let token = null;
|
|
6452
|
+
let readThrew = false;
|
|
6453
|
+
try {
|
|
6454
|
+
token = await this.store.getAccessTokenForAccount(providerId, accountId);
|
|
6455
|
+
} catch {
|
|
6456
|
+
readThrew = true;
|
|
6457
|
+
}
|
|
6458
|
+
if (readThrew) {
|
|
6459
|
+
this.record(providerId, accountId, { ts: now, ok: false, status: null, tier: "local" });
|
|
6460
|
+
return { ok: false, marked: false };
|
|
6461
|
+
}
|
|
6462
|
+
if (!token) {
|
|
6463
|
+
this.health.recordUpstreamOutcome(providerId, accountId, { status: 401, now });
|
|
6464
|
+
this.record(providerId, accountId, { ts: now, ok: false, status: 401, tier: "local" });
|
|
6465
|
+
return { ok: false, marked: true };
|
|
6466
|
+
}
|
|
6467
|
+
const plan = this.planFor(providerId);
|
|
6468
|
+
if (plan.kind === "local") {
|
|
6469
|
+
this.record(providerId, accountId, { ts: now, ok: true, tier: "local" });
|
|
6470
|
+
return { ok: true, marked: false };
|
|
6471
|
+
}
|
|
6472
|
+
const start = this.now();
|
|
6473
|
+
let status = null;
|
|
6474
|
+
let bodyText;
|
|
6475
|
+
try {
|
|
6476
|
+
const res = await this.fetchImpl(
|
|
6477
|
+
plan.url,
|
|
6478
|
+
{ ...plan.buildInit(token), signal: AbortSignal.timeout(this.config.timeoutMs) },
|
|
6479
|
+
{ providerId, accountId }
|
|
6480
|
+
);
|
|
6481
|
+
status = res.status;
|
|
6482
|
+
if (status === 403) bodyText = await this.readBounded(res);
|
|
6483
|
+
} catch {
|
|
6484
|
+
status = null;
|
|
6485
|
+
}
|
|
6486
|
+
const latencyMs = this.now() - start;
|
|
6487
|
+
const marked = this.applyOutcome(providerId, accountId, status, bodyText, now);
|
|
6488
|
+
this.record(providerId, accountId, {
|
|
6489
|
+
ts: now,
|
|
6490
|
+
ok: status !== null && status >= 200 && status < 300,
|
|
6491
|
+
status,
|
|
6492
|
+
latencyMs,
|
|
6493
|
+
tier: "upstream"
|
|
6494
|
+
});
|
|
6495
|
+
return { ok: status !== null && status < 400, marked };
|
|
6496
|
+
}
|
|
6497
|
+
/** Per-account rolling history for the authed admin surface (design D5). */
|
|
6498
|
+
getAllHistory() {
|
|
6499
|
+
const out = [];
|
|
6500
|
+
for (const [key, records] of this.history) {
|
|
6501
|
+
const [providerId, accountId] = this.parseKey(key);
|
|
6502
|
+
out.push({ providerId, accountId, records: records.slice() });
|
|
6503
|
+
}
|
|
6504
|
+
return out;
|
|
6505
|
+
}
|
|
6506
|
+
/**
|
|
6507
|
+
* The coarse, account-ANONYMOUS `/health` signal (design D5): `true` when no
|
|
6508
|
+
* probed account is currently unhealthy (per #2's tracker). No ids, no counts —
|
|
6509
|
+
* safe for the unauthenticated `/health`. Vacuously `true` when nothing probed.
|
|
6510
|
+
*/
|
|
6511
|
+
probedAccountsHealthy(now = this.now()) {
|
|
6512
|
+
for (const key of this.history.keys()) {
|
|
6513
|
+
const [providerId, accountId] = this.parseKey(key);
|
|
6514
|
+
if (!this.health.isSchedulable(providerId, accountId, now)) return false;
|
|
6515
|
+
}
|
|
6516
|
+
return true;
|
|
6517
|
+
}
|
|
6518
|
+
/**
|
|
6519
|
+
* Feed ONLY the account/auth-decisive statuses to #2 (LEAD constraint):
|
|
6520
|
+
* 401/403 → mark; 2xx → clear transient; 429 / 5xx / other 4xx / null →
|
|
6521
|
+
* NOT forwarded (never a mark). Returns whether a NEGATIVE mark was applied.
|
|
6522
|
+
*/
|
|
6523
|
+
applyOutcome(providerId, accountId, status, bodyText, now) {
|
|
6524
|
+
if (status === null) return false;
|
|
6525
|
+
if (status === 401 || status === 403) {
|
|
6526
|
+
this.health.recordUpstreamOutcome(providerId, accountId, { status, bodyText, now });
|
|
6527
|
+
return true;
|
|
6528
|
+
}
|
|
6529
|
+
if (status >= 200 && status < 300) {
|
|
6530
|
+
this.health.clearTransientMark(providerId, accountId);
|
|
6531
|
+
return false;
|
|
6532
|
+
}
|
|
6533
|
+
return false;
|
|
6534
|
+
}
|
|
6535
|
+
/** Append a record, capping the ring at `historySize` (drop oldest). */
|
|
6536
|
+
record(providerId, accountId, rec) {
|
|
6537
|
+
const key = this.key(providerId, accountId);
|
|
6538
|
+
const list = this.history.get(key) ?? [];
|
|
6539
|
+
list.push(rec);
|
|
6540
|
+
const overflow = list.length - this.config.historySize;
|
|
6541
|
+
if (overflow > 0) list.splice(0, overflow);
|
|
6542
|
+
this.history.set(key, list);
|
|
6543
|
+
}
|
|
6544
|
+
/** Read a bounded slice of the response body for the 403-ban sniff (never throws). */
|
|
6545
|
+
async readBounded(res) {
|
|
6546
|
+
try {
|
|
6547
|
+
return (await res.text()).slice(0, MAX_BODY_SNIFF);
|
|
6548
|
+
} catch {
|
|
6549
|
+
return "";
|
|
6550
|
+
}
|
|
6551
|
+
}
|
|
6552
|
+
key(providerId, accountId) {
|
|
6553
|
+
return `${providerId}${KEY_SEP}${accountId}`;
|
|
6554
|
+
}
|
|
6555
|
+
parseKey(key) {
|
|
6556
|
+
const idx = key.indexOf(KEY_SEP);
|
|
6557
|
+
return [key.slice(0, idx), key.slice(idx + 1)];
|
|
6558
|
+
}
|
|
6559
|
+
};
|
|
6560
|
+
|
|
6561
|
+
// src/AccountHealthSweeper.ts
|
|
6562
|
+
var REFRESH_LEAD_MS = 5 * 6e4;
|
|
6563
|
+
var SWEEP_INTERVAL_MS = 6e4;
|
|
6564
|
+
var OAUTH_PROVIDERS = ["claude", "codex", "gemini"];
|
|
6565
|
+
function isOAuthProvider(providerId) {
|
|
6566
|
+
return OAUTH_PROVIDERS.includes(providerId);
|
|
6567
|
+
}
|
|
6568
|
+
var AccountHealthSweeper = class {
|
|
6569
|
+
constructor(store, health2, logger, intervalMs = SWEEP_INTERVAL_MS, leadMs = REFRESH_LEAD_MS) {
|
|
6570
|
+
this.store = store;
|
|
6571
|
+
this.health = health2;
|
|
6572
|
+
this.logger = logger;
|
|
6573
|
+
this.intervalMs = intervalMs;
|
|
6574
|
+
this.leadMs = leadMs;
|
|
6575
|
+
}
|
|
6576
|
+
store;
|
|
6577
|
+
health;
|
|
6578
|
+
logger;
|
|
6579
|
+
intervalMs;
|
|
6580
|
+
leadMs;
|
|
6581
|
+
timer = null;
|
|
6582
|
+
sweeping = false;
|
|
6583
|
+
/** Arm the sweep interval. Idempotent. The timer never holds the loop open. */
|
|
6584
|
+
start() {
|
|
6585
|
+
if (this.timer) return;
|
|
6586
|
+
this.timer = setInterval(() => void this.sweep(), this.intervalMs);
|
|
6587
|
+
this.timer.unref?.();
|
|
6588
|
+
}
|
|
6589
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6590
|
+
dispose() {
|
|
6591
|
+
if (this.timer) {
|
|
6592
|
+
clearInterval(this.timer);
|
|
6593
|
+
this.timer = null;
|
|
6594
|
+
}
|
|
6595
|
+
}
|
|
6596
|
+
/**
|
|
6597
|
+
* One sweep: surface accounts that just recovered (emits the recovery signal
|
|
6598
|
+
* through the tracker's hook) and nudge a fresh token for any recovered OAuth
|
|
6599
|
+
* account whose token is near expiry. Exposed for tests. Never throws.
|
|
6600
|
+
*/
|
|
6601
|
+
async sweep(now = Date.now()) {
|
|
6602
|
+
if (this.sweeping) return;
|
|
6603
|
+
this.sweeping = true;
|
|
6604
|
+
try {
|
|
6605
|
+
const recovered = this.health.sweepRecoveries(now);
|
|
6606
|
+
if (recovered.length === 0) return;
|
|
6607
|
+
const config = await this.store.getFullConfig();
|
|
6608
|
+
for (const event of recovered) {
|
|
6609
|
+
if (!isOAuthProvider(event.providerId)) continue;
|
|
6610
|
+
const account = getAccountById(config, event.providerId, event.accountId);
|
|
6611
|
+
if (!account || !this.needsRefresh(account.tokens, now)) continue;
|
|
6612
|
+
await this.refreshOne(event.providerId, event.accountId);
|
|
6613
|
+
}
|
|
6614
|
+
} catch (error) {
|
|
6615
|
+
this.logger.warn("account-health sweep failed", {
|
|
6616
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6617
|
+
});
|
|
6618
|
+
} finally {
|
|
6619
|
+
this.sweeping = false;
|
|
6620
|
+
}
|
|
6621
|
+
}
|
|
6622
|
+
/** Expiring within the lead window, refreshable, and not already dead. */
|
|
6623
|
+
needsRefresh(tokens, now) {
|
|
6624
|
+
const t = tokens;
|
|
6625
|
+
if (!t.refreshToken || t.status === "expired" || t.status === "error") return false;
|
|
6626
|
+
if (!t.expiresAt) return false;
|
|
6627
|
+
const expiresAt = Date.parse(t.expiresAt);
|
|
6628
|
+
return Number.isFinite(expiresAt) && now >= expiresAt - this.leadMs;
|
|
6629
|
+
}
|
|
6630
|
+
/** Refresh one recovered account by id; failures are logged, never thrown. */
|
|
6631
|
+
async refreshOne(provider, id) {
|
|
6632
|
+
try {
|
|
6633
|
+
const ok = await this.store.refreshAccountById(provider, id);
|
|
6634
|
+
if (ok) this.logger.info("account-health recovery refresh succeeded", { provider, accountId: id });
|
|
6635
|
+
else this.logger.warn("account-health recovery refresh failed", { provider, accountId: id });
|
|
6636
|
+
} catch (error) {
|
|
6637
|
+
this.logger.warn("account-health recovery refresh threw", {
|
|
6638
|
+
provider,
|
|
6639
|
+
accountId: id,
|
|
6640
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6641
|
+
});
|
|
6642
|
+
}
|
|
6643
|
+
}
|
|
6644
|
+
};
|
|
6645
|
+
|
|
6646
|
+
// src/audit/AuditPruneSweeper.ts
|
|
6647
|
+
var import_node_fs15 = require("fs");
|
|
6648
|
+
var import_node_path8 = require("path");
|
|
6649
|
+
|
|
6650
|
+
// src/audit/auditFiles.ts
|
|
6651
|
+
var AUDIT_FILE_RE = /^audit-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
6652
|
+
var pad22 = (n) => String(n).padStart(2, "0");
|
|
6653
|
+
function auditFileName(ts) {
|
|
6654
|
+
const d = new Date(ts);
|
|
6655
|
+
return `audit-${d.getFullYear()}-${pad22(d.getMonth() + 1)}-${pad22(d.getDate())}.jsonl`;
|
|
6656
|
+
}
|
|
6657
|
+
function auditFileDateMs(fileName) {
|
|
6658
|
+
const m = AUDIT_FILE_RE.exec(fileName);
|
|
6659
|
+
if (!m) return null;
|
|
6660
|
+
const year = Number(m[1]);
|
|
6661
|
+
const month = Number(m[2]);
|
|
6662
|
+
const day = Number(m[3]);
|
|
6663
|
+
const d = new Date(year, month - 1, day);
|
|
6664
|
+
if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) {
|
|
6665
|
+
return null;
|
|
6666
|
+
}
|
|
6667
|
+
return d.getTime();
|
|
6668
|
+
}
|
|
6669
|
+
|
|
6670
|
+
// src/audit/AuditPruneSweeper.ts
|
|
6671
|
+
var DAY_MS = 24 * 60 * 6e4;
|
|
6672
|
+
var SWEEP_INTERVAL_MS2 = 60 * 6e4;
|
|
6673
|
+
var AuditPruneSweeper = class {
|
|
6674
|
+
constructor(auditDir, logger, config, intervalMs = SWEEP_INTERVAL_MS2, now = Date.now) {
|
|
6675
|
+
this.auditDir = auditDir;
|
|
6676
|
+
this.logger = logger;
|
|
6677
|
+
this.config = config;
|
|
6678
|
+
this.intervalMs = intervalMs;
|
|
6679
|
+
this.now = now;
|
|
6680
|
+
}
|
|
6681
|
+
auditDir;
|
|
6682
|
+
logger;
|
|
6683
|
+
config;
|
|
6684
|
+
intervalMs;
|
|
6685
|
+
now;
|
|
6686
|
+
timer = null;
|
|
6687
|
+
sweeping = false;
|
|
6688
|
+
/** Whether pruning is active (audit enabled). */
|
|
6689
|
+
get enabled() {
|
|
6690
|
+
return this.config.enabled;
|
|
6691
|
+
}
|
|
6692
|
+
/** Re-apply config to the live instance (boot + admin PUT hot-reload). */
|
|
6693
|
+
configure(config) {
|
|
6694
|
+
this.config = config;
|
|
6695
|
+
}
|
|
6696
|
+
/**
|
|
6697
|
+
* Arm the prune interval AND run one prune immediately (boot cleanup). No-op
|
|
6698
|
+
* when audit is disabled (zero regression). Idempotent.
|
|
6699
|
+
*/
|
|
6700
|
+
start() {
|
|
6701
|
+
if (this.timer || !this.config.enabled) return;
|
|
6702
|
+
void this.sweep();
|
|
6703
|
+
this.timer = setInterval(() => void this.sweep(), this.intervalMs);
|
|
6704
|
+
this.timer.unref?.();
|
|
6705
|
+
}
|
|
6706
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6707
|
+
dispose() {
|
|
6708
|
+
if (this.timer) {
|
|
6709
|
+
clearInterval(this.timer);
|
|
6710
|
+
this.timer = null;
|
|
6711
|
+
}
|
|
6712
|
+
}
|
|
6713
|
+
/**
|
|
6714
|
+
* One prune: unlink every audit date file strictly OLDER than the retention
|
|
6715
|
+
* cutoff (`now - retentionDays` days, at local-midnight granularity). Exposed
|
|
6716
|
+
* for tests; never throws. Returns the number of files removed.
|
|
6717
|
+
*/
|
|
6718
|
+
async sweep() {
|
|
6719
|
+
if (!this.config.enabled || this.sweeping) return 0;
|
|
6720
|
+
this.sweeping = true;
|
|
6721
|
+
try {
|
|
6722
|
+
if (!(0, import_node_fs15.existsSync)(this.auditDir)) return 0;
|
|
6723
|
+
const today = new Date(this.now());
|
|
6724
|
+
const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
|
|
6725
|
+
const cutoff = todayMidnight - (this.config.retentionDays - 1) * DAY_MS;
|
|
6726
|
+
let removed = 0;
|
|
6727
|
+
for (const file of (0, import_node_fs15.readdirSync)(this.auditDir)) {
|
|
6728
|
+
const dateMs = auditFileDateMs(file);
|
|
6729
|
+
if (dateMs === null || dateMs >= cutoff) continue;
|
|
6730
|
+
try {
|
|
6731
|
+
(0, import_node_fs15.unlinkSync)((0, import_node_path8.join)(this.auditDir, file));
|
|
6732
|
+
removed += 1;
|
|
6733
|
+
} catch (error) {
|
|
6734
|
+
this.logger.warn("[AuditPruneSweeper] failed to unlink expired audit file", {
|
|
6735
|
+
file,
|
|
6736
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6737
|
+
});
|
|
6738
|
+
}
|
|
6739
|
+
}
|
|
6740
|
+
if (removed > 0) this.logger.debug("audit prune complete", { removed });
|
|
6741
|
+
return removed;
|
|
6742
|
+
} catch (error) {
|
|
6743
|
+
this.logger.warn("audit prune sweep failed", {
|
|
6744
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6745
|
+
});
|
|
6746
|
+
return 0;
|
|
6747
|
+
} finally {
|
|
6748
|
+
this.sweeping = false;
|
|
6749
|
+
}
|
|
6750
|
+
}
|
|
6751
|
+
};
|
|
6752
|
+
|
|
6753
|
+
// src/audit/auditReader.ts
|
|
6754
|
+
var import_node_fs16 = require("fs");
|
|
6755
|
+
var import_node_path9 = require("path");
|
|
6756
|
+
var DEFAULT_LIMIT = 200;
|
|
6757
|
+
var MAX_LIMIT = 2e3;
|
|
6758
|
+
function readAuditRecords(auditDir, query = {}) {
|
|
6759
|
+
if (!(0, import_node_fs16.existsSync)(auditDir)) return [];
|
|
6760
|
+
let files;
|
|
6761
|
+
try {
|
|
6762
|
+
files = (0, import_node_fs16.readdirSync)(auditDir).filter((f) => AUDIT_FILE_RE.test(f));
|
|
6763
|
+
} catch {
|
|
6764
|
+
return [];
|
|
6765
|
+
}
|
|
6766
|
+
const from = typeof query.from === "number" ? query.from : -Infinity;
|
|
6767
|
+
const to = typeof query.to === "number" ? query.to : Infinity;
|
|
6768
|
+
const limit = Math.min(MAX_LIMIT, Math.max(1, Math.trunc(query.limit ?? DEFAULT_LIMIT)));
|
|
6769
|
+
const matched = [];
|
|
6770
|
+
for (const file of files.sort().reverse()) {
|
|
6771
|
+
let raw;
|
|
6772
|
+
try {
|
|
6773
|
+
raw = (0, import_node_fs16.readFileSync)((0, import_node_path9.join)(auditDir, file), "utf8");
|
|
6774
|
+
} catch {
|
|
6775
|
+
continue;
|
|
6776
|
+
}
|
|
6777
|
+
for (const line of raw.split("\n")) {
|
|
6778
|
+
const trimmed = line.trim();
|
|
6779
|
+
if (!trimmed) continue;
|
|
6780
|
+
let rec;
|
|
6781
|
+
try {
|
|
6782
|
+
rec = JSON.parse(trimmed);
|
|
6783
|
+
} catch {
|
|
6784
|
+
continue;
|
|
6785
|
+
}
|
|
6786
|
+
if (!isAuditRecord(rec)) continue;
|
|
6787
|
+
if (query.keyId !== void 0 && rec.keyId !== query.keyId) continue;
|
|
6788
|
+
if (rec.ts < from || rec.ts > to) continue;
|
|
6789
|
+
matched.push(rec);
|
|
6790
|
+
}
|
|
6791
|
+
}
|
|
6792
|
+
matched.sort((a, b) => b.ts - a.ts);
|
|
6793
|
+
return matched.slice(0, limit);
|
|
6794
|
+
}
|
|
6795
|
+
function isAuditRecord(value) {
|
|
6796
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
6797
|
+
const r = value;
|
|
6798
|
+
return typeof r["id"] === "string" && typeof r["ts"] === "number" && typeof r["method"] === "string" && typeof r["path"] === "string" && typeof r["status"] === "number";
|
|
6799
|
+
}
|
|
6800
|
+
|
|
6801
|
+
// src/audit/AuditWriter.ts
|
|
6802
|
+
var import_node_fs17 = require("fs");
|
|
6803
|
+
var import_node_path10 = require("path");
|
|
6804
|
+
var AuditWriter = class {
|
|
6805
|
+
constructor(auditDir, logger, defer = (fn) => setTimeout(fn, 0)) {
|
|
6806
|
+
this.auditDir = auditDir;
|
|
6807
|
+
this.logger = logger;
|
|
6808
|
+
this.defer = defer;
|
|
6809
|
+
}
|
|
6810
|
+
auditDir;
|
|
6811
|
+
logger;
|
|
6812
|
+
defer;
|
|
6813
|
+
dirEnsured = false;
|
|
6814
|
+
/**
|
|
6815
|
+
* Enqueue one record for append. Returns IMMEDIATELY (fire-and-forget); the fs
|
|
6816
|
+
* write happens on the deferred tick. A failure is logged, never thrown.
|
|
6817
|
+
*/
|
|
6818
|
+
record(record) {
|
|
6819
|
+
this.defer(() => {
|
|
6820
|
+
try {
|
|
6821
|
+
this.appendNow(record);
|
|
6822
|
+
} catch (error) {
|
|
6823
|
+
this.logger.warn("[AuditWriter] failed to append audit record", {
|
|
6824
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6825
|
+
});
|
|
6826
|
+
}
|
|
6827
|
+
});
|
|
6828
|
+
}
|
|
6829
|
+
/**
|
|
6830
|
+
* Append synchronously — the awaitable form tests use to assert the line landed.
|
|
6831
|
+
* Ensures the `audit/` directory exists on first write (lazy, like the usage
|
|
6832
|
+
* store's lazy file creation).
|
|
4465
6833
|
*/
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
6834
|
+
appendNow(record) {
|
|
6835
|
+
if (!this.dirEnsured) {
|
|
6836
|
+
(0, import_node_fs17.mkdirSync)(this.auditDir, { recursive: true });
|
|
6837
|
+
this.dirEnsured = true;
|
|
6838
|
+
}
|
|
6839
|
+
const file = (0, import_node_path10.join)(this.auditDir, auditFileName(record.ts));
|
|
6840
|
+
(0, import_node_fs17.appendFileSync)(file, JSON.stringify(record) + "\n", "utf8");
|
|
6841
|
+
}
|
|
6842
|
+
};
|
|
6843
|
+
|
|
6844
|
+
// src/billing/BillingPublisher.ts
|
|
6845
|
+
var import_node_fs18 = require("fs");
|
|
6846
|
+
var import_node_crypto10 = require("crypto");
|
|
6847
|
+
var import_node_path11 = require("path");
|
|
6848
|
+
var import_upstreamFetch5 = require("@omnicross/core/pipeline/upstreamFetch");
|
|
6849
|
+
|
|
6850
|
+
// src/billing/billingFiles.ts
|
|
6851
|
+
var BILLING_FILE_RE = /^billing-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
6852
|
+
var DELIVERED_FILE_RE = /^delivered-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
6853
|
+
var pad23 = (n) => String(n).padStart(2, "0");
|
|
6854
|
+
function dateStamp(ts) {
|
|
6855
|
+
const d = new Date(ts);
|
|
6856
|
+
return `${d.getFullYear()}-${pad23(d.getMonth() + 1)}-${pad23(d.getDate())}`;
|
|
6857
|
+
}
|
|
6858
|
+
function billingFileName(ts) {
|
|
6859
|
+
return `billing-${dateStamp(ts)}.jsonl`;
|
|
6860
|
+
}
|
|
6861
|
+
function deliveredFileName(ts) {
|
|
6862
|
+
return `delivered-${dateStamp(ts)}.jsonl`;
|
|
6863
|
+
}
|
|
6864
|
+
|
|
6865
|
+
// src/billing/BillingPublisher.ts
|
|
6866
|
+
var BILLING_POST_TIMEOUT_MS = 1e4;
|
|
6867
|
+
var BillingPublisher = class {
|
|
6868
|
+
constructor(billingDir, logger, opts = {}) {
|
|
6869
|
+
this.billingDir = billingDir;
|
|
6870
|
+
this.logger = logger;
|
|
6871
|
+
this.fetchImpl = opts.fetchImpl ?? ((url, init) => (0, import_upstreamFetch5.fetchUpstream)(url, init));
|
|
6872
|
+
this.defer = opts.defer ?? ((fn) => setTimeout(fn, 0));
|
|
6873
|
+
this.timeoutMs = opts.timeoutMs ?? BILLING_POST_TIMEOUT_MS;
|
|
6874
|
+
this.now = opts.now ?? Date.now;
|
|
6875
|
+
}
|
|
6876
|
+
billingDir;
|
|
6877
|
+
logger;
|
|
6878
|
+
config;
|
|
6879
|
+
dirEnsured = false;
|
|
6880
|
+
fetchImpl;
|
|
6881
|
+
defer;
|
|
6882
|
+
timeoutMs;
|
|
6883
|
+
now;
|
|
6884
|
+
/** Install/replace the live billing config (endpoint + secret + retry bound). */
|
|
6885
|
+
setConfig(config) {
|
|
6886
|
+
this.config = config;
|
|
4470
6887
|
}
|
|
4471
6888
|
/**
|
|
4472
|
-
*
|
|
4473
|
-
*
|
|
4474
|
-
*
|
|
6889
|
+
* Record one billing event. DURABLE-FIRST: append synchronously (the event is
|
|
6890
|
+
* now on disk, never lost), THEN schedule a best-effort POST off the caller's
|
|
6891
|
+
* stack (non-blocking; ledger-only when no endpoint). Returns IMMEDIATELY and
|
|
6892
|
+
* NEVER throws — a failing append/POST is logged, never propagated.
|
|
4475
6893
|
*/
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
6894
|
+
record(event) {
|
|
6895
|
+
let appended = false;
|
|
6896
|
+
try {
|
|
6897
|
+
this.appendNow(event);
|
|
6898
|
+
appended = true;
|
|
6899
|
+
} catch (error) {
|
|
6900
|
+
this.logger.warn("[BillingPublisher] failed to append billing event", {
|
|
6901
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6902
|
+
});
|
|
6903
|
+
}
|
|
6904
|
+
if (appended && this.config?.endpoint) {
|
|
6905
|
+
this.defer(() => {
|
|
6906
|
+
void this.deliverNow(event).catch(() => {
|
|
6907
|
+
});
|
|
6908
|
+
});
|
|
6909
|
+
}
|
|
4481
6910
|
}
|
|
4482
6911
|
/**
|
|
4483
|
-
*
|
|
4484
|
-
*
|
|
6912
|
+
* Append the event as one JSON line to `billing-YYYY-MM-DD.jsonl` (the event's
|
|
6913
|
+
* LOCAL date). Synchronous — the awaitable form tests use to assert the ledger
|
|
6914
|
+
* line landed BEFORE any delivery. Ensures the `billing/` directory on first write.
|
|
4485
6915
|
*/
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
const
|
|
4489
|
-
|
|
4490
|
-
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
4491
|
-
return result;
|
|
6916
|
+
appendNow(event) {
|
|
6917
|
+
this.ensureDir();
|
|
6918
|
+
const file = (0, import_node_path11.join)(this.billingDir, billingFileName(event.ts));
|
|
6919
|
+
(0, import_node_fs18.appendFileSync)(file, JSON.stringify(event) + "\n", "utf8");
|
|
4492
6920
|
}
|
|
4493
6921
|
/**
|
|
4494
|
-
*
|
|
4495
|
-
*
|
|
4496
|
-
*
|
|
6922
|
+
* One best-effort delivery attempt for an event ALREADY in the ledger. POSTs the
|
|
6923
|
+
* event JSON to the configured endpoint (optionally HMAC-signed); on a 2xx ack
|
|
6924
|
+
* appends a delivery marker and returns `true`. Any non-2xx / thrown / timed-out
|
|
6925
|
+
* attempt returns `false` — the event stays UNdelivered in the ledger (never
|
|
6926
|
+
* lost). NEVER rejects. A no-op `false` when no endpoint is configured.
|
|
4497
6927
|
*/
|
|
4498
|
-
async
|
|
4499
|
-
const
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
6928
|
+
async deliverNow(event) {
|
|
6929
|
+
const endpoint = this.config?.endpoint;
|
|
6930
|
+
if (!endpoint) return false;
|
|
6931
|
+
try {
|
|
6932
|
+
const body = JSON.stringify(event);
|
|
6933
|
+
const headers = { "Content-Type": "application/json" };
|
|
6934
|
+
const secret = this.config?.secret;
|
|
6935
|
+
if (secret) {
|
|
6936
|
+
const hmac = (0, import_node_crypto10.createHmac)("sha256", secret).update(body).digest("hex");
|
|
6937
|
+
headers["X-Omnicross-Billing-Signature"] = `sha256=${hmac}`;
|
|
6938
|
+
}
|
|
6939
|
+
const res = await this.fetchImpl(endpoint, {
|
|
6940
|
+
method: "POST",
|
|
6941
|
+
headers,
|
|
6942
|
+
body,
|
|
6943
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
6944
|
+
});
|
|
6945
|
+
if (!res.ok) {
|
|
6946
|
+
this.logger.debug(`[billing] delivery failed ${event.id} (HTTP ${res.status})`);
|
|
6947
|
+
return false;
|
|
6948
|
+
}
|
|
6949
|
+
this.markDelivered(event);
|
|
6950
|
+
this.logger.debug(`[billing] delivered ${event.id}`);
|
|
6951
|
+
return true;
|
|
6952
|
+
} catch (error) {
|
|
6953
|
+
this.logger.debug(
|
|
6954
|
+
`[billing] delivery error ${event.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
6955
|
+
);
|
|
6956
|
+
return false;
|
|
6957
|
+
}
|
|
4504
6958
|
}
|
|
4505
6959
|
/**
|
|
4506
|
-
*
|
|
4507
|
-
*
|
|
4508
|
-
*
|
|
6960
|
+
* Append a delivery marker `{ id, deliveredAt }` to `delivered-YYYY-MM-DD.jsonl`
|
|
6961
|
+
* (keyed by the EVENT's date so the reader finds both together). Idempotent at
|
|
6962
|
+
* the reconciliation layer — the reader unions marker ids into a delivered set,
|
|
6963
|
+
* so a duplicate marker is harmless. A marker-write failure is logged, never thrown.
|
|
4509
6964
|
*/
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
6965
|
+
markDelivered(event) {
|
|
6966
|
+
try {
|
|
6967
|
+
this.ensureDir();
|
|
6968
|
+
const file = (0, import_node_path11.join)(this.billingDir, deliveredFileName(event.ts));
|
|
6969
|
+
(0, import_node_fs18.appendFileSync)(file, JSON.stringify({ id: event.id, deliveredAt: this.now() }) + "\n", "utf8");
|
|
6970
|
+
} catch (error) {
|
|
6971
|
+
this.logger.warn("[BillingPublisher] failed to append delivery marker", {
|
|
6972
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6973
|
+
});
|
|
6974
|
+
}
|
|
6975
|
+
}
|
|
6976
|
+
ensureDir() {
|
|
6977
|
+
if (this.dirEnsured) return;
|
|
6978
|
+
(0, import_node_fs18.mkdirSync)(this.billingDir, { recursive: true });
|
|
6979
|
+
this.dirEnsured = true;
|
|
6980
|
+
}
|
|
6981
|
+
};
|
|
6982
|
+
|
|
6983
|
+
// src/billing/billingReader.ts
|
|
6984
|
+
var import_node_fs19 = require("fs");
|
|
6985
|
+
var import_node_path12 = require("path");
|
|
6986
|
+
function readBillingLedger(billingDir) {
|
|
6987
|
+
const view = { events: [], deliveredIds: /* @__PURE__ */ new Set() };
|
|
6988
|
+
if (!(0, import_node_fs19.existsSync)(billingDir)) return view;
|
|
6989
|
+
let files;
|
|
6990
|
+
try {
|
|
6991
|
+
files = (0, import_node_fs19.readdirSync)(billingDir);
|
|
6992
|
+
} catch {
|
|
6993
|
+
return view;
|
|
6994
|
+
}
|
|
6995
|
+
for (const file of files.sort()) {
|
|
6996
|
+
if (BILLING_FILE_RE.test(file)) {
|
|
6997
|
+
for (const rec of parseLines(billingDir, file)) {
|
|
6998
|
+
if (isBillingEvent(rec)) view.events.push(rec);
|
|
6999
|
+
}
|
|
7000
|
+
} else if (DELIVERED_FILE_RE.test(file)) {
|
|
7001
|
+
for (const rec of parseLines(billingDir, file)) {
|
|
7002
|
+
const id = rec.id;
|
|
7003
|
+
if (typeof id === "string") view.deliveredIds.add(id);
|
|
7004
|
+
}
|
|
7005
|
+
}
|
|
7006
|
+
}
|
|
7007
|
+
return view;
|
|
7008
|
+
}
|
|
7009
|
+
function readUndeliveredEvents(billingDir) {
|
|
7010
|
+
const { events, deliveredIds } = readBillingLedger(billingDir);
|
|
7011
|
+
return events.filter((e) => !deliveredIds.has(e.id)).sort((a, b) => a.ts - b.ts);
|
|
7012
|
+
}
|
|
7013
|
+
function readBillingStatus(billingDir) {
|
|
7014
|
+
const { events, deliveredIds } = readBillingLedger(billingDir);
|
|
7015
|
+
let delivered = 0;
|
|
7016
|
+
for (const e of events) if (deliveredIds.has(e.id)) delivered += 1;
|
|
7017
|
+
return { total: events.length, delivered, pending: events.length - delivered };
|
|
7018
|
+
}
|
|
7019
|
+
function parseLines(dir, file) {
|
|
7020
|
+
let raw;
|
|
7021
|
+
try {
|
|
7022
|
+
raw = (0, import_node_fs19.readFileSync)((0, import_node_path12.join)(dir, file), "utf8");
|
|
7023
|
+
} catch {
|
|
7024
|
+
return [];
|
|
7025
|
+
}
|
|
7026
|
+
const out = [];
|
|
7027
|
+
for (const line of raw.split("\n")) {
|
|
7028
|
+
const trimmed = line.trim();
|
|
7029
|
+
if (!trimmed) continue;
|
|
7030
|
+
try {
|
|
7031
|
+
out.push(JSON.parse(trimmed));
|
|
7032
|
+
} catch {
|
|
7033
|
+
}
|
|
7034
|
+
}
|
|
7035
|
+
return out;
|
|
7036
|
+
}
|
|
7037
|
+
function isBillingEvent(value) {
|
|
7038
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
7039
|
+
const r = value;
|
|
7040
|
+
return typeof r["id"] === "string" && typeof r["ts"] === "number" && typeof r["model"] === "string" && typeof r["status"] === "number";
|
|
7041
|
+
}
|
|
7042
|
+
|
|
7043
|
+
// src/billing/BillingRetrySweeper.ts
|
|
7044
|
+
var SWEEP_INTERVAL_MS3 = 5 * 6e4;
|
|
7045
|
+
var BillingRetrySweeper = class {
|
|
7046
|
+
constructor(billingDir, publisher2, logger, config, intervalMs = SWEEP_INTERVAL_MS3, now = Date.now) {
|
|
7047
|
+
this.billingDir = billingDir;
|
|
7048
|
+
this.publisher = publisher2;
|
|
7049
|
+
this.logger = logger;
|
|
7050
|
+
this.config = config;
|
|
7051
|
+
this.intervalMs = intervalMs;
|
|
7052
|
+
this.now = now;
|
|
7053
|
+
}
|
|
7054
|
+
billingDir;
|
|
7055
|
+
publisher;
|
|
7056
|
+
logger;
|
|
7057
|
+
config;
|
|
7058
|
+
intervalMs;
|
|
7059
|
+
now;
|
|
7060
|
+
timer = null;
|
|
7061
|
+
sweeping = false;
|
|
7062
|
+
/** Whether retrying is active: billing enabled AND an endpoint is configured. */
|
|
7063
|
+
get enabled() {
|
|
7064
|
+
return this.config.enabled && typeof this.config.endpoint === "string" && this.config.endpoint.length > 0;
|
|
7065
|
+
}
|
|
7066
|
+
/** Re-apply config to the live instance (boot + admin PUT hot-reload). */
|
|
7067
|
+
configure(config) {
|
|
7068
|
+
this.config = config;
|
|
4516
7069
|
}
|
|
4517
7070
|
/**
|
|
4518
|
-
*
|
|
4519
|
-
*
|
|
4520
|
-
*
|
|
4521
|
-
* provider was already absent (still re-stamps + persists).
|
|
7071
|
+
* Arm the retry interval AND run one sweep immediately (boot catch-up for events
|
|
7072
|
+
* that failed to deliver while the daemon was down). No-op when disabled or in
|
|
7073
|
+
* ledger-only mode (no endpoint to POST to). Idempotent.
|
|
4522
7074
|
*/
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
this.
|
|
7075
|
+
start() {
|
|
7076
|
+
if (this.timer || !this.enabled) return;
|
|
7077
|
+
void this.sweep();
|
|
7078
|
+
this.timer = setInterval(() => void this.sweep(), this.intervalMs);
|
|
7079
|
+
this.timer.unref?.();
|
|
4527
7080
|
}
|
|
4528
|
-
/**
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
const encrypted = encryptTokens(config, this.box);
|
|
4535
|
-
(0, import_node_fs12.writeFileSync)(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
|
|
7081
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
7082
|
+
dispose() {
|
|
7083
|
+
if (this.timer) {
|
|
7084
|
+
clearInterval(this.timer);
|
|
7085
|
+
this.timer = null;
|
|
7086
|
+
}
|
|
4536
7087
|
}
|
|
4537
7088
|
/**
|
|
4538
|
-
*
|
|
4539
|
-
*
|
|
4540
|
-
*
|
|
4541
|
-
*
|
|
4542
|
-
* The fs-read + JSON-parse tolerance is INSIDE the try (a missing or corrupt
|
|
4543
|
-
* file → empty `{ updatedAt: '' }`). The DECRYPT runs OUTSIDE the try, so a
|
|
4544
|
-
* wrong/missing master key or a tampered `enc:` envelope FAILS FAST with the
|
|
4545
|
-
* box's clear, secret-free error (secrets spec "错误密钥 / 篡改的解密失败 UX":
|
|
4546
|
-
* SHALL fail-fast, SHALL NOT 静默降级 — a swallowed decrypt would report "no
|
|
4547
|
-
* tokens" and silently send the WRONG bearer upstream → 401). Mirrors
|
|
4548
|
-
* `config.ts loadConfig`, which decrypts outside its parse try.
|
|
7089
|
+
* One sweep: re-POST every UNdelivered ledger event still within
|
|
7090
|
+
* `maxRetryAgeMs`; leave over-age undelivered events for reconciliation (NEVER
|
|
7091
|
+
* deleted). Exposed for tests; never throws. Returns the number of events a
|
|
7092
|
+
* re-POST was attempted for.
|
|
4549
7093
|
*/
|
|
4550
|
-
|
|
4551
|
-
if (!
|
|
4552
|
-
|
|
7094
|
+
async sweep() {
|
|
7095
|
+
if (!this.enabled || this.sweeping) return 0;
|
|
7096
|
+
this.sweeping = true;
|
|
4553
7097
|
try {
|
|
4554
|
-
const
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
7098
|
+
const cutoff = this.now() - this.config.maxRetryAgeMs;
|
|
7099
|
+
let attempted = 0;
|
|
7100
|
+
for (const event of readUndeliveredEvents(this.billingDir)) {
|
|
7101
|
+
if (event.ts < cutoff) continue;
|
|
7102
|
+
attempted += 1;
|
|
7103
|
+
await this.publisher.deliverNow(event);
|
|
7104
|
+
}
|
|
7105
|
+
if (attempted > 0) this.logger.debug("billing retry sweep complete", { attempted });
|
|
7106
|
+
return attempted;
|
|
7107
|
+
} catch (error) {
|
|
7108
|
+
this.logger.warn("billing retry sweep failed", {
|
|
7109
|
+
error: error instanceof Error ? error.message : String(error)
|
|
7110
|
+
});
|
|
7111
|
+
return 0;
|
|
7112
|
+
} finally {
|
|
7113
|
+
this.sweeping = false;
|
|
4558
7114
|
}
|
|
4559
|
-
if (!parsed) return { updatedAt: "" };
|
|
4560
|
-
const decrypted = decryptTokens(parsed, this.box);
|
|
4561
|
-
return migrateLazily(decrypted);
|
|
4562
7115
|
}
|
|
4563
7116
|
};
|
|
4564
7117
|
|
|
4565
7118
|
// src/TokenRefreshScheduler.ts
|
|
4566
|
-
var
|
|
4567
|
-
var
|
|
4568
|
-
var
|
|
7119
|
+
var REFRESH_LEAD_MS2 = 5 * 6e4;
|
|
7120
|
+
var SWEEP_INTERVAL_MS4 = 6e4;
|
|
7121
|
+
var OAUTH_PROVIDERS2 = ["claude", "codex", "gemini"];
|
|
4569
7122
|
var TokenRefreshScheduler = class {
|
|
4570
|
-
constructor(store, logger, intervalMs =
|
|
7123
|
+
constructor(store, logger, intervalMs = SWEEP_INTERVAL_MS4, leadMs = REFRESH_LEAD_MS2) {
|
|
4571
7124
|
this.store = store;
|
|
4572
7125
|
this.logger = logger;
|
|
4573
7126
|
this.intervalMs = intervalMs;
|
|
@@ -4598,7 +7151,7 @@ var TokenRefreshScheduler = class {
|
|
|
4598
7151
|
this.sweeping = true;
|
|
4599
7152
|
try {
|
|
4600
7153
|
const config = await this.store.getFullConfig();
|
|
4601
|
-
for (const provider of
|
|
7154
|
+
for (const provider of OAUTH_PROVIDERS2) {
|
|
4602
7155
|
const activeId = getActiveAccount(config, provider)?.id;
|
|
4603
7156
|
for (const account of listAccounts(config, provider)) {
|
|
4604
7157
|
if (!this.needsRefresh(account.tokens, now)) continue;
|
|
@@ -4651,16 +7204,186 @@ var TokenRefreshScheduler = class {
|
|
|
4651
7204
|
}
|
|
4652
7205
|
};
|
|
4653
7206
|
|
|
7207
|
+
// src/webhook/WebhookDispatcher.ts
|
|
7208
|
+
var import_node_crypto11 = require("crypto");
|
|
7209
|
+
var import_upstreamFetch6 = require("@omnicross/core/pipeline/upstreamFetch");
|
|
7210
|
+
var WEBHOOK_MAX_ATTEMPTS = 3;
|
|
7211
|
+
var WEBHOOK_QUEUE_MAX = 1e3;
|
|
7212
|
+
var WEBHOOK_SEND_TIMEOUT_MS = 1e4;
|
|
7213
|
+
var WEBHOOK_BASE_BACKOFF_MS = 200;
|
|
7214
|
+
var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
7215
|
+
var WebhookDispatcher = class {
|
|
7216
|
+
config;
|
|
7217
|
+
queue = [];
|
|
7218
|
+
draining = false;
|
|
7219
|
+
warnedFull = false;
|
|
7220
|
+
fetchImpl;
|
|
7221
|
+
logger;
|
|
7222
|
+
maxAttempts;
|
|
7223
|
+
queueMax;
|
|
7224
|
+
timeoutMs;
|
|
7225
|
+
baseBackoffMs;
|
|
7226
|
+
sleep;
|
|
7227
|
+
now;
|
|
7228
|
+
constructor(opts = {}) {
|
|
7229
|
+
this.fetchImpl = opts.fetchImpl ?? ((url, init) => (0, import_upstreamFetch6.fetchUpstream)(url, init));
|
|
7230
|
+
this.logger = opts.logger;
|
|
7231
|
+
this.maxAttempts = opts.maxAttempts ?? WEBHOOK_MAX_ATTEMPTS;
|
|
7232
|
+
this.queueMax = opts.queueMax ?? WEBHOOK_QUEUE_MAX;
|
|
7233
|
+
this.timeoutMs = opts.timeoutMs ?? WEBHOOK_SEND_TIMEOUT_MS;
|
|
7234
|
+
this.baseBackoffMs = opts.baseBackoffMs ?? WEBHOOK_BASE_BACKOFF_MS;
|
|
7235
|
+
this.sleep = opts.sleep ?? defaultSleep;
|
|
7236
|
+
this.now = opts.now ?? Date.now;
|
|
7237
|
+
}
|
|
7238
|
+
/** Install/replace the live webhook config (destinations + master switch). */
|
|
7239
|
+
setConfig(config) {
|
|
7240
|
+
this.config = config;
|
|
7241
|
+
}
|
|
7242
|
+
/**
|
|
7243
|
+
* Enqueue an event and return IMMEDIATELY (fire-and-forget). NEVER awaits a
|
|
7244
|
+
* send, NEVER throws — the drain loop does all sending on a side channel. A
|
|
7245
|
+
* full queue drops the OLDEST event (with a one-shot warn) so a runaway source
|
|
7246
|
+
* can't OOM the process.
|
|
7247
|
+
*/
|
|
7248
|
+
emit(event) {
|
|
7249
|
+
if (this.queue.length >= this.queueMax) {
|
|
7250
|
+
this.queue.shift();
|
|
7251
|
+
if (!this.warnedFull) {
|
|
7252
|
+
this.logger?.warn("[webhook] queue full \u2014 dropping oldest events");
|
|
7253
|
+
this.warnedFull = true;
|
|
7254
|
+
}
|
|
7255
|
+
}
|
|
7256
|
+
this.queue.push(event);
|
|
7257
|
+
if (!this.draining) {
|
|
7258
|
+
this.draining = true;
|
|
7259
|
+
queueMicrotask(() => void this.drain());
|
|
7260
|
+
}
|
|
7261
|
+
}
|
|
7262
|
+
/** Drain the queue, sending each event to its matching destinations concurrently. */
|
|
7263
|
+
async drain() {
|
|
7264
|
+
try {
|
|
7265
|
+
while (this.queue.length > 0) {
|
|
7266
|
+
const event = this.queue.shift();
|
|
7267
|
+
const destinations = this.matchingDestinations(event.kind);
|
|
7268
|
+
if (destinations.length === 0) continue;
|
|
7269
|
+
await Promise.all(destinations.map((d) => this.sendWithRetry(event, d)));
|
|
7270
|
+
}
|
|
7271
|
+
} finally {
|
|
7272
|
+
this.draining = false;
|
|
7273
|
+
if (this.queue.length > 0) {
|
|
7274
|
+
this.draining = true;
|
|
7275
|
+
queueMicrotask(() => void this.drain());
|
|
7276
|
+
}
|
|
7277
|
+
}
|
|
7278
|
+
}
|
|
7279
|
+
/** The enabled destinations whose event filter admits this kind (empty ⇒ all). */
|
|
7280
|
+
matchingDestinations(kind) {
|
|
7281
|
+
const cfg = this.config;
|
|
7282
|
+
if (!cfg || !cfg.enabled) return [];
|
|
7283
|
+
return cfg.destinations.filter(
|
|
7284
|
+
(d) => d.enabled && (!d.events || d.events.length === 0 || d.events.includes(kind))
|
|
7285
|
+
);
|
|
7286
|
+
}
|
|
7287
|
+
/** Send with bounded exponential backoff; log-and-drop after `maxAttempts`. */
|
|
7288
|
+
async sendWithRetry(event, dest) {
|
|
7289
|
+
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
|
7290
|
+
const result = await this.sendOnce(event, dest);
|
|
7291
|
+
if (result.ok) {
|
|
7292
|
+
this.logger?.debug(`[webhook] delivered ${event.kind} \u2192 ${dest.id} (${result.status})`);
|
|
7293
|
+
return;
|
|
7294
|
+
}
|
|
7295
|
+
if (attempt < this.maxAttempts) {
|
|
7296
|
+
await this.sleep(this.baseBackoffMs * 2 ** (attempt - 1));
|
|
7297
|
+
} else {
|
|
7298
|
+
this.logger?.warn(
|
|
7299
|
+
`[webhook] dropped ${event.kind} \u2192 ${dest.id} after ${this.maxAttempts} attempts: ${result.error ?? `HTTP ${result.status}`}`
|
|
7300
|
+
);
|
|
7301
|
+
}
|
|
7302
|
+
}
|
|
7303
|
+
}
|
|
7304
|
+
/** One POST attempt. Returns an outcome; a thrown error becomes `{ ok:false }`. */
|
|
7305
|
+
async sendOnce(event, dest) {
|
|
7306
|
+
try {
|
|
7307
|
+
const { body, headers } = buildRequest(event, dest, this.now());
|
|
7308
|
+
const res = await this.fetchImpl(dest.url, {
|
|
7309
|
+
method: "POST",
|
|
7310
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
7311
|
+
body,
|
|
7312
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
7313
|
+
});
|
|
7314
|
+
return res.ok ? { ok: true, status: res.status } : { ok: false, status: res.status };
|
|
7315
|
+
} catch (err5) {
|
|
7316
|
+
return { ok: false, error: err5 instanceof Error ? err5.message : String(err5) };
|
|
7317
|
+
}
|
|
7318
|
+
}
|
|
7319
|
+
/**
|
|
7320
|
+
* ADMIN test path (design D8): deliver a `test` event to ONE destination and
|
|
7321
|
+
* AWAIT the single-attempt result. This is the ONLY awaited send — it runs on
|
|
7322
|
+
* the admin request path (an operator clicking "Test"), NEVER on a relay path,
|
|
7323
|
+
* so awaiting it is safe. Finds the destination regardless of its `enabled`
|
|
7324
|
+
* flag or the master switch (an explicit operator action).
|
|
7325
|
+
*/
|
|
7326
|
+
async deliverTest(destinationId) {
|
|
7327
|
+
const dest = this.config?.destinations.find((d) => d.id === destinationId);
|
|
7328
|
+
if (!dest) return { ok: false, error: "destination not found" };
|
|
7329
|
+
return this.sendOnce({ kind: "test", at: this.now() }, dest);
|
|
7330
|
+
}
|
|
7331
|
+
};
|
|
7332
|
+
function buildRequest(event, dest, nowMs) {
|
|
7333
|
+
if (dest.type === "feishu") return buildFeishu(event, dest, nowMs);
|
|
7334
|
+
return buildCustom(event, dest);
|
|
7335
|
+
}
|
|
7336
|
+
function buildCustom(event, dest) {
|
|
7337
|
+
const body = JSON.stringify(event);
|
|
7338
|
+
const headers = {};
|
|
7339
|
+
if (dest.secret) {
|
|
7340
|
+
const hmac = (0, import_node_crypto11.createHmac)("sha256", dest.secret).update(body).digest("hex");
|
|
7341
|
+
headers["X-Omnicross-Signature"] = `sha256=${hmac}`;
|
|
7342
|
+
}
|
|
7343
|
+
return { body, headers };
|
|
7344
|
+
}
|
|
7345
|
+
function buildFeishu(event, dest, nowMs) {
|
|
7346
|
+
const payload = {
|
|
7347
|
+
msg_type: "text",
|
|
7348
|
+
content: { text: feishuText(event) }
|
|
7349
|
+
};
|
|
7350
|
+
if (dest.secret) {
|
|
7351
|
+
const timestamp = Math.floor(nowMs / 1e3).toString();
|
|
7352
|
+
const stringToSign = `${timestamp}
|
|
7353
|
+
${dest.secret}`;
|
|
7354
|
+
payload["timestamp"] = timestamp;
|
|
7355
|
+
payload["sign"] = (0, import_node_crypto11.createHmac)("sha256", stringToSign).digest("base64");
|
|
7356
|
+
}
|
|
7357
|
+
return { body: JSON.stringify(payload), headers: {} };
|
|
7358
|
+
}
|
|
7359
|
+
function feishuText(event) {
|
|
7360
|
+
switch (event.kind) {
|
|
7361
|
+
case "account.recovery":
|
|
7362
|
+
return `omnicross: account recovered \u2014 ${event.providerId}/${event.accountId}`;
|
|
7363
|
+
case "account.anomaly":
|
|
7364
|
+
return `omnicross: account anomaly [${event.state}] \u2014 ${event.providerId}/${event.accountId}`;
|
|
7365
|
+
case "key.quotaWarning":
|
|
7366
|
+
return `omnicross: key quota warning (${event.scope}) \u2014 $${event.spentUsd.toFixed(2)} of $${event.limitUsd.toFixed(2)} (key ${event.keyId})`;
|
|
7367
|
+
case "key.quotaExceeded":
|
|
7368
|
+
return `omnicross: key quota EXCEEDED (${event.scope}) \u2014 $${event.spentUsd.toFixed(2)} of $${event.limitUsd.toFixed(2)} (key ${event.keyId})`;
|
|
7369
|
+
case "server.error":
|
|
7370
|
+
return `omnicross: server error \u2014 ${event.message}`;
|
|
7371
|
+
case "test":
|
|
7372
|
+
return "omnicross: webhook test";
|
|
7373
|
+
}
|
|
7374
|
+
}
|
|
7375
|
+
|
|
4654
7376
|
// src/bootstrap.ts
|
|
4655
7377
|
function buildDaemon(config, paths) {
|
|
4656
|
-
const logger = new
|
|
7378
|
+
const logger = new ConfigurableLogger(config.logging);
|
|
4657
7379
|
const secretBox3 = new SecretBox(() => resolveMasterKey({ keyFilePath: paths.masterKeyFilePath }));
|
|
4658
7380
|
setSecretBox(secretBox3);
|
|
4659
7381
|
setSecretBox2(secretBox3);
|
|
4660
7382
|
const decryptedConfig = decryptConfigSecrets(config, secretBox3);
|
|
4661
7383
|
const llmConfig = new ConfigFileProviderConfigSource(decryptedConfig);
|
|
4662
7384
|
const keyDb = new JsonOutboundKeyDb(paths.keysPath);
|
|
4663
|
-
const
|
|
7385
|
+
const voucherDb = new JsonVoucherDb(defaultVouchersPath(paths.configPath));
|
|
7386
|
+
const settingsStore = new JsonApiServerSettingsStore(paths.configPath, secretBox3);
|
|
4664
7387
|
const credentialStore = new JsonSubscriptionCredentialStore(paths.tokensPath, secretBox3);
|
|
4665
7388
|
const subscriptionAccounts = new import_subscriptions4.SubscriptionAccountService(credentialStore);
|
|
4666
7389
|
(0, import_subscriptions4.setSubscriptionAccountService)(subscriptionAccounts);
|
|
@@ -4669,6 +7392,12 @@ function buildDaemon(config, paths) {
|
|
|
4669
7392
|
credentialStore
|
|
4670
7393
|
);
|
|
4671
7394
|
(0, import_subscriptions4.setSubscriptionProviderRegistry)(subscriptionRegistry);
|
|
7395
|
+
setServerProxyConfig(decryptedConfig.server?.proxy);
|
|
7396
|
+
(0, import_upstreamFetch7.setUpstreamProxyResolver)(
|
|
7397
|
+
createUpstreamProxyResolver({
|
|
7398
|
+
getAccountProxy: (providerId, accountId) => credentialStore.getAccountProxy(providerId, accountId)
|
|
7399
|
+
})
|
|
7400
|
+
);
|
|
4672
7401
|
(0, import_gemini_code_assist_resolver.setGeminiCodeAssistResolver)((0, import_GeminiCodeAssistProjectResolver.getGeminiCodeAssistProjectResolver)());
|
|
4673
7402
|
const autoDisableStore = new AutoDisableStore();
|
|
4674
7403
|
const apiKeyPool = new import_ApiKeyPoolService.ApiKeyPoolService(
|
|
@@ -4689,19 +7418,59 @@ function buildDaemon(config, paths) {
|
|
|
4689
7418
|
defaultUsageEventsPath(paths.configPath),
|
|
4690
7419
|
async (providerId, model) => await pricingEngine.getEntry(providerId, model) !== null
|
|
4691
7420
|
);
|
|
4692
|
-
const
|
|
7421
|
+
const keySpendTracker = new import_outbound_api6.KeySpendTracker(usageEventStore);
|
|
7422
|
+
const usageRecorder = new import_usage.UsageRecorder(usageEventStore, pricingEngine, logger, {
|
|
7423
|
+
onRecord: (apiKeyId, costUsd, at) => keySpendTracker.add(apiKeyId, costUsd, at)
|
|
7424
|
+
});
|
|
4693
7425
|
const providerProxy = (0, import_provider_proxy.getProviderProxy)({ llmConfig, apiKeyPool, usageRecorder });
|
|
4694
7426
|
llmConfig.setReloadHook(() => apiKeyPool.invalidateCache());
|
|
4695
|
-
const
|
|
7427
|
+
const accountHealthProbeScheduler = new AccountHealthProbeScheduler(
|
|
7428
|
+
credentialStore,
|
|
7429
|
+
(0, import_SubscriptionAccountHealth2.getSharedAccountHealth)(),
|
|
7430
|
+
logger,
|
|
7431
|
+
import_outbound_api5.DEFAULT_ACCOUNT_PROBE
|
|
7432
|
+
);
|
|
7433
|
+
const getHealthReport = () => buildHealthReport({
|
|
7434
|
+
version: DAEMON_VERSION,
|
|
7435
|
+
// CRITICAL: the config loaded with a providers array.
|
|
7436
|
+
configPresent: () => Array.isArray(decryptedConfig.providers),
|
|
7437
|
+
// CRITICAL: the credential store's tokens.json is readable WITHOUT
|
|
7438
|
+
// decrypting (a missing file is fine — no accounts yet). A stat/access
|
|
7439
|
+
// only; never reads or decrypts token material.
|
|
7440
|
+
credentialStoreReadable: () => isTokensStoreReadable(paths.tokensPath),
|
|
7441
|
+
outboundServerRunning: () => outboundApiServer.getStatus().running,
|
|
7442
|
+
adminServerRunning: () => adminServer.getStatus().running,
|
|
7443
|
+
// Coarse, account-anonymous probe signal (#8, D5) — added to `checks` ONLY
|
|
7444
|
+
// when probing is ENABLED; disabled ⇒ `undefined` ⇒ key omitted ⇒ the
|
|
7445
|
+
// `/health` body stays byte-identical (zero regression).
|
|
7446
|
+
subscriptionAccountsHealthy: () => accountHealthProbeScheduler.enabled ? accountHealthProbeScheduler.probedAccountsHealthy() : void 0
|
|
7447
|
+
});
|
|
7448
|
+
const outboundApiServer = (0, import_outbound_api5.getOutboundApiServer)({
|
|
4696
7449
|
db: keyDb,
|
|
7450
|
+
// voucher-redemption #9: the key-authenticated `POST /redeem` endpoint redeems
|
|
7451
|
+
// cards against the presenting key (gated on `voucher.enabled`).
|
|
7452
|
+
voucherDb,
|
|
4697
7453
|
llmConfig,
|
|
4698
7454
|
providerProxy,
|
|
4699
|
-
proxyDeps: providerProxy.getDeps()
|
|
7455
|
+
proxyDeps: providerProxy.getDeps(),
|
|
7456
|
+
healthReportProvider: getHealthReport,
|
|
7457
|
+
// outbound-key-policy: the wire layer's 402 cost check reads per-key spend.
|
|
7458
|
+
keySpendTracker,
|
|
7459
|
+
// configurable-logging: route the server's OWN lifecycle + relay dispatch-error
|
|
7460
|
+
// lines through the injected logger (honors level/format/file sink).
|
|
7461
|
+
logger
|
|
4700
7462
|
});
|
|
7463
|
+
const auditDir = defaultAuditDir(paths.configPath);
|
|
7464
|
+
const billingDir = defaultBillingDir(paths.configPath);
|
|
4701
7465
|
const adminServer = new AdminServer({
|
|
4702
7466
|
configPath: paths.configPath,
|
|
4703
7467
|
llmConfig,
|
|
4704
7468
|
keyDb,
|
|
7469
|
+
// voucher-redemption #9: the admin `/admin/api/voucher` surface generates/
|
|
7470
|
+
// lists/revokes redemption cards (gated on `voucher.enabled`).
|
|
7471
|
+
voucherDb,
|
|
7472
|
+
// outbound-key-policy: the admin key list surfaces each key's OWN spend.
|
|
7473
|
+
keySpendReader: keySpendTracker,
|
|
4705
7474
|
settingsStore,
|
|
4706
7475
|
outboundApiServer,
|
|
4707
7476
|
subscriptionAccounts,
|
|
@@ -4723,14 +7492,16 @@ function buildDaemon(config, paths) {
|
|
|
4723
7492
|
oauthSessions: new OAuthSessionStore(),
|
|
4724
7493
|
// Real global fetch by default; a test seam (`paths.oauthExchangeFetch`) can
|
|
4725
7494
|
// inject a mock so no real token endpoint is hit.
|
|
4726
|
-
|
|
7495
|
+
// upstream-proxy: default the OAuth token-exchange fetch to the proxy-aware
|
|
7496
|
+
// helper so interactive login honors a configured proxy (global/env layers).
|
|
7497
|
+
oauthExchangeFetch: paths.oauthExchangeFetch ?? ((url, init) => (0, import_upstreamFetch7.fetchUpstream)(url, init)),
|
|
4727
7498
|
subscriptionAccountAppender: credentialStore,
|
|
4728
7499
|
// Codex interactive OAuth (app-parity-2 child 5) — the async loopback flow store
|
|
4729
7500
|
// + the one-shot 127.0.0.1:1455 listener. Token captured + persisted daemon-side;
|
|
4730
7501
|
// the app polls the token-free status. A test seam (`paths.codexAwaitLoopback`)
|
|
4731
7502
|
// can inject a mock so no real port is bound.
|
|
4732
7503
|
codexSessions: new CodexOAuthSessionStore(),
|
|
4733
|
-
codexAwaitLoopback: paths.codexAwaitLoopback ?? ((state, timeoutMs) => awaitLoopbackCode(state, timeoutMs)),
|
|
7504
|
+
codexAwaitLoopback: paths.codexAwaitLoopback ?? ((state, timeoutMs, signal) => awaitLoopbackCode(state, timeoutMs, signal)),
|
|
4734
7505
|
// Migration pack (app-parity child 6, design D2/D3) — the concrete credential
|
|
4735
7506
|
// store provides BOTH the full DECRYPTED read (`getFullConfig`, export) and
|
|
4736
7507
|
// the multi-account append (`appendProviderAccount`, import re-encrypts at-
|
|
@@ -4750,9 +7521,48 @@ function buildDaemon(config, paths) {
|
|
|
4750
7521
|
pricingStore,
|
|
4751
7522
|
// Use the DECRYPTED config so `admin.token` (if stored as `enc:`) is the
|
|
4752
7523
|
// plaintext bearer the AdminServer's constant-time compare expects (D4).
|
|
4753
|
-
getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin)
|
|
7524
|
+
getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin),
|
|
7525
|
+
// Unauthenticated `/health` probe (daemon-health-endpoint) — the SAME shared
|
|
7526
|
+
// builder the outbound server uses, served before the admin auth gate.
|
|
7527
|
+
getHealthReport,
|
|
7528
|
+
// configurable-logging: the admin listener's lifecycle lines route through
|
|
7529
|
+
// the injected logger.
|
|
7530
|
+
logger,
|
|
7531
|
+
// subscription-account-probe #8: the AUTHED `GET /admin/api/account-probes`
|
|
7532
|
+
// reads per-account probe history from the scheduler (secret-free — ids +
|
|
7533
|
+
// status labels only). Routed in `AdminServer` (not `adminApi.ts`).
|
|
7534
|
+
probeHistoryReader: accountHealthProbeScheduler,
|
|
7535
|
+
// request-audit-log: the AUTHED `GET /admin/api/audit` reads + filters the
|
|
7536
|
+
// date-rotated audit store. Bound to the store dir here so the AdminServer
|
|
7537
|
+
// carries no path/store coupling. Records hold IP/UA/bodies → admin-only,
|
|
7538
|
+
// NEVER unauth, NEVER on `/health`. Routed in `AdminServer` (not `adminApi.ts`).
|
|
7539
|
+
auditReader: (query) => readAuditRecords(auditDir, query),
|
|
7540
|
+
// billing-event-stream: the AUTHED `GET /admin/api/billing-status` returns the
|
|
7541
|
+
// secret-free total/delivered/pending counts of the durable ledger.
|
|
7542
|
+
billingStatusReader: () => readBillingStatus(billingDir)
|
|
7543
|
+
});
|
|
7544
|
+
const webhookDispatcher = new WebhookDispatcher({
|
|
7545
|
+
logger,
|
|
7546
|
+
fetchImpl: (url, init) => (0, import_upstreamFetch7.fetchUpstream)(url, init)
|
|
4754
7547
|
});
|
|
7548
|
+
setWebhookRuntime(webhookDispatcher, (0, import_SubscriptionAccountHealth2.getSharedAccountHealth)());
|
|
7549
|
+
const auditWriter = new AuditWriter(auditDir, logger);
|
|
7550
|
+
const auditPruneSweeper = new AuditPruneSweeper(auditDir, logger, import_audit_types.DEFAULT_AUDIT_CONFIG);
|
|
7551
|
+
setAuditRuntime(auditWriter, auditPruneSweeper);
|
|
7552
|
+
const billingPublisher = new BillingPublisher(billingDir, logger);
|
|
7553
|
+
const billingRetrySweeper = new BillingRetrySweeper(
|
|
7554
|
+
billingDir,
|
|
7555
|
+
billingPublisher,
|
|
7556
|
+
logger,
|
|
7557
|
+
import_billing_types.DEFAULT_BILLING_CONFIG
|
|
7558
|
+
);
|
|
7559
|
+
setBillingRuntime(billingPublisher, billingRetrySweeper);
|
|
4755
7560
|
const tokenRefreshScheduler = new TokenRefreshScheduler(credentialStore, logger);
|
|
7561
|
+
const accountHealthSweeper = new AccountHealthSweeper(
|
|
7562
|
+
credentialStore,
|
|
7563
|
+
(0, import_SubscriptionAccountHealth2.getSharedAccountHealth)(),
|
|
7564
|
+
logger
|
|
7565
|
+
);
|
|
4756
7566
|
return {
|
|
4757
7567
|
logger,
|
|
4758
7568
|
llmConfig,
|
|
@@ -4769,9 +7579,25 @@ function buildDaemon(config, paths) {
|
|
|
4769
7579
|
pricingEngine,
|
|
4770
7580
|
usageRecorder,
|
|
4771
7581
|
adminServer,
|
|
4772
|
-
tokenRefreshScheduler
|
|
7582
|
+
tokenRefreshScheduler,
|
|
7583
|
+
accountHealthSweeper,
|
|
7584
|
+
accountHealthProbeScheduler,
|
|
7585
|
+
webhookDispatcher,
|
|
7586
|
+
auditWriter,
|
|
7587
|
+
auditPruneSweeper,
|
|
7588
|
+
billingPublisher,
|
|
7589
|
+
billingRetrySweeper
|
|
4773
7590
|
};
|
|
4774
7591
|
}
|
|
7592
|
+
function isTokensStoreReadable(tokensPath) {
|
|
7593
|
+
try {
|
|
7594
|
+
if (!(0, import_node_fs20.existsSync)(tokensPath)) return true;
|
|
7595
|
+
(0, import_node_fs20.accessSync)(tokensPath, import_node_fs20.constants.R_OK);
|
|
7596
|
+
return true;
|
|
7597
|
+
} catch {
|
|
7598
|
+
return false;
|
|
7599
|
+
}
|
|
7600
|
+
}
|
|
4775
7601
|
|
|
4776
7602
|
// src/commands/launch.ts
|
|
4777
7603
|
var SUPPORTED_LAUNCH_CLIS = [
|
|
@@ -4811,10 +7637,10 @@ function buildCliSpawnPlan(opts) {
|
|
|
4811
7637
|
};
|
|
4812
7638
|
}
|
|
4813
7639
|
function resolveInPathDefault(candidate) {
|
|
4814
|
-
const segments = (process.env["PATH"] ?? "").split(
|
|
7640
|
+
const segments = (process.env["PATH"] ?? "").split(import_node_path13.delimiter).filter(Boolean);
|
|
4815
7641
|
for (const seg of segments) {
|
|
4816
|
-
const full = (0,
|
|
4817
|
-
if ((0,
|
|
7642
|
+
const full = (0, import_node_path13.join)(seg, candidate);
|
|
7643
|
+
if ((0, import_node_fs21.existsSync)(full)) return full;
|
|
4818
7644
|
}
|
|
4819
7645
|
return null;
|
|
4820
7646
|
}
|
|
@@ -4857,6 +7683,10 @@ async function runLaunch(argv, deps) {
|
|
|
4857
7683
|
} catch (err5) {
|
|
4858
7684
|
daemon.apiKeyPool.dispose();
|
|
4859
7685
|
daemon.tokenRefreshScheduler.dispose();
|
|
7686
|
+
daemon.accountHealthSweeper.dispose();
|
|
7687
|
+
daemon.accountHealthProbeScheduler.dispose();
|
|
7688
|
+
daemon.auditPruneSweeper.dispose();
|
|
7689
|
+
daemon.billingRetrySweeper.dispose();
|
|
4860
7690
|
throw err5;
|
|
4861
7691
|
}
|
|
4862
7692
|
let launch;
|
|
@@ -4869,6 +7699,10 @@ async function runLaunch(argv, deps) {
|
|
|
4869
7699
|
await daemon.providerProxy.stop();
|
|
4870
7700
|
daemon.apiKeyPool.dispose();
|
|
4871
7701
|
daemon.tokenRefreshScheduler.dispose();
|
|
7702
|
+
daemon.accountHealthSweeper.dispose();
|
|
7703
|
+
daemon.accountHealthProbeScheduler.dispose();
|
|
7704
|
+
daemon.auditPruneSweeper.dispose();
|
|
7705
|
+
daemon.billingRetrySweeper.dispose();
|
|
4872
7706
|
throw err5;
|
|
4873
7707
|
}
|
|
4874
7708
|
try {
|
|
@@ -4891,6 +7725,10 @@ async function runLaunch(argv, deps) {
|
|
|
4891
7725
|
await daemon.providerProxy.stop();
|
|
4892
7726
|
daemon.apiKeyPool.dispose();
|
|
4893
7727
|
daemon.tokenRefreshScheduler.dispose();
|
|
7728
|
+
daemon.accountHealthSweeper.dispose();
|
|
7729
|
+
daemon.accountHealthProbeScheduler.dispose();
|
|
7730
|
+
daemon.auditPruneSweeper.dispose();
|
|
7731
|
+
daemon.billingRetrySweeper.dispose();
|
|
4894
7732
|
}
|
|
4895
7733
|
}
|
|
4896
7734
|
async function buildLaunchConfig(cli, llmConfig, opts) {
|
|
@@ -4963,6 +7801,7 @@ function spawnCliInherit(plan) {
|
|
|
4963
7801
|
var import_node_child_process3 = require("child_process");
|
|
4964
7802
|
var import_node_readline = require("readline");
|
|
4965
7803
|
var import_node_util4 = require("util");
|
|
7804
|
+
var import_upstreamFetch8 = require("@omnicross/core/pipeline/upstreamFetch");
|
|
4966
7805
|
var import_subscriptions5 = require("@omnicross/subscriptions");
|
|
4967
7806
|
var PROVIDERS = ["claude", "codex", "gemini"];
|
|
4968
7807
|
async function runLogin(argv, deps) {
|
|
@@ -4994,9 +7833,10 @@ async function runLogin(argv, deps) {
|
|
|
4994
7833
|
};
|
|
4995
7834
|
const box = resolveSecretBox(values["master-key-file"]);
|
|
4996
7835
|
setSecretBox(box);
|
|
7836
|
+
(0, import_upstreamFetch8.setUpstreamProxyResolver)(createUpstreamProxyResolver());
|
|
4997
7837
|
try {
|
|
4998
7838
|
const tokensPath = defaultTokensPath(values.config);
|
|
4999
|
-
const exchangeFetch = resolved.tokensFetch ?? ((url, init) =>
|
|
7839
|
+
const exchangeFetch = resolved.tokensFetch ?? ((url, init) => (0, import_upstreamFetch8.fetchUpstream)(url, init, { providerId: provider }));
|
|
5000
7840
|
const store = new JsonSubscriptionCredentialStore(tokensPath, box, exchangeFetch);
|
|
5001
7841
|
const expiresAt = await runProviderLogin(
|
|
5002
7842
|
provider,
|
|
@@ -5009,6 +7849,7 @@ async function runLogin(argv, deps) {
|
|
|
5009
7849
|
console.info(` token: [stored, encrypted] expiresAt: ${expiresAt ?? "n/a"}`);
|
|
5010
7850
|
} finally {
|
|
5011
7851
|
setSecretBox(null);
|
|
7852
|
+
(0, import_upstreamFetch8.setUpstreamProxyResolver)(null);
|
|
5012
7853
|
}
|
|
5013
7854
|
}
|
|
5014
7855
|
async function runProviderLogin(provider, store, deps, exchangeFetch, label) {
|
|
@@ -5131,7 +7972,7 @@ function promptPaste(prompt) {
|
|
|
5131
7972
|
}
|
|
5132
7973
|
|
|
5133
7974
|
// src/commands/providers.ts
|
|
5134
|
-
var
|
|
7975
|
+
var import_node_crypto12 = require("crypto");
|
|
5135
7976
|
var import_node_util5 = require("util");
|
|
5136
7977
|
async function runProviders(argv) {
|
|
5137
7978
|
const { values, positionals } = (0, import_node_util5.parseArgs)({
|
|
@@ -5253,7 +8094,7 @@ function providersAddKey(configPath, providerId, opts) {
|
|
|
5253
8094
|
const cfg = loadConfig(configPath);
|
|
5254
8095
|
const row = cfg.providers.find((p) => p.id === providerId);
|
|
5255
8096
|
if (!row) throw new Error(`providers add-key: unknown provider '${providerId}'`);
|
|
5256
|
-
const entry = { id: (0,
|
|
8097
|
+
const entry = { id: (0, import_node_crypto12.randomUUID)(), apiKey: opts.key };
|
|
5257
8098
|
if (opts.label) entry.label = opts.label;
|
|
5258
8099
|
if (opts.weight !== void 0) {
|
|
5259
8100
|
const w = Number(opts.weight);
|
|
@@ -5281,7 +8122,7 @@ function providersRmKey(configPath, providerId, keyId) {
|
|
|
5281
8122
|
}
|
|
5282
8123
|
|
|
5283
8124
|
// src/commands/secrets.ts
|
|
5284
|
-
var
|
|
8125
|
+
var import_node_fs22 = require("fs");
|
|
5285
8126
|
var import_node_util6 = require("util");
|
|
5286
8127
|
async function runSecrets(argv) {
|
|
5287
8128
|
const { values, positionals } = (0, import_node_util6.parseArgs)({
|
|
@@ -5353,7 +8194,7 @@ function secretsStatus(args) {
|
|
|
5353
8194
|
reportField("admin.token", cfg.admin.token);
|
|
5354
8195
|
}
|
|
5355
8196
|
const tokensPath = defaultTokensPath(args.config);
|
|
5356
|
-
if ((0,
|
|
8197
|
+
if ((0, import_node_fs22.existsSync)(tokensPath)) {
|
|
5357
8198
|
console.info(`Secret status for ${tokensPath}:`);
|
|
5358
8199
|
reportTokenFields(tokensPath);
|
|
5359
8200
|
}
|
|
@@ -5393,7 +8234,7 @@ function secretsRotate(args) {
|
|
|
5393
8234
|
const tokensPath = defaultTokensPath(args.config);
|
|
5394
8235
|
try {
|
|
5395
8236
|
cfg = loadConfig(args.config);
|
|
5396
|
-
if ((0,
|
|
8237
|
+
if ((0, import_node_fs22.existsSync)(tokensPath)) tokensPlain = decryptTokensFile(tokensPath, oldBox);
|
|
5397
8238
|
} finally {
|
|
5398
8239
|
setSecretBox(null);
|
|
5399
8240
|
}
|
|
@@ -5422,20 +8263,20 @@ function secretsDecrypt(args) {
|
|
|
5422
8263
|
let tokensPlain = null;
|
|
5423
8264
|
try {
|
|
5424
8265
|
cfg = loadConfig(args.config);
|
|
5425
|
-
if ((0,
|
|
8266
|
+
if ((0, import_node_fs22.existsSync)(tokensPath)) tokensPlain = decryptTokensFile(tokensPath, box);
|
|
5426
8267
|
} finally {
|
|
5427
8268
|
setSecretBox(null);
|
|
5428
8269
|
}
|
|
5429
8270
|
saveConfig(args.config, cfg);
|
|
5430
8271
|
if (tokensPlain) {
|
|
5431
|
-
(0,
|
|
8272
|
+
(0, import_node_fs22.writeFileSync)(tokensPath, JSON.stringify(tokensPlain, null, 2) + "\n", "utf8");
|
|
5432
8273
|
}
|
|
5433
8274
|
console.info(`Decrypted secrets to plaintext in ${args.config}` + tokensSuffix(args.config));
|
|
5434
8275
|
}
|
|
5435
8276
|
function readRawConfig(path2) {
|
|
5436
8277
|
let parsed;
|
|
5437
8278
|
try {
|
|
5438
|
-
parsed = JSON.parse((0,
|
|
8279
|
+
parsed = JSON.parse((0, import_node_fs22.readFileSync)(path2, "utf8"));
|
|
5439
8280
|
} catch {
|
|
5440
8281
|
throw new Error(`secrets: cannot read or parse '${path2}'`);
|
|
5441
8282
|
}
|
|
@@ -5443,7 +8284,7 @@ function readRawConfig(path2) {
|
|
|
5443
8284
|
}
|
|
5444
8285
|
function readRawJson(path2) {
|
|
5445
8286
|
try {
|
|
5446
|
-
const parsed = JSON.parse((0,
|
|
8287
|
+
const parsed = JSON.parse((0, import_node_fs22.readFileSync)(path2, "utf8"));
|
|
5447
8288
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
5448
8289
|
return parsed;
|
|
5449
8290
|
}
|
|
@@ -5453,7 +8294,7 @@ function readRawJson(path2) {
|
|
|
5453
8294
|
}
|
|
5454
8295
|
function encryptTokensFileInPlace(configPath, box) {
|
|
5455
8296
|
const tokensPath = defaultTokensPath(configPath);
|
|
5456
|
-
if (!(0,
|
|
8297
|
+
if (!(0, import_node_fs22.existsSync)(tokensPath)) return;
|
|
5457
8298
|
const plain = decryptTokensFile(tokensPath, box);
|
|
5458
8299
|
writeTokensEncrypted(tokensPath, plain, box);
|
|
5459
8300
|
}
|
|
@@ -5466,7 +8307,7 @@ function writeTokensEncrypted(tokensPath, plain, box) {
|
|
|
5466
8307
|
{ updatedAt: "", ...plain },
|
|
5467
8308
|
box
|
|
5468
8309
|
);
|
|
5469
|
-
(0,
|
|
8310
|
+
(0, import_node_fs22.writeFileSync)(tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
|
|
5470
8311
|
}
|
|
5471
8312
|
var TOKEN_FIELDS2 = {
|
|
5472
8313
|
claude: ["accessToken", "refreshToken"],
|
|
@@ -5489,12 +8330,47 @@ function walkTokens(raw, fn) {
|
|
|
5489
8330
|
return next;
|
|
5490
8331
|
}
|
|
5491
8332
|
function tokensSuffix(configPath) {
|
|
5492
|
-
return (0,
|
|
8333
|
+
return (0, import_node_fs22.existsSync)(defaultTokensPath(configPath)) ? " (+ tokens.json)" : "";
|
|
5493
8334
|
}
|
|
5494
8335
|
|
|
5495
8336
|
// src/commands/start.ts
|
|
5496
8337
|
var import_node_util7 = require("util");
|
|
5497
|
-
var
|
|
8338
|
+
var import_outbound_api7 = require("@omnicross/core/outbound-api");
|
|
8339
|
+
var import_SubscriptionAccountHealth3 = require("@omnicross/core/pipeline/SubscriptionAccountHealth");
|
|
8340
|
+
|
|
8341
|
+
// src/identity/identityRuntime.ts
|
|
8342
|
+
var import_SubscriptionIdentityStore3 = require("@omnicross/core/provider-proxy/identity/SubscriptionIdentityStore");
|
|
8343
|
+
async function applyFingerprintConfig(config, credentialStore) {
|
|
8344
|
+
const store = (0, import_SubscriptionIdentityStore3.getSharedIdentityStore)();
|
|
8345
|
+
const enabled = config?.enabled === true;
|
|
8346
|
+
store.configure({ enabled, ua: config?.ua ?? null });
|
|
8347
|
+
if (!enabled) {
|
|
8348
|
+
store.setPersistence(null);
|
|
8349
|
+
return;
|
|
8350
|
+
}
|
|
8351
|
+
await seedIdentities(store, credentialStore);
|
|
8352
|
+
store.setPersistence({
|
|
8353
|
+
persist: (providerId, accountId, identity) => {
|
|
8354
|
+
void credentialStore.setAccountIdentity(providerId, accountId, identity).catch(() => {
|
|
8355
|
+
});
|
|
8356
|
+
}
|
|
8357
|
+
});
|
|
8358
|
+
}
|
|
8359
|
+
async function seedIdentities(store, credentialStore) {
|
|
8360
|
+
let config;
|
|
8361
|
+
try {
|
|
8362
|
+
config = await credentialStore.getFullConfig();
|
|
8363
|
+
} catch {
|
|
8364
|
+
return;
|
|
8365
|
+
}
|
|
8366
|
+
for (const provider of Object.keys(DAEMON_PROVIDER_KEYS)) {
|
|
8367
|
+
for (const account of listAccounts(config, provider)) {
|
|
8368
|
+
if (account.identity) store.seed(provider, account.id, account.identity);
|
|
8369
|
+
}
|
|
8370
|
+
}
|
|
8371
|
+
}
|
|
8372
|
+
|
|
8373
|
+
// src/commands/start.ts
|
|
5498
8374
|
async function runStart(argv) {
|
|
5499
8375
|
const { values } = (0, import_node_util7.parseArgs)({
|
|
5500
8376
|
args: argv,
|
|
@@ -5519,19 +8395,45 @@ async function runStart(argv) {
|
|
|
5519
8395
|
const daemon = buildDaemon(config, paths);
|
|
5520
8396
|
await daemon.llmConfig.ready();
|
|
5521
8397
|
await daemon.providerProxy.start();
|
|
5522
|
-
const serverConfig = await (0,
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
endpoints: serverConfig.endpoints,
|
|
5527
|
-
port: serverConfig.port
|
|
8398
|
+
const serverConfig = await (0, import_outbound_api7.loadServerConfig)(daemon.settingsStore);
|
|
8399
|
+
(0, import_SubscriptionAccountHealth3.getSharedAccountHealth)().configure({
|
|
8400
|
+
overloadEnabled: serverConfig.accountHealth?.overloadCooldownEnabled,
|
|
8401
|
+
overloadTtlMs: serverConfig.accountHealth?.overloadCooldownMs
|
|
5528
8402
|
});
|
|
8403
|
+
try {
|
|
8404
|
+
await daemon.outboundApiServer.applyConfig({
|
|
8405
|
+
enabled: true,
|
|
8406
|
+
networkBinding: serverConfig.networkBinding,
|
|
8407
|
+
endpoints: serverConfig.endpoints,
|
|
8408
|
+
port: serverConfig.port,
|
|
8409
|
+
userMessageQueue: serverConfig.userMessageQueue,
|
|
8410
|
+
concurrencyQueue: serverConfig.concurrencyQueue,
|
|
8411
|
+
// voucher-redemption #9: carry the persisted flag so `POST /redeem` works on
|
|
8412
|
+
// boot when the operator has enabled the product.
|
|
8413
|
+
voucher: serverConfig.voucher
|
|
8414
|
+
});
|
|
8415
|
+
} catch (err5) {
|
|
8416
|
+
if (err5 instanceof import_outbound_api7.OutboundApiConfigError) {
|
|
8417
|
+
console.warn(`[outbound] not started \u2014 incomplete model configuration: ${err5.message}`);
|
|
8418
|
+
} else {
|
|
8419
|
+
throw err5;
|
|
8420
|
+
}
|
|
8421
|
+
}
|
|
5529
8422
|
let dashboardUrl = null;
|
|
5530
8423
|
if (!values["no-dashboard"]) {
|
|
5531
8424
|
await daemon.adminServer.start();
|
|
5532
8425
|
dashboardUrl = daemon.adminServer.getStatus().url;
|
|
5533
8426
|
}
|
|
5534
8427
|
daemon.tokenRefreshScheduler.start();
|
|
8428
|
+
daemon.accountHealthSweeper.start();
|
|
8429
|
+
if (serverConfig.accountProbe) {
|
|
8430
|
+
daemon.accountHealthProbeScheduler.configure(serverConfig.accountProbe);
|
|
8431
|
+
}
|
|
8432
|
+
daemon.accountHealthProbeScheduler.start();
|
|
8433
|
+
applyWebhookConfig(serverConfig.webhook);
|
|
8434
|
+
applyAuditConfig(serverConfig.audit);
|
|
8435
|
+
applyBillingConfig(serverConfig.billing);
|
|
8436
|
+
await applyFingerprintConfig(serverConfig.fingerprint, daemon.credentialStore);
|
|
5535
8437
|
const status = daemon.outboundApiServer.getStatus();
|
|
5536
8438
|
console.info("omnicross daemon is running.");
|
|
5537
8439
|
if (dashboardUrl) console.info(` dashboard : ${dashboardUrl}`);
|