@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.js
CHANGED
|
@@ -270,6 +270,70 @@ var SecretBox = class {
|
|
|
270
270
|
};
|
|
271
271
|
|
|
272
272
|
// src/secrets/secretFields.ts
|
|
273
|
+
function urlHasInlineCredential(url) {
|
|
274
|
+
try {
|
|
275
|
+
const u = new URL(url);
|
|
276
|
+
return u.username.length > 0 || u.password.length > 0;
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function transformProxyConfig(cfg, fn) {
|
|
282
|
+
if ("url" in cfg) {
|
|
283
|
+
if (isEnvelope(cfg.url) || urlHasInlineCredential(cfg.url)) {
|
|
284
|
+
return { url: fn(cfg.url) };
|
|
285
|
+
}
|
|
286
|
+
return cfg;
|
|
287
|
+
}
|
|
288
|
+
if (typeof cfg.password === "string" && cfg.password.length > 0) {
|
|
289
|
+
return { ...cfg, password: fn(cfg.password) };
|
|
290
|
+
}
|
|
291
|
+
return cfg;
|
|
292
|
+
}
|
|
293
|
+
function transformOutboundProxy(proxy, fn) {
|
|
294
|
+
const next = {};
|
|
295
|
+
if (proxy.global) next.global = transformProxyConfig(proxy.global, fn);
|
|
296
|
+
if (proxy.byProvider) {
|
|
297
|
+
const byProvider = {};
|
|
298
|
+
for (const [key, value] of Object.entries(proxy.byProvider)) {
|
|
299
|
+
byProvider[key] = transformProxyConfig(value, fn);
|
|
300
|
+
}
|
|
301
|
+
next.byProvider = byProvider;
|
|
302
|
+
}
|
|
303
|
+
return next;
|
|
304
|
+
}
|
|
305
|
+
function encryptProxySegment(proxy, box) {
|
|
306
|
+
return transformOutboundProxy(proxy, (v) => box.encryptMaybe(v));
|
|
307
|
+
}
|
|
308
|
+
function decryptProxySegment(proxy, box) {
|
|
309
|
+
return transformOutboundProxy(proxy, (v) => box.decryptMaybe(v));
|
|
310
|
+
}
|
|
311
|
+
function transformWebhookSegment(webhook, fn) {
|
|
312
|
+
return {
|
|
313
|
+
...webhook,
|
|
314
|
+
destinations: webhook.destinations.map(
|
|
315
|
+
(d) => typeof d.secret === "string" && d.secret.length > 0 ? { ...d, secret: fn(d.secret) } : d
|
|
316
|
+
)
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function encryptWebhookSegment(webhook, box) {
|
|
320
|
+
return transformWebhookSegment(webhook, (v) => box.encryptMaybe(v));
|
|
321
|
+
}
|
|
322
|
+
function decryptWebhookSegment(webhook, box) {
|
|
323
|
+
return transformWebhookSegment(webhook, (v) => box.decryptMaybe(v));
|
|
324
|
+
}
|
|
325
|
+
function transformBillingSegment(billing, fn) {
|
|
326
|
+
if (typeof billing.secret === "string" && billing.secret.length > 0) {
|
|
327
|
+
return { ...billing, secret: fn(billing.secret) };
|
|
328
|
+
}
|
|
329
|
+
return billing;
|
|
330
|
+
}
|
|
331
|
+
function encryptBillingSegment(billing, box) {
|
|
332
|
+
return transformBillingSegment(billing, (v) => box.encryptMaybe(v));
|
|
333
|
+
}
|
|
334
|
+
function decryptBillingSegment(billing, box) {
|
|
335
|
+
return transformBillingSegment(billing, (v) => box.decryptMaybe(v));
|
|
336
|
+
}
|
|
273
337
|
function transformProvider(provider, fn) {
|
|
274
338
|
const next = { ...provider, apiKey: fn(provider.apiKey) };
|
|
275
339
|
if (provider.apiKeys) {
|
|
@@ -293,6 +357,17 @@ function transformConfigSecrets(cfg, fn) {
|
|
|
293
357
|
if (cfg.admin && typeof cfg.admin.token === "string" && cfg.admin.token.length > 0) {
|
|
294
358
|
next.admin = { ...cfg.admin, token: fn(cfg.admin.token) };
|
|
295
359
|
}
|
|
360
|
+
const proxy = cfg.server?.proxy;
|
|
361
|
+
const webhook = cfg.server?.webhook;
|
|
362
|
+
const billing = cfg.server?.billing;
|
|
363
|
+
if (cfg.server && (proxy?.global || proxy?.byProvider || webhook || billing?.secret)) {
|
|
364
|
+
next.server = { ...cfg.server };
|
|
365
|
+
if (proxy && (proxy.global || proxy.byProvider)) {
|
|
366
|
+
next.server.proxy = transformOutboundProxy(proxy, fn);
|
|
367
|
+
}
|
|
368
|
+
if (webhook) next.server.webhook = transformWebhookSegment(webhook, fn);
|
|
369
|
+
if (billing?.secret) next.server.billing = transformBillingSegment(billing, fn);
|
|
370
|
+
}
|
|
296
371
|
return next;
|
|
297
372
|
}
|
|
298
373
|
function encryptConfigSecrets(cfg, box) {
|
|
@@ -330,7 +405,7 @@ function transformTokens(tokens, fn) {
|
|
|
330
405
|
if (Array.isArray(accounts)) {
|
|
331
406
|
bag[accountsKey] = accounts.map((entry) => {
|
|
332
407
|
if (entry && typeof entry === "object" && "tokens" in entry && entry.tokens && typeof entry.tokens === "object") {
|
|
333
|
-
|
|
408
|
+
const nextEntry = {
|
|
334
409
|
...entry,
|
|
335
410
|
tokens: transformTokenBlock(
|
|
336
411
|
entry.tokens,
|
|
@@ -338,6 +413,11 @@ function transformTokens(tokens, fn) {
|
|
|
338
413
|
fn
|
|
339
414
|
)
|
|
340
415
|
};
|
|
416
|
+
const proxy = entry.proxy;
|
|
417
|
+
if (proxy && typeof proxy === "object") {
|
|
418
|
+
nextEntry.proxy = transformProxyConfig(proxy, fn);
|
|
419
|
+
}
|
|
420
|
+
return nextEntry;
|
|
341
421
|
}
|
|
342
422
|
return entry;
|
|
343
423
|
});
|
|
@@ -372,6 +452,17 @@ function resolveAdminConfig(admin) {
|
|
|
372
452
|
token: typeof admin?.token === "string" && admin.token.length > 0 ? admin.token : void 0
|
|
373
453
|
};
|
|
374
454
|
}
|
|
455
|
+
function validateLogging(raw) {
|
|
456
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
457
|
+
const l = raw;
|
|
458
|
+
const out = {};
|
|
459
|
+
if (l["level"] === "error" || l["level"] === "warn" || l["level"] === "info" || l["level"] === "debug") {
|
|
460
|
+
out.level = l["level"];
|
|
461
|
+
}
|
|
462
|
+
if (l["format"] === "text" || l["format"] === "json") out.format = l["format"];
|
|
463
|
+
if (typeof l["file"] === "string" && l["file"].length > 0) out.file = l["file"];
|
|
464
|
+
return out.level !== void 0 || out.format !== void 0 || out.file !== void 0 ? out : void 0;
|
|
465
|
+
}
|
|
375
466
|
var VALID_FORMATS = ["openai", "anthropic", "gemini"];
|
|
376
467
|
function validateApiKeys(raw) {
|
|
377
468
|
if (!Array.isArray(raw)) return void 0;
|
|
@@ -546,7 +637,8 @@ function validateConfig(raw) {
|
|
|
546
637
|
const providers = providersRaw.map((p, i) => validateProvider(p, i));
|
|
547
638
|
const server = obj["server"];
|
|
548
639
|
const admin = validateAdmin(obj["admin"]);
|
|
549
|
-
|
|
640
|
+
const logging = validateLogging(obj["logging"]);
|
|
641
|
+
return { providers, server, admin, logging };
|
|
550
642
|
}
|
|
551
643
|
var secretBox = null;
|
|
552
644
|
function setSecretBox(box) {
|
|
@@ -578,6 +670,9 @@ import { dirname as dirname2, join as join2 } from "path";
|
|
|
578
670
|
function defaultKeysPath(configPath) {
|
|
579
671
|
return join2(dirname2(configPath), "keys.json");
|
|
580
672
|
}
|
|
673
|
+
function defaultVouchersPath(configPath) {
|
|
674
|
+
return join2(dirname2(configPath), "vouchers.json");
|
|
675
|
+
}
|
|
581
676
|
function defaultTokensPath(configPath) {
|
|
582
677
|
return join2(dirname2(configPath), "tokens.json");
|
|
583
678
|
}
|
|
@@ -587,6 +682,12 @@ function defaultPricingPath(configPath) {
|
|
|
587
682
|
function defaultUsageEventsPath(configPath) {
|
|
588
683
|
return join2(dirname2(configPath), "usage-events.jsonl");
|
|
589
684
|
}
|
|
685
|
+
function defaultAuditDir(configPath) {
|
|
686
|
+
return join2(dirname2(configPath), "audit");
|
|
687
|
+
}
|
|
688
|
+
function defaultBillingDir(configPath) {
|
|
689
|
+
return join2(dirname2(configPath), "billing");
|
|
690
|
+
}
|
|
590
691
|
function resolveSecretBox(masterKeyFilePath) {
|
|
591
692
|
return new SecretBox(() => resolveMasterKey({ keyFilePath: masterKeyFilePath }));
|
|
592
693
|
}
|
|
@@ -686,6 +787,45 @@ var JsonOutboundKeyDb = class {
|
|
|
686
787
|
return true;
|
|
687
788
|
});
|
|
688
789
|
}
|
|
790
|
+
async outboundApiKeysSetMaxConcurrency(id, maxConcurrency) {
|
|
791
|
+
return this.mutateRow(id, (row) => {
|
|
792
|
+
if (row.revokedAt !== null) return false;
|
|
793
|
+
if (maxConcurrency === null) delete row.maxConcurrency;
|
|
794
|
+
else row.maxConcurrency = maxConcurrency;
|
|
795
|
+
return true;
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
async outboundApiKeysSetPolicy(id, policy) {
|
|
799
|
+
return this.mutateRow(id, (row) => {
|
|
800
|
+
if (row.revokedAt !== null) return false;
|
|
801
|
+
applyPolicyField(row, "expiresAt", policy.expiresAt);
|
|
802
|
+
applyPolicyField(row, "activationDays", policy.activationDays);
|
|
803
|
+
applyPolicyField(row, "dailyCostLimitUsd", policy.dailyCostLimitUsd);
|
|
804
|
+
applyPolicyField(row, "totalCostLimitUsd", policy.totalCostLimitUsd);
|
|
805
|
+
applyPolicyField(row, "weeklyCostLimitUsd", policy.weeklyCostLimitUsd);
|
|
806
|
+
applyPolicyField(row, "rateLimitMaxRequests", policy.rateLimitMaxRequests);
|
|
807
|
+
applyPolicyField(row, "rateLimitWindowMs", policy.rateLimitWindowMs);
|
|
808
|
+
if (policy.activationMode === null) delete row.activationMode;
|
|
809
|
+
else if (policy.activationMode !== void 0) row.activationMode = policy.activationMode;
|
|
810
|
+
if (policy.enableModelRestriction === null) delete row.enableModelRestriction;
|
|
811
|
+
else if (policy.enableModelRestriction !== void 0) {
|
|
812
|
+
row.enableModelRestriction = policy.enableModelRestriction;
|
|
813
|
+
}
|
|
814
|
+
if (policy.restrictionMode === null) delete row.restrictionMode;
|
|
815
|
+
else if (policy.restrictionMode !== void 0) row.restrictionMode = policy.restrictionMode;
|
|
816
|
+
if (policy.restrictedModels === null) delete row.restrictedModels;
|
|
817
|
+
else if (policy.restrictedModels !== void 0) row.restrictedModels = policy.restrictedModels;
|
|
818
|
+
return true;
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
async outboundApiKeysMarkActivated(id, activatedAt) {
|
|
822
|
+
return this.mutateRow(id, (row) => {
|
|
823
|
+
if (row.revokedAt !== null) return false;
|
|
824
|
+
if (row.activatedAt != null) return false;
|
|
825
|
+
row.activatedAt = activatedAt;
|
|
826
|
+
return true;
|
|
827
|
+
});
|
|
828
|
+
}
|
|
689
829
|
/** Apply `fn` to the row with `id`, persisting when it returns true. */
|
|
690
830
|
mutateRow(id, fn) {
|
|
691
831
|
const rows = this.readRows();
|
|
@@ -709,6 +849,11 @@ var JsonOutboundKeyDb = class {
|
|
|
709
849
|
writeFileSync3(this.keysPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
|
|
710
850
|
}
|
|
711
851
|
};
|
|
852
|
+
function applyPolicyField(row, field, value) {
|
|
853
|
+
if (value === void 0) return;
|
|
854
|
+
if (value === null) delete row[field];
|
|
855
|
+
else row[field] = value;
|
|
856
|
+
}
|
|
712
857
|
|
|
713
858
|
// src/commands/keys.ts
|
|
714
859
|
async function runKeys(argv) {
|
|
@@ -765,8 +910,8 @@ async function keysRevoke(db, id) {
|
|
|
765
910
|
|
|
766
911
|
// src/commands/launch.ts
|
|
767
912
|
import { spawn as spawn2 } from "child_process";
|
|
768
|
-
import { existsSync as
|
|
769
|
-
import { delimiter as delimiter2, join as
|
|
913
|
+
import { existsSync as existsSync15 } from "fs";
|
|
914
|
+
import { delimiter as delimiter2, join as join10 } from "path";
|
|
770
915
|
import { parseArgs as parseArgs3 } from "util";
|
|
771
916
|
import {
|
|
772
917
|
buildChatCliLaunchConfig as buildChatCliLaunchConfig2,
|
|
@@ -776,18 +921,26 @@ import {
|
|
|
776
921
|
} from "@omnicross/cli-launcher";
|
|
777
922
|
|
|
778
923
|
// src/bootstrap.ts
|
|
924
|
+
import { accessSync, constants as fsConstants, existsSync as existsSync14 } from "fs";
|
|
925
|
+
import { DEFAULT_AUDIT_CONFIG } from "@omnicross/contracts/audit-types";
|
|
926
|
+
import { DEFAULT_BILLING_CONFIG } from "@omnicross/contracts/billing-types";
|
|
779
927
|
import { getGeminiCodeAssistProjectResolver } from "@omnicross/core/auth/GeminiCodeAssistProjectResolver";
|
|
780
928
|
import { ApiKeyPoolService } from "@omnicross/core/completion/ApiKeyPoolService";
|
|
781
929
|
import {
|
|
782
930
|
__resetOutboundApiServerForTests,
|
|
931
|
+
DEFAULT_ACCOUNT_PROBE,
|
|
783
932
|
getOutboundApiServer
|
|
784
933
|
} from "@omnicross/core/outbound-api";
|
|
785
934
|
import { setSubscriptionRegistryForOutbound } from "@omnicross/core/outbound-api/subscriptionRegistryPort";
|
|
935
|
+
import { getSharedAccountHealth as getSharedAccountHealth2 } from "@omnicross/core/pipeline/SubscriptionAccountHealth";
|
|
936
|
+
import { fetchUpstream as fetchUpstream6, setUpstreamProxyResolver } from "@omnicross/core/pipeline/upstreamFetch";
|
|
937
|
+
import { __resetSharedIdentityStoreForTests } from "@omnicross/core/provider-proxy/identity/SubscriptionIdentityStore";
|
|
786
938
|
import { setGeminiCodeAssistResolver } from "@omnicross/core/ports/gemini-code-assist-resolver";
|
|
787
939
|
import {
|
|
788
940
|
__resetProviderProxyForTests,
|
|
789
941
|
getProviderProxy
|
|
790
942
|
} from "@omnicross/core/provider-proxy";
|
|
943
|
+
import { KeySpendTracker } from "@omnicross/core/outbound-api";
|
|
791
944
|
import { PricingEngine, UsageRecorder } from "@omnicross/core/usage";
|
|
792
945
|
import {
|
|
793
946
|
setSubscriptionAccountService,
|
|
@@ -807,6 +960,7 @@ var CodexOAuthSessionStore = class {
|
|
|
807
960
|
ttlMs;
|
|
808
961
|
sessions = /* @__PURE__ */ new Map();
|
|
809
962
|
activeSessionId = null;
|
|
963
|
+
aborters = /* @__PURE__ */ new Map();
|
|
810
964
|
/** Whether a codex sign-in is currently in flight (port 1455 held). */
|
|
811
965
|
isBusy() {
|
|
812
966
|
this.sweep();
|
|
@@ -818,13 +972,22 @@ var CodexOAuthSessionStore = class {
|
|
|
818
972
|
const sessionId = crypto.randomBytes(24).toString("base64url");
|
|
819
973
|
this.sessions.set(sessionId, { status: "pending", createdAt: Date.now() });
|
|
820
974
|
this.activeSessionId = sessionId;
|
|
821
|
-
|
|
975
|
+
const controller = new AbortController();
|
|
976
|
+
this.aborters.set(sessionId, controller);
|
|
977
|
+
return { sessionId, signal: controller.signal };
|
|
822
978
|
}
|
|
823
979
|
/** Settle a flow (done/error) + free the active slot. */
|
|
824
980
|
settle(sessionId, status, error) {
|
|
825
981
|
const prior = this.sessions.get(sessionId);
|
|
826
982
|
this.sessions.set(sessionId, { status, error, createdAt: prior?.createdAt ?? Date.now() });
|
|
827
983
|
if (this.activeSessionId === sessionId) this.activeSessionId = null;
|
|
984
|
+
this.aborters.delete(sessionId);
|
|
985
|
+
}
|
|
986
|
+
cancel(sessionId) {
|
|
987
|
+
if (!this.sessions.has(sessionId)) return false;
|
|
988
|
+
this.aborters.get(sessionId)?.abort();
|
|
989
|
+
this.settle(sessionId, "error", "login: cancelled");
|
|
990
|
+
return true;
|
|
828
991
|
}
|
|
829
992
|
/** Read a flow's status (token-free), or null when unknown/expired. */
|
|
830
993
|
get(sessionId) {
|
|
@@ -853,13 +1016,13 @@ function handleCodexOAuthStart(deps) {
|
|
|
853
1016
|
);
|
|
854
1017
|
}
|
|
855
1018
|
const { authUrl, codeVerifier, state } = codexOAuth.generateAuthParams();
|
|
856
|
-
const sessionId = deps.codexSessions.begin();
|
|
857
|
-
void runCodexLoopback(sessionId, codeVerifier, state, deps);
|
|
1019
|
+
const { sessionId, signal } = deps.codexSessions.begin();
|
|
1020
|
+
void runCodexLoopback(sessionId, codeVerifier, state, signal, deps);
|
|
858
1021
|
return { status: 200, body: { authUrl, sessionId } };
|
|
859
1022
|
}
|
|
860
|
-
async function runCodexLoopback(sessionId, codeVerifier, state, deps) {
|
|
1023
|
+
async function runCodexLoopback(sessionId, codeVerifier, state, signal, deps) {
|
|
861
1024
|
try {
|
|
862
|
-
const code = await deps.codexAwaitLoopback(state);
|
|
1025
|
+
const code = await deps.codexAwaitLoopback(state, void 0, signal);
|
|
863
1026
|
const result = await codexOAuth.exchangeCodeForTokens(
|
|
864
1027
|
{ authorizationCode: code, codeVerifier, state },
|
|
865
1028
|
deps.oauthExchangeFetch
|
|
@@ -881,6 +1044,10 @@ async function runCodexLoopback(sessionId, codeVerifier, state, deps) {
|
|
|
881
1044
|
deps.codexSessions.settle(sessionId, "error", reason);
|
|
882
1045
|
}
|
|
883
1046
|
}
|
|
1047
|
+
function handleCodexOAuthCancel(sessionId, deps) {
|
|
1048
|
+
if (!deps.codexSessions.cancel(sessionId)) return err(404, "unknown or expired codex sign-in session");
|
|
1049
|
+
return { status: 200, body: { ok: true } };
|
|
1050
|
+
}
|
|
884
1051
|
function handleCodexOAuthStatus(sessionId, deps) {
|
|
885
1052
|
const s = deps.codexSessions.get(sessionId);
|
|
886
1053
|
if (!s) return err(404, "unknown or expired codex sign-in session");
|
|
@@ -890,15 +1057,139 @@ function handleCodexOAuthStatus(sessionId, deps) {
|
|
|
890
1057
|
// src/admin/AdminServer.ts
|
|
891
1058
|
import { timingSafeEqual } from "crypto";
|
|
892
1059
|
import http2 from "http";
|
|
1060
|
+
import {
|
|
1061
|
+
healthHttpStatus
|
|
1062
|
+
} from "@omnicross/contracts/health-logging-types";
|
|
1063
|
+
|
|
1064
|
+
// src/admin/accountProbesApi.ts
|
|
1065
|
+
function handleAccountProbes(res, reader) {
|
|
1066
|
+
const accounts = reader ? reader.getAllHistory() : [];
|
|
1067
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1068
|
+
res.end(JSON.stringify({ accounts }));
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// src/admin/auditQueryApi.ts
|
|
1072
|
+
function intParam(value) {
|
|
1073
|
+
if (value === null || value.trim() === "") return void 0;
|
|
1074
|
+
const n = Number(value);
|
|
1075
|
+
return Number.isFinite(n) ? Math.trunc(n) : void 0;
|
|
1076
|
+
}
|
|
1077
|
+
function handleAuditQuery(req, res, reader) {
|
|
1078
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1079
|
+
const query = {};
|
|
1080
|
+
const keyId = url.searchParams.get("keyId");
|
|
1081
|
+
if (keyId && keyId.trim()) query.keyId = keyId.trim();
|
|
1082
|
+
const from = intParam(url.searchParams.get("from"));
|
|
1083
|
+
if (from !== void 0) query.from = from;
|
|
1084
|
+
const to = intParam(url.searchParams.get("to"));
|
|
1085
|
+
if (to !== void 0) query.to = to;
|
|
1086
|
+
const limit = intParam(url.searchParams.get("limit"));
|
|
1087
|
+
if (limit !== void 0) query.limit = limit;
|
|
1088
|
+
const records = reader ? reader(query) : [];
|
|
1089
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1090
|
+
res.end(JSON.stringify({ records }));
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// src/admin/billingStatusApi.ts
|
|
1094
|
+
function handleBillingStatus(res, reader) {
|
|
1095
|
+
const status = reader ? reader() : { total: 0, delivered: 0, pending: 0 };
|
|
1096
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1097
|
+
res.end(JSON.stringify({ status }));
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// src/webhook/webhookRuntime.ts
|
|
1101
|
+
import { setWebhookSink } from "@omnicross/core/pipeline/webhookEmit";
|
|
1102
|
+
var dispatcher = null;
|
|
1103
|
+
var health = null;
|
|
1104
|
+
var unsubscribers = [];
|
|
1105
|
+
var wired = false;
|
|
1106
|
+
function setWebhookRuntime(d, h) {
|
|
1107
|
+
dispatcher = d;
|
|
1108
|
+
health = h;
|
|
1109
|
+
}
|
|
1110
|
+
function applyWebhookConfig(config) {
|
|
1111
|
+
if (!dispatcher) return;
|
|
1112
|
+
dispatcher.setConfig(config);
|
|
1113
|
+
const shouldWire = config?.enabled === true && (config?.destinations.length ?? 0) > 0;
|
|
1114
|
+
if (shouldWire && !wired) {
|
|
1115
|
+
const active = dispatcher;
|
|
1116
|
+
setWebhookSink((event) => active.emit(event));
|
|
1117
|
+
if (health) {
|
|
1118
|
+
unsubscribers.push(
|
|
1119
|
+
health.onRecovered(
|
|
1120
|
+
(e) => active.emit({ kind: "account.recovery", at: e.at, providerId: e.providerId, accountId: e.accountId })
|
|
1121
|
+
)
|
|
1122
|
+
);
|
|
1123
|
+
unsubscribers.push(
|
|
1124
|
+
health.onAnomaly(
|
|
1125
|
+
(e) => active.emit({
|
|
1126
|
+
kind: "account.anomaly",
|
|
1127
|
+
at: e.at,
|
|
1128
|
+
providerId: e.providerId,
|
|
1129
|
+
accountId: e.accountId,
|
|
1130
|
+
state: e.state
|
|
1131
|
+
})
|
|
1132
|
+
)
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
wired = true;
|
|
1136
|
+
} else if (!shouldWire && wired) {
|
|
1137
|
+
teardown();
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
async function deliverWebhookTest(destinationId) {
|
|
1141
|
+
if (!dispatcher) return { ok: false, error: "webhook dispatcher not wired" };
|
|
1142
|
+
return dispatcher.deliverTest(destinationId);
|
|
1143
|
+
}
|
|
1144
|
+
function teardown() {
|
|
1145
|
+
setWebhookSink(null);
|
|
1146
|
+
for (const unsub of unsubscribers) unsub();
|
|
1147
|
+
unsubscribers = [];
|
|
1148
|
+
wired = false;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// src/admin/webhookTestApi.ts
|
|
1152
|
+
function readJsonBody(req) {
|
|
1153
|
+
return new Promise((resolve) => {
|
|
1154
|
+
const chunks = [];
|
|
1155
|
+
req.on("data", (c) => chunks.push(c));
|
|
1156
|
+
req.on("end", () => {
|
|
1157
|
+
try {
|
|
1158
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1159
|
+
const parsed = raw ? JSON.parse(raw) : {};
|
|
1160
|
+
resolve(parsed && typeof parsed === "object" ? parsed : {});
|
|
1161
|
+
} catch {
|
|
1162
|
+
resolve({});
|
|
1163
|
+
}
|
|
1164
|
+
});
|
|
1165
|
+
req.on("error", () => resolve({}));
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
async function handleWebhookTest(req, res) {
|
|
1169
|
+
const body = await readJsonBody(req);
|
|
1170
|
+
const destinationId = body["destinationId"];
|
|
1171
|
+
if (typeof destinationId !== "string" || !destinationId.trim()) {
|
|
1172
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1173
|
+
res.end(JSON.stringify({ error: { type: "bad_request", message: "destinationId is required" } }));
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
const result = await deliverWebhookTest(destinationId.trim());
|
|
1177
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1178
|
+
res.end(JSON.stringify({ result }));
|
|
1179
|
+
}
|
|
893
1180
|
|
|
894
1181
|
// src/admin/adminApi.ts
|
|
895
1182
|
import http from "http";
|
|
896
1183
|
import {
|
|
897
1184
|
createNamedKey as createNamedKey2,
|
|
898
|
-
|
|
1185
|
+
isKindMappedEndpoint,
|
|
1186
|
+
loadServerConfig as loadServerConfig2,
|
|
899
1187
|
mergeServerConfig,
|
|
900
|
-
|
|
1188
|
+
normalizeProxyConfig,
|
|
1189
|
+
saveServerConfig,
|
|
1190
|
+
validateServerModelConfig
|
|
901
1191
|
} from "@omnicross/core/outbound-api";
|
|
1192
|
+
import { fetchUpstream } from "@omnicross/core/pipeline/upstreamFetch";
|
|
902
1193
|
|
|
903
1194
|
// src/pool/resolveEnvKey.ts
|
|
904
1195
|
function resolveEnvKey(rawKey) {
|
|
@@ -993,6 +1284,163 @@ function listMappablePresets() {
|
|
|
993
1284
|
return { mappable, excluded };
|
|
994
1285
|
}
|
|
995
1286
|
|
|
1287
|
+
// src/proxy/sanitizeProxy.ts
|
|
1288
|
+
function sanitizeProxyConfig(cfg) {
|
|
1289
|
+
if ("url" in cfg) {
|
|
1290
|
+
let endpoint;
|
|
1291
|
+
let username;
|
|
1292
|
+
let hasPassword = false;
|
|
1293
|
+
try {
|
|
1294
|
+
const u = new URL(cfg.url);
|
|
1295
|
+
endpoint = u.port ? `${u.hostname}:${u.port}` : u.hostname;
|
|
1296
|
+
username = u.username ? decodeURIComponent(u.username) : void 0;
|
|
1297
|
+
hasPassword = u.password.length > 0;
|
|
1298
|
+
} catch {
|
|
1299
|
+
}
|
|
1300
|
+
return { kind: "url", endpoint, username, hasPassword };
|
|
1301
|
+
}
|
|
1302
|
+
return {
|
|
1303
|
+
kind: cfg.type,
|
|
1304
|
+
endpoint: `${cfg.host}:${cfg.port}`,
|
|
1305
|
+
username: cfg.username,
|
|
1306
|
+
hasPassword: typeof cfg.password === "string" && cfg.password.length > 0
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
function redactProxyConfig(cfg) {
|
|
1310
|
+
if ("url" in cfg) {
|
|
1311
|
+
try {
|
|
1312
|
+
const u = new URL(cfg.url);
|
|
1313
|
+
if (u.password) u.password = "";
|
|
1314
|
+
return { url: u.toString() };
|
|
1315
|
+
} catch {
|
|
1316
|
+
return cfg;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
const { password: _password, ...rest } = cfg;
|
|
1320
|
+
return rest;
|
|
1321
|
+
}
|
|
1322
|
+
function redactOutboundProxy(proxy) {
|
|
1323
|
+
const out = {};
|
|
1324
|
+
if (proxy.global) out.global = redactProxyConfig(proxy.global);
|
|
1325
|
+
if (proxy.byProvider) {
|
|
1326
|
+
const byProvider = {};
|
|
1327
|
+
for (const [key, value] of Object.entries(proxy.byProvider)) {
|
|
1328
|
+
byProvider[key] = redactProxyConfig(value);
|
|
1329
|
+
}
|
|
1330
|
+
out.byProvider = byProvider;
|
|
1331
|
+
}
|
|
1332
|
+
return out;
|
|
1333
|
+
}
|
|
1334
|
+
function preserveProxyConfigSecret(incoming, current) {
|
|
1335
|
+
if (!current) return incoming;
|
|
1336
|
+
if ("url" in incoming) {
|
|
1337
|
+
if ("url" in current) {
|
|
1338
|
+
try {
|
|
1339
|
+
const inU = new URL(incoming.url);
|
|
1340
|
+
const curU = new URL(current.url);
|
|
1341
|
+
if (!inU.password && curU.password) {
|
|
1342
|
+
inU.password = curU.password;
|
|
1343
|
+
return { url: inU.toString() };
|
|
1344
|
+
}
|
|
1345
|
+
} catch {
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
return incoming;
|
|
1349
|
+
}
|
|
1350
|
+
if ("url" in current) return incoming;
|
|
1351
|
+
const blank = incoming.password === void 0 || incoming.password === "";
|
|
1352
|
+
if (blank && typeof current.password === "string" && current.password.length > 0) {
|
|
1353
|
+
return { ...incoming, password: current.password };
|
|
1354
|
+
}
|
|
1355
|
+
return incoming;
|
|
1356
|
+
}
|
|
1357
|
+
function preserveOutboundProxySecrets(incoming, current) {
|
|
1358
|
+
const out = {};
|
|
1359
|
+
if (incoming.global) out.global = preserveProxyConfigSecret(incoming.global, current?.global);
|
|
1360
|
+
if (incoming.byProvider) {
|
|
1361
|
+
const byProvider = {};
|
|
1362
|
+
for (const [key, value] of Object.entries(incoming.byProvider)) {
|
|
1363
|
+
byProvider[key] = preserveProxyConfigSecret(value, current?.byProvider?.[key]);
|
|
1364
|
+
}
|
|
1365
|
+
out.byProvider = byProvider;
|
|
1366
|
+
}
|
|
1367
|
+
return out;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// src/proxy/upstreamProxyResolver.ts
|
|
1371
|
+
import {
|
|
1372
|
+
bumpUpstreamProxyGeneration
|
|
1373
|
+
} from "@omnicross/core/pipeline/upstreamFetch";
|
|
1374
|
+
var serverProxy;
|
|
1375
|
+
function setServerProxyConfig(proxy) {
|
|
1376
|
+
serverProxy = proxy;
|
|
1377
|
+
bumpUpstreamProxyGeneration();
|
|
1378
|
+
}
|
|
1379
|
+
function getServerProxyConfig() {
|
|
1380
|
+
return serverProxy;
|
|
1381
|
+
}
|
|
1382
|
+
var envProxyLoggedFor;
|
|
1383
|
+
function maskProxyUrl(url) {
|
|
1384
|
+
return url.replace(/\/\/[^/@]*@/, "//***@");
|
|
1385
|
+
}
|
|
1386
|
+
function hostFromCtx(ctx) {
|
|
1387
|
+
if (!ctx.url) return void 0;
|
|
1388
|
+
try {
|
|
1389
|
+
return new URL(ctx.url).hostname.toLowerCase();
|
|
1390
|
+
} catch {
|
|
1391
|
+
return void 0;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
function isLoopbackHost(host) {
|
|
1395
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]" || host.startsWith("127.") || host.endsWith(".localhost");
|
|
1396
|
+
}
|
|
1397
|
+
function noProxyMatches(noProxy, host) {
|
|
1398
|
+
if (!noProxy) return false;
|
|
1399
|
+
for (const raw of noProxy.split(",")) {
|
|
1400
|
+
const entry = raw.trim().toLowerCase();
|
|
1401
|
+
if (!entry) continue;
|
|
1402
|
+
if (entry === "*") return true;
|
|
1403
|
+
const bare = entry.startsWith(".") ? entry.slice(1) : entry;
|
|
1404
|
+
if (host === bare || host.endsWith(`.${bare}`)) return true;
|
|
1405
|
+
}
|
|
1406
|
+
return false;
|
|
1407
|
+
}
|
|
1408
|
+
function resolveEnvProxy(ctx, env = process.env) {
|
|
1409
|
+
const raw = env.HTTPS_PROXY ?? env.https_proxy ?? env.ALL_PROXY ?? env.all_proxy ?? env.HTTP_PROXY ?? env.http_proxy;
|
|
1410
|
+
if (!raw || !raw.trim()) return void 0;
|
|
1411
|
+
const host = hostFromCtx(ctx);
|
|
1412
|
+
if (host && (isLoopbackHost(host) || noProxyMatches(env.NO_PROXY ?? env.no_proxy, host))) {
|
|
1413
|
+
return void 0;
|
|
1414
|
+
}
|
|
1415
|
+
const url = raw.trim();
|
|
1416
|
+
if (envProxyLoggedFor !== url) {
|
|
1417
|
+
envProxyLoggedFor = url;
|
|
1418
|
+
console.info(`[upstream-proxy] routing upstream egress through the environment proxy: ${maskProxyUrl(url)}`);
|
|
1419
|
+
}
|
|
1420
|
+
return { url };
|
|
1421
|
+
}
|
|
1422
|
+
function createUpstreamProxyResolver(src = {}) {
|
|
1423
|
+
const readServer = src.getServerProxy ?? getServerProxyConfig;
|
|
1424
|
+
return (ctx) => {
|
|
1425
|
+
const host = hostFromCtx(ctx);
|
|
1426
|
+
if (host) {
|
|
1427
|
+
if (isLoopbackHost(host)) return void 0;
|
|
1428
|
+
const env = src.env ?? process.env;
|
|
1429
|
+
if (noProxyMatches(env.NO_PROXY ?? env.no_proxy, host)) return void 0;
|
|
1430
|
+
}
|
|
1431
|
+
if (src.getAccountProxy && ctx.providerId && ctx.accountId) {
|
|
1432
|
+
const account = src.getAccountProxy(ctx.providerId, ctx.accountId);
|
|
1433
|
+
if (account) return account;
|
|
1434
|
+
}
|
|
1435
|
+
const server = readServer();
|
|
1436
|
+
if (ctx.providerId && server?.byProvider?.[ctx.providerId]) {
|
|
1437
|
+
return server.byProvider[ctx.providerId];
|
|
1438
|
+
}
|
|
1439
|
+
if (server?.global) return server.global;
|
|
1440
|
+
return resolveEnvProxy(ctx, src.env);
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
|
|
996
1444
|
// src/admin/accountsOAuth.ts
|
|
997
1445
|
import { claudeOAuth, geminiOAuth } from "@omnicross/subscriptions";
|
|
998
1446
|
|
|
@@ -1115,6 +1563,24 @@ function validateTokenBody(providerId, body) {
|
|
|
1115
1563
|
return null;
|
|
1116
1564
|
}
|
|
1117
1565
|
}
|
|
1566
|
+
function validateSupportedModelsBody(raw) {
|
|
1567
|
+
if (raw === null || raw === void 0) return { ok: true, value: void 0 };
|
|
1568
|
+
if (Array.isArray(raw)) {
|
|
1569
|
+
if (raw.length === 0) return { ok: false };
|
|
1570
|
+
if (!raw.every((x) => typeof x === "string" && x.trim().length > 0)) return { ok: false };
|
|
1571
|
+
return { ok: true, value: raw };
|
|
1572
|
+
}
|
|
1573
|
+
if (typeof raw === "object") {
|
|
1574
|
+
const entries = Object.entries(raw);
|
|
1575
|
+
if (entries.length === 0) return { ok: false };
|
|
1576
|
+
const valid = entries.every(
|
|
1577
|
+
([k, v]) => k.trim().length > 0 && typeof v === "string" && v.trim().length > 0
|
|
1578
|
+
);
|
|
1579
|
+
if (!valid) return { ok: false };
|
|
1580
|
+
return { ok: true, value: Object.fromEntries(entries) };
|
|
1581
|
+
}
|
|
1582
|
+
return { ok: false };
|
|
1583
|
+
}
|
|
1118
1584
|
async function statusEntryFor(reader, providerId) {
|
|
1119
1585
|
const all = await reader.listAll();
|
|
1120
1586
|
return all.find((a) => a.providerId === providerId) ?? null;
|
|
@@ -1396,6 +1862,434 @@ async function handleCliLaunch(cli, body, ctx) {
|
|
|
1396
1862
|
return { status: 200, body: { sessionId: id, providerId: target.providerId, model: target.model } };
|
|
1397
1863
|
}
|
|
1398
1864
|
|
|
1865
|
+
// src/admin/auditConfigBody.ts
|
|
1866
|
+
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1867
|
+
function validateAuditSegment(patch) {
|
|
1868
|
+
const errors = [];
|
|
1869
|
+
const audit = patch.audit;
|
|
1870
|
+
if (audit === void 0) return errors;
|
|
1871
|
+
if (!isPlainObject(audit)) {
|
|
1872
|
+
errors.push("audit must be an object");
|
|
1873
|
+
return errors;
|
|
1874
|
+
}
|
|
1875
|
+
for (const flag of ["enabled", "captureBodies", "trustForwardedFor"]) {
|
|
1876
|
+
if (audit[flag] !== void 0 && typeof audit[flag] !== "boolean") {
|
|
1877
|
+
errors.push(`audit.${flag} must be a boolean`);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
const maxBodyBytes = audit["maxBodyBytes"];
|
|
1881
|
+
if (maxBodyBytes !== void 0 && (typeof maxBodyBytes !== "number" || !Number.isFinite(maxBodyBytes) || maxBodyBytes < 0)) {
|
|
1882
|
+
errors.push("audit.maxBodyBytes must be a non-negative number");
|
|
1883
|
+
}
|
|
1884
|
+
const retentionDays = audit["retentionDays"];
|
|
1885
|
+
if (retentionDays !== void 0 && (typeof retentionDays !== "number" || !Number.isFinite(retentionDays) || retentionDays < 0)) {
|
|
1886
|
+
errors.push("audit.retentionDays must be a non-negative number");
|
|
1887
|
+
}
|
|
1888
|
+
return errors;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// src/admin/billingConfigBody.ts
|
|
1892
|
+
var BILLING_SECRET_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
1893
|
+
var isPlainObject2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1894
|
+
function validateBillingSegment(patch) {
|
|
1895
|
+
const errors = [];
|
|
1896
|
+
const billing = patch.billing;
|
|
1897
|
+
if (billing === void 0) return errors;
|
|
1898
|
+
if (!isPlainObject2(billing)) {
|
|
1899
|
+
errors.push("billing must be an object");
|
|
1900
|
+
return errors;
|
|
1901
|
+
}
|
|
1902
|
+
if (billing["enabled"] !== void 0 && typeof billing["enabled"] !== "boolean") {
|
|
1903
|
+
errors.push("billing.enabled must be a boolean");
|
|
1904
|
+
}
|
|
1905
|
+
if (billing["endpoint"] !== void 0 && typeof billing["endpoint"] !== "string") {
|
|
1906
|
+
errors.push("billing.endpoint must be a string");
|
|
1907
|
+
}
|
|
1908
|
+
if (billing["secret"] !== void 0 && typeof billing["secret"] !== "string") {
|
|
1909
|
+
errors.push("billing.secret must be a string");
|
|
1910
|
+
}
|
|
1911
|
+
const maxRetryAgeMs = billing["maxRetryAgeMs"];
|
|
1912
|
+
if (maxRetryAgeMs !== void 0 && (typeof maxRetryAgeMs !== "number" || !Number.isFinite(maxRetryAgeMs) || maxRetryAgeMs < 0)) {
|
|
1913
|
+
errors.push("billing.maxRetryAgeMs must be a non-negative number");
|
|
1914
|
+
}
|
|
1915
|
+
return errors;
|
|
1916
|
+
}
|
|
1917
|
+
function redactBillingConfig(billing) {
|
|
1918
|
+
if (typeof billing.secret === "string" && billing.secret.length > 0) {
|
|
1919
|
+
return { ...billing, secret: BILLING_SECRET_MASK };
|
|
1920
|
+
}
|
|
1921
|
+
return billing;
|
|
1922
|
+
}
|
|
1923
|
+
function preserveBillingSecret(incoming, current) {
|
|
1924
|
+
const isMaskedOrBlank = incoming.secret === void 0 || incoming.secret === "" || incoming.secret === BILLING_SECRET_MASK;
|
|
1925
|
+
if (isMaskedOrBlank) {
|
|
1926
|
+
if (current?.secret) return { ...incoming, secret: current.secret };
|
|
1927
|
+
const { secret: _secret, ...rest } = incoming;
|
|
1928
|
+
return rest;
|
|
1929
|
+
}
|
|
1930
|
+
return incoming;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// src/admin/dashboard.ts
|
|
1934
|
+
function startOfLocalDayMs(ts) {
|
|
1935
|
+
const d = new Date(ts);
|
|
1936
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
1937
|
+
}
|
|
1938
|
+
function accountProviderId(entry) {
|
|
1939
|
+
if (!entry || typeof entry !== "object") return null;
|
|
1940
|
+
const e = entry;
|
|
1941
|
+
if (typeof e["providerId"] === "string" && e["providerId"]) return e["providerId"];
|
|
1942
|
+
if (typeof e["provider"] === "string" && e["provider"]) return e["provider"];
|
|
1943
|
+
return null;
|
|
1944
|
+
}
|
|
1945
|
+
async function handleDashboard(deps) {
|
|
1946
|
+
const now = Date.now();
|
|
1947
|
+
const today = await deps.usageRecorder.getTotals({ startTs: startOfLocalDayMs(now), endTs: now });
|
|
1948
|
+
const total = await deps.usageRecorder.getTotals({ startTs: 0, endTs: now });
|
|
1949
|
+
const providerList = loadConfig(deps.configPath).providers;
|
|
1950
|
+
const providers = {
|
|
1951
|
+
total: providerList.length,
|
|
1952
|
+
enabled: providerList.filter((p) => p.enabled !== false).length
|
|
1953
|
+
};
|
|
1954
|
+
const keys = await deps.keyDb.outboundApiKeysList();
|
|
1955
|
+
const outboundKeys = {
|
|
1956
|
+
total: keys.length,
|
|
1957
|
+
active: keys.filter((k) => k.enabled && k.revokedAt === null).length
|
|
1958
|
+
};
|
|
1959
|
+
const accountsList = await deps.subscriptionAccounts.listAll();
|
|
1960
|
+
const byProvider = {};
|
|
1961
|
+
for (const entry of accountsList) {
|
|
1962
|
+
const providerId = accountProviderId(entry);
|
|
1963
|
+
if (providerId) byProvider[providerId] = (byProvider[providerId] ?? 0) + 1;
|
|
1964
|
+
}
|
|
1965
|
+
const accounts = { total: accountsList.length, byProvider };
|
|
1966
|
+
const status = deps.outboundApiServer.getStatus();
|
|
1967
|
+
const server = {
|
|
1968
|
+
running: status.running,
|
|
1969
|
+
port: status.port,
|
|
1970
|
+
uptimeMs: Math.round(process.uptime() * 1e3)
|
|
1971
|
+
};
|
|
1972
|
+
const summary = { today, total, providers, outboundKeys, accounts, server, generatedAt: now };
|
|
1973
|
+
return { status: 200, body: summary };
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// src/admin/keyPolicyBody.ts
|
|
1977
|
+
function parseKeyPolicyBody(body) {
|
|
1978
|
+
const policy = {};
|
|
1979
|
+
if ("activationMode" in body) {
|
|
1980
|
+
const m = body["activationMode"];
|
|
1981
|
+
if (m === null) policy.activationMode = null;
|
|
1982
|
+
else if (m === "fixed" || m === "activation") policy.activationMode = m;
|
|
1983
|
+
else return { ok: false, message: "activationMode must be 'fixed', 'activation', or null" };
|
|
1984
|
+
}
|
|
1985
|
+
const numericFields = [
|
|
1986
|
+
{ key: "expiresAt", min: 0 },
|
|
1987
|
+
{ key: "activationDays", min: 1, integer: true },
|
|
1988
|
+
{ key: "dailyCostLimitUsd", min: 0 },
|
|
1989
|
+
{ key: "totalCostLimitUsd", min: 0 },
|
|
1990
|
+
{ key: "weeklyCostLimitUsd", min: 0 },
|
|
1991
|
+
{ key: "rateLimitMaxRequests", min: 0, integer: true },
|
|
1992
|
+
{ key: "rateLimitWindowMs", min: 1 }
|
|
1993
|
+
];
|
|
1994
|
+
for (const { key, min, integer } of numericFields) {
|
|
1995
|
+
if (!(key in body)) continue;
|
|
1996
|
+
const v = body[key];
|
|
1997
|
+
if (v === null) {
|
|
1998
|
+
policy[key] = null;
|
|
1999
|
+
continue;
|
|
2000
|
+
}
|
|
2001
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < min || integer && !Number.isInteger(v)) {
|
|
2002
|
+
return {
|
|
2003
|
+
ok: false,
|
|
2004
|
+
message: `${key} must be ${integer ? "an integer" : "a number"} >= ${min} or null`
|
|
2005
|
+
};
|
|
2006
|
+
}
|
|
2007
|
+
policy[key] = v;
|
|
2008
|
+
}
|
|
2009
|
+
if ("enableModelRestriction" in body) {
|
|
2010
|
+
const v = body["enableModelRestriction"];
|
|
2011
|
+
if (v === null) policy.enableModelRestriction = null;
|
|
2012
|
+
else if (typeof v === "boolean") policy.enableModelRestriction = v;
|
|
2013
|
+
else return { ok: false, message: "enableModelRestriction must be a boolean or null" };
|
|
2014
|
+
}
|
|
2015
|
+
if ("restrictionMode" in body) {
|
|
2016
|
+
const v = body["restrictionMode"];
|
|
2017
|
+
if (v === null) policy.restrictionMode = null;
|
|
2018
|
+
else if (v === "blacklist" || v === "allowlist") policy.restrictionMode = v;
|
|
2019
|
+
else return { ok: false, message: "restrictionMode must be 'blacklist', 'allowlist', or null" };
|
|
2020
|
+
}
|
|
2021
|
+
if ("restrictedModels" in body) {
|
|
2022
|
+
const v = body["restrictedModels"];
|
|
2023
|
+
if (v === null) {
|
|
2024
|
+
policy.restrictedModels = null;
|
|
2025
|
+
} else if (Array.isArray(v) && v.every((e) => typeof e === "string")) {
|
|
2026
|
+
policy.restrictedModels = v.map((e) => e.trim()).filter((e) => e !== "");
|
|
2027
|
+
} else {
|
|
2028
|
+
return { ok: false, message: "restrictedModels must be an array of strings or null" };
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
return { ok: true, policy };
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// src/admin/voucherAdmin.ts
|
|
2035
|
+
import {
|
|
2036
|
+
generateVoucherCode,
|
|
2037
|
+
hashVoucherCode,
|
|
2038
|
+
loadServerConfig,
|
|
2039
|
+
newVoucherId,
|
|
2040
|
+
toVoucherInfo,
|
|
2041
|
+
voucherCodePrefix
|
|
2042
|
+
} from "@omnicross/core/outbound-api";
|
|
2043
|
+
function writeJson(res, status, body) {
|
|
2044
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
2045
|
+
res.end(JSON.stringify(body));
|
|
2046
|
+
}
|
|
2047
|
+
function writeErr(res, status, message) {
|
|
2048
|
+
writeJson(res, status, { error: { type: "voucher_error", message } });
|
|
2049
|
+
}
|
|
2050
|
+
function readJsonBody2(req) {
|
|
2051
|
+
return new Promise((resolve, reject) => {
|
|
2052
|
+
const chunks = [];
|
|
2053
|
+
req.on("data", (c) => chunks.push(c));
|
|
2054
|
+
req.on("end", () => {
|
|
2055
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
2056
|
+
if (!raw.trim()) return resolve({});
|
|
2057
|
+
try {
|
|
2058
|
+
const parsed = JSON.parse(raw);
|
|
2059
|
+
resolve(parsed && typeof parsed === "object" ? parsed : {});
|
|
2060
|
+
} catch {
|
|
2061
|
+
reject(new Error("invalid-json"));
|
|
2062
|
+
}
|
|
2063
|
+
});
|
|
2064
|
+
req.on("error", reject);
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
function optPositive(value, integer) {
|
|
2068
|
+
if (value === void 0 || value === null) return { ok: true, value: void 0 };
|
|
2069
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return { ok: false };
|
|
2070
|
+
if (integer && !Number.isInteger(value)) return { ok: false };
|
|
2071
|
+
return { ok: true, value };
|
|
2072
|
+
}
|
|
2073
|
+
function parseVoucherCreateBody(body) {
|
|
2074
|
+
const type = body["type"];
|
|
2075
|
+
if (type !== "credit" && type !== "renewal") {
|
|
2076
|
+
return { ok: false, message: "type must be 'credit' or 'renewal'" };
|
|
2077
|
+
}
|
|
2078
|
+
const maxTotal = optPositive(body["maxTotalCostLimitUsd"], false);
|
|
2079
|
+
if (!maxTotal.ok) return { ok: false, message: "maxTotalCostLimitUsd must be a positive number" };
|
|
2080
|
+
const maxDays = optPositive(body["maxExpiryDays"], true);
|
|
2081
|
+
if (!maxDays.ok) return { ok: false, message: "maxExpiryDays must be a positive integer" };
|
|
2082
|
+
const input = { type };
|
|
2083
|
+
if (maxTotal.value !== void 0) input.maxTotalCostLimitUsd = maxTotal.value;
|
|
2084
|
+
if (maxDays.value !== void 0) input.maxExpiryDays = maxDays.value;
|
|
2085
|
+
if (type === "credit") {
|
|
2086
|
+
const credit = optPositive(body["creditUsd"], false);
|
|
2087
|
+
if (!credit.ok || credit.value === void 0) {
|
|
2088
|
+
return { ok: false, message: "creditUsd must be a positive number for a credit card" };
|
|
2089
|
+
}
|
|
2090
|
+
input.creditUsd = credit.value;
|
|
2091
|
+
} else {
|
|
2092
|
+
const days = optPositive(body["renewalDays"], true);
|
|
2093
|
+
if (!days.ok || days.value === void 0) {
|
|
2094
|
+
return { ok: false, message: "renewalDays must be a positive integer for a renewal card" };
|
|
2095
|
+
}
|
|
2096
|
+
input.renewalDays = days.value;
|
|
2097
|
+
}
|
|
2098
|
+
return { ok: true, input };
|
|
2099
|
+
}
|
|
2100
|
+
async function voucherEnabled(deps) {
|
|
2101
|
+
const config = await loadServerConfig(deps.settingsStore);
|
|
2102
|
+
return config.voucher?.enabled === true;
|
|
2103
|
+
}
|
|
2104
|
+
async function handleVoucher(req, res, method, rest, deps) {
|
|
2105
|
+
const voucherDb = deps.voucherDb;
|
|
2106
|
+
if (!voucherDb) return writeErr(res, 501, "Voucher feature is not available");
|
|
2107
|
+
if (method === "GET" && rest.length === 0) {
|
|
2108
|
+
const rows = await voucherDb.voucherList();
|
|
2109
|
+
return writeJson(res, 200, { vouchers: rows.map(toVoucherInfo) });
|
|
2110
|
+
}
|
|
2111
|
+
if (method === "POST" && rest.length === 0) {
|
|
2112
|
+
if (!await voucherEnabled(deps)) return writeErr(res, 403, "Voucher feature is disabled");
|
|
2113
|
+
let body;
|
|
2114
|
+
try {
|
|
2115
|
+
body = await readJsonBody2(req);
|
|
2116
|
+
} catch {
|
|
2117
|
+
return writeErr(res, 400, "Invalid JSON in request body");
|
|
2118
|
+
}
|
|
2119
|
+
const parsed = parseVoucherCreateBody(body);
|
|
2120
|
+
if (!parsed.ok) return writeErr(res, 400, parsed.message);
|
|
2121
|
+
const code = generateVoucherCode();
|
|
2122
|
+
const created = await voucherDb.voucherCreate({
|
|
2123
|
+
id: newVoucherId(),
|
|
2124
|
+
codeHash: hashVoucherCode(code),
|
|
2125
|
+
codePrefix: voucherCodePrefix(code),
|
|
2126
|
+
...parsed.input
|
|
2127
|
+
});
|
|
2128
|
+
return writeJson(res, 201, {
|
|
2129
|
+
id: created.id,
|
|
2130
|
+
codePrefix: created.codePrefix,
|
|
2131
|
+
type: created.type,
|
|
2132
|
+
createdAt: created.createdAt,
|
|
2133
|
+
// `plaintextOnce` is the ONLY place the full code crosses the wire (D3).
|
|
2134
|
+
plaintextOnce: code
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
const id = rest[0];
|
|
2138
|
+
if (method === "POST" && id && rest[1] === "revoke") {
|
|
2139
|
+
if (!await voucherEnabled(deps)) return writeErr(res, 403, "Voucher feature is disabled");
|
|
2140
|
+
const ok = await voucherDb.voucherRevokeCas(id, Date.now());
|
|
2141
|
+
return writeJson(res, ok ? 200 : 409, { ok });
|
|
2142
|
+
}
|
|
2143
|
+
return writeErr(res, 405, `method ${method} not allowed on voucher`);
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
// src/admin/webhookConfigBody.ts
|
|
2147
|
+
import {
|
|
2148
|
+
WEBHOOK_DESTINATION_TYPES,
|
|
2149
|
+
WEBHOOK_EVENT_KINDS
|
|
2150
|
+
} from "@omnicross/contracts/webhook-types";
|
|
2151
|
+
var WEBHOOK_SECRET_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
2152
|
+
var isPlainObject3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2153
|
+
function validateWebhookSegment(patch) {
|
|
2154
|
+
const errors = [];
|
|
2155
|
+
const webhook = patch.webhook;
|
|
2156
|
+
if (webhook === void 0) return errors;
|
|
2157
|
+
if (!isPlainObject3(webhook)) {
|
|
2158
|
+
errors.push("webhook must be an object");
|
|
2159
|
+
return errors;
|
|
2160
|
+
}
|
|
2161
|
+
if (typeof webhook["enabled"] !== "boolean") {
|
|
2162
|
+
errors.push("webhook.enabled must be a boolean");
|
|
2163
|
+
}
|
|
2164
|
+
const destinations = webhook["destinations"];
|
|
2165
|
+
if (destinations !== void 0 && !Array.isArray(destinations)) {
|
|
2166
|
+
errors.push("webhook.destinations must be an array");
|
|
2167
|
+
return errors;
|
|
2168
|
+
}
|
|
2169
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
2170
|
+
for (const [i, raw] of (Array.isArray(destinations) ? destinations : []).entries()) {
|
|
2171
|
+
if (!isPlainObject3(raw)) {
|
|
2172
|
+
errors.push(`webhook.destinations[${i}] must be an object`);
|
|
2173
|
+
continue;
|
|
2174
|
+
}
|
|
2175
|
+
const id = raw["id"];
|
|
2176
|
+
if (typeof id !== "string" || !id.trim()) {
|
|
2177
|
+
errors.push(`webhook.destinations[${i}].id must be a non-empty string`);
|
|
2178
|
+
} else if (seenIds.has(id.trim())) {
|
|
2179
|
+
errors.push(`webhook.destinations[${i}].id '${id.trim()}' is duplicated`);
|
|
2180
|
+
} else {
|
|
2181
|
+
seenIds.add(id.trim());
|
|
2182
|
+
}
|
|
2183
|
+
if (typeof raw["type"] !== "string" || !WEBHOOK_DESTINATION_TYPES.includes(raw["type"])) {
|
|
2184
|
+
errors.push(`webhook.destinations[${i}].type must be one of ${WEBHOOK_DESTINATION_TYPES.join(", ")}`);
|
|
2185
|
+
}
|
|
2186
|
+
if (typeof raw["url"] !== "string" || !raw["url"].trim()) {
|
|
2187
|
+
errors.push(`webhook.destinations[${i}].url must be a non-empty string`);
|
|
2188
|
+
}
|
|
2189
|
+
if (raw["secret"] !== void 0 && typeof raw["secret"] !== "string") {
|
|
2190
|
+
errors.push(`webhook.destinations[${i}].secret must be a string`);
|
|
2191
|
+
}
|
|
2192
|
+
if (raw["enabled"] !== void 0 && typeof raw["enabled"] !== "boolean") {
|
|
2193
|
+
errors.push(`webhook.destinations[${i}].enabled must be a boolean`);
|
|
2194
|
+
}
|
|
2195
|
+
const events = raw["events"];
|
|
2196
|
+
if (events !== void 0) {
|
|
2197
|
+
if (!Array.isArray(events)) {
|
|
2198
|
+
errors.push(`webhook.destinations[${i}].events must be an array`);
|
|
2199
|
+
} else {
|
|
2200
|
+
for (const e of events) {
|
|
2201
|
+
if (typeof e !== "string" || !WEBHOOK_EVENT_KINDS.includes(e)) {
|
|
2202
|
+
errors.push(`webhook.destinations[${i}].events contains an unknown kind '${String(e)}'`);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
return errors;
|
|
2209
|
+
}
|
|
2210
|
+
function redactWebhookConfig(webhook) {
|
|
2211
|
+
return {
|
|
2212
|
+
...webhook,
|
|
2213
|
+
destinations: webhook.destinations.map(
|
|
2214
|
+
(d) => typeof d.secret === "string" && d.secret.length > 0 ? { ...d, secret: WEBHOOK_SECRET_MASK } : d
|
|
2215
|
+
)
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
function preserveWebhookSecrets(incoming, current) {
|
|
2219
|
+
const currentById = /* @__PURE__ */ new Map();
|
|
2220
|
+
for (const d of current?.destinations ?? []) currentById.set(d.id, d);
|
|
2221
|
+
return {
|
|
2222
|
+
...incoming,
|
|
2223
|
+
destinations: incoming.destinations.map((d) => {
|
|
2224
|
+
const isMaskedOrBlank = d.secret === void 0 || d.secret === "" || d.secret === WEBHOOK_SECRET_MASK;
|
|
2225
|
+
if (isMaskedOrBlank) {
|
|
2226
|
+
const prev = currentById.get(d.id);
|
|
2227
|
+
if (prev?.secret) return { ...d, secret: prev.secret };
|
|
2228
|
+
const { secret: _secret, ...rest } = d;
|
|
2229
|
+
return rest;
|
|
2230
|
+
}
|
|
2231
|
+
return d;
|
|
2232
|
+
})
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
// src/audit/auditRuntime.ts
|
|
2237
|
+
import { setAuditCaptureConfig, setAuditSink } from "@omnicross/core/pipeline/auditSink";
|
|
2238
|
+
var writer = null;
|
|
2239
|
+
var sweeper = null;
|
|
2240
|
+
function setAuditRuntime(w, s) {
|
|
2241
|
+
writer = w;
|
|
2242
|
+
sweeper = s;
|
|
2243
|
+
}
|
|
2244
|
+
function applyAuditConfig(config) {
|
|
2245
|
+
const enabled = config?.enabled === true && writer !== null;
|
|
2246
|
+
if (enabled && config) {
|
|
2247
|
+
setAuditCaptureConfig(config);
|
|
2248
|
+
const activeWriter = writer;
|
|
2249
|
+
setAuditSink((record) => activeWriter.record(record));
|
|
2250
|
+
if (sweeper) {
|
|
2251
|
+
sweeper.configure(config);
|
|
2252
|
+
sweeper.start();
|
|
2253
|
+
}
|
|
2254
|
+
} else {
|
|
2255
|
+
setAuditCaptureConfig(null);
|
|
2256
|
+
setAuditSink(null);
|
|
2257
|
+
if (sweeper) {
|
|
2258
|
+
if (config) sweeper.configure(config);
|
|
2259
|
+
sweeper.dispose();
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
// src/billing/billingRuntime.ts
|
|
2265
|
+
import { setBillingCaptureConfig, setBillingSink } from "@omnicross/core/pipeline/billingEmit";
|
|
2266
|
+
var publisher = null;
|
|
2267
|
+
var sweeper2 = null;
|
|
2268
|
+
function setBillingRuntime(p, s) {
|
|
2269
|
+
publisher = p;
|
|
2270
|
+
sweeper2 = s;
|
|
2271
|
+
}
|
|
2272
|
+
function applyBillingConfig(config) {
|
|
2273
|
+
const enabled = config?.enabled === true && publisher !== null;
|
|
2274
|
+
if (enabled && config) {
|
|
2275
|
+
const activePublisher = publisher;
|
|
2276
|
+
activePublisher.setConfig(config);
|
|
2277
|
+
setBillingCaptureConfig(config);
|
|
2278
|
+
setBillingSink((event) => activePublisher.record(event));
|
|
2279
|
+
if (sweeper2) {
|
|
2280
|
+
sweeper2.configure(config);
|
|
2281
|
+
sweeper2.start();
|
|
2282
|
+
}
|
|
2283
|
+
} else {
|
|
2284
|
+
setBillingCaptureConfig(null);
|
|
2285
|
+
setBillingSink(null);
|
|
2286
|
+
if (sweeper2) {
|
|
2287
|
+
if (config) sweeper2.configure(config);
|
|
2288
|
+
sweeper2.dispose();
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
|
|
1399
2293
|
// src/ports/account-multi.ts
|
|
1400
2294
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
1401
2295
|
var PROVIDER_KEYS = {
|
|
@@ -1507,6 +2401,9 @@ function getAccountById(config, p, id) {
|
|
|
1507
2401
|
const account = getAccounts(config, p).find((a) => a.id === id);
|
|
1508
2402
|
return account ? { id: account.id, tokens: account.tokens } : void 0;
|
|
1509
2403
|
}
|
|
2404
|
+
function getAccountProxy(config, p, id) {
|
|
2405
|
+
return getAccounts(config, p).find((a) => a.id === id)?.proxy;
|
|
2406
|
+
}
|
|
1510
2407
|
function getActiveAccount(config, p) {
|
|
1511
2408
|
const active = getAccounts(config, p).find((a) => a.id === getActiveId(config, p));
|
|
1512
2409
|
return active ? { id: active.id, tokens: active.tokens } : void 0;
|
|
@@ -1547,7 +2444,17 @@ function sanitizeAccounts(config, p) {
|
|
|
1547
2444
|
isSetupToken: t.isSetupToken,
|
|
1548
2445
|
hasAccessToken: !!(t.accessToken || t.apiKey),
|
|
1549
2446
|
isActive: a.id === activeId,
|
|
1550
|
-
|
|
2447
|
+
// Scheduling metadata (subscription-account-scheduling): editable priority
|
|
2448
|
+
// (default 50 shown when unset) + display-only lastUsedAt. Secret-free.
|
|
2449
|
+
priority: a.priority,
|
|
2450
|
+
lastUsedAt: a.lastUsedAt,
|
|
2451
|
+
syncWarning: t.syncWarning,
|
|
2452
|
+
// Per-account proxy (upstream-proxy): masked view — password → hasPassword,
|
|
2453
|
+
// userinfo stripped. The plaintext password is NEVER projected.
|
|
2454
|
+
proxy: a.proxy ? sanitizeProxyConfig(a.proxy) : void 0,
|
|
2455
|
+
// Per-account model support / remap (subscription-account-model-map): model
|
|
2456
|
+
// ids are not token material → carried through verbatim for the editor.
|
|
2457
|
+
supportedModels: a.supportedModels
|
|
1551
2458
|
};
|
|
1552
2459
|
});
|
|
1553
2460
|
}
|
|
@@ -1561,8 +2468,79 @@ function renameAccount(config, p, id, label) {
|
|
|
1561
2468
|
);
|
|
1562
2469
|
return { ok: true };
|
|
1563
2470
|
}
|
|
1564
|
-
function
|
|
1565
|
-
|
|
2471
|
+
function setAccountPriority(config, p, id, priority) {
|
|
2472
|
+
const accounts = getAccounts(config, p);
|
|
2473
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2474
|
+
setAccounts(
|
|
2475
|
+
config,
|
|
2476
|
+
p,
|
|
2477
|
+
accounts.map((a) => a.id === id ? { ...a, priority } : a)
|
|
2478
|
+
);
|
|
2479
|
+
return { ok: true };
|
|
2480
|
+
}
|
|
2481
|
+
function setAccountProxy(config, p, id, proxy) {
|
|
2482
|
+
const accounts = getAccounts(config, p);
|
|
2483
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2484
|
+
setAccounts(
|
|
2485
|
+
config,
|
|
2486
|
+
p,
|
|
2487
|
+
accounts.map((a) => {
|
|
2488
|
+
if (a.id !== id) return a;
|
|
2489
|
+
if (!proxy) {
|
|
2490
|
+
const { proxy: _drop, ...rest } = a;
|
|
2491
|
+
return rest;
|
|
2492
|
+
}
|
|
2493
|
+
return { ...a, proxy };
|
|
2494
|
+
})
|
|
2495
|
+
);
|
|
2496
|
+
return { ok: true };
|
|
2497
|
+
}
|
|
2498
|
+
function setAccountSupportedModels(config, p, id, supportedModels) {
|
|
2499
|
+
const accounts = getAccounts(config, p);
|
|
2500
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2501
|
+
setAccounts(
|
|
2502
|
+
config,
|
|
2503
|
+
p,
|
|
2504
|
+
accounts.map((a) => {
|
|
2505
|
+
if (a.id !== id) return a;
|
|
2506
|
+
if (supportedModels === void 0) {
|
|
2507
|
+
const { supportedModels: _drop, ...rest } = a;
|
|
2508
|
+
return rest;
|
|
2509
|
+
}
|
|
2510
|
+
return { ...a, supportedModels };
|
|
2511
|
+
})
|
|
2512
|
+
);
|
|
2513
|
+
return { ok: true };
|
|
2514
|
+
}
|
|
2515
|
+
function setAccountLastUsed(config, p, id, iso) {
|
|
2516
|
+
const accounts = getAccounts(config, p);
|
|
2517
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2518
|
+
setAccounts(
|
|
2519
|
+
config,
|
|
2520
|
+
p,
|
|
2521
|
+
accounts.map((a) => a.id === id ? { ...a, lastUsedAt: iso } : a)
|
|
2522
|
+
);
|
|
2523
|
+
return { ok: true };
|
|
2524
|
+
}
|
|
2525
|
+
function setAccountIdentity(config, p, id, identity) {
|
|
2526
|
+
const accounts = getAccounts(config, p);
|
|
2527
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2528
|
+
setAccounts(
|
|
2529
|
+
config,
|
|
2530
|
+
p,
|
|
2531
|
+
accounts.map((a) => {
|
|
2532
|
+
if (a.id !== id) return a;
|
|
2533
|
+
if (identity === void 0) {
|
|
2534
|
+
const { identity: _drop, ...rest } = a;
|
|
2535
|
+
return rest;
|
|
2536
|
+
}
|
|
2537
|
+
return { ...a, identity };
|
|
2538
|
+
})
|
|
2539
|
+
);
|
|
2540
|
+
return { ok: true };
|
|
2541
|
+
}
|
|
2542
|
+
function clearProvider(config, p) {
|
|
2543
|
+
setBlock(config, p, void 0);
|
|
1566
2544
|
setAccounts(config, p, void 0);
|
|
1567
2545
|
setActiveId(config, p, void 0);
|
|
1568
2546
|
}
|
|
@@ -1826,6 +2804,12 @@ function parseRange(query) {
|
|
|
1826
2804
|
return { startTs, endTs };
|
|
1827
2805
|
}
|
|
1828
2806
|
var isRange = (v) => v.startTs !== void 0 && !("status" in v);
|
|
2807
|
+
var BUCKET_SPAN_MS = {
|
|
2808
|
+
hour: 36e5,
|
|
2809
|
+
day: 864e5,
|
|
2810
|
+
month: 28 * 864e5
|
|
2811
|
+
};
|
|
2812
|
+
var MAX_TIMESERIES_BUCKETS = 2e3;
|
|
1829
2813
|
async function handleUsageGet(view, query, deps) {
|
|
1830
2814
|
const range = parseRange(query);
|
|
1831
2815
|
if (!isRange(range)) return range;
|
|
@@ -1834,6 +2818,24 @@ async function handleUsageGet(view, query, deps) {
|
|
|
1834
2818
|
return { status: 200, body: await deps.usageRecorder.getTotals(range) };
|
|
1835
2819
|
case "by-model":
|
|
1836
2820
|
return { status: 200, body: await deps.usageRecorder.getByModel(range) };
|
|
2821
|
+
case "timeseries": {
|
|
2822
|
+
const bucket = query.get("bucket");
|
|
2823
|
+
if (bucket !== "hour" && bucket !== "day" && bucket !== "month") {
|
|
2824
|
+
return err4(400, "bucket must be one of 'hour', 'day', 'month'");
|
|
2825
|
+
}
|
|
2826
|
+
const now = Date.now();
|
|
2827
|
+
const clamped = { startTs: range.startTs, endTs: Math.min(range.endTs, now) };
|
|
2828
|
+
if (clamped.startTs < clamped.endTs) {
|
|
2829
|
+
const projected = Math.ceil((clamped.endTs - clamped.startTs) / BUCKET_SPAN_MS[bucket]) + 1;
|
|
2830
|
+
if (projected > MAX_TIMESERIES_BUCKETS) {
|
|
2831
|
+
return err4(
|
|
2832
|
+
400,
|
|
2833
|
+
`requested range projects ~${projected} '${bucket}' buckets (max ${MAX_TIMESERIES_BUCKETS}); narrow the range or use a coarser bucket`
|
|
2834
|
+
);
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
return { status: 200, body: await deps.usageRecorder.getTimeSeries(clamped, bucket) };
|
|
2838
|
+
}
|
|
1837
2839
|
case "by-api-key": {
|
|
1838
2840
|
const rows = await deps.usageRecorder.getByApiKey(range);
|
|
1839
2841
|
const labels = poolKeyLabels(loadConfig(deps.configPath));
|
|
@@ -1979,7 +2981,7 @@ function readBody(req) {
|
|
|
1979
2981
|
req.on("error", reject);
|
|
1980
2982
|
});
|
|
1981
2983
|
}
|
|
1982
|
-
async function
|
|
2984
|
+
async function readJsonBody3(req) {
|
|
1983
2985
|
const raw = await readBody(req);
|
|
1984
2986
|
if (!raw.trim()) return {};
|
|
1985
2987
|
try {
|
|
@@ -1989,12 +2991,12 @@ async function readJsonBody(req) {
|
|
|
1989
2991
|
return {};
|
|
1990
2992
|
}
|
|
1991
2993
|
}
|
|
1992
|
-
function
|
|
2994
|
+
function writeJson2(res, status, body) {
|
|
1993
2995
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1994
2996
|
res.end(JSON.stringify(body));
|
|
1995
2997
|
}
|
|
1996
2998
|
function writeJsonError(res, status, message) {
|
|
1997
|
-
|
|
2999
|
+
writeJson2(res, status, { error: { type: "admin_api_error", message } });
|
|
1998
3000
|
}
|
|
1999
3001
|
function maskProviderApiKey(apiKey) {
|
|
2000
3002
|
if (!apiKey) return "";
|
|
@@ -2010,7 +3012,23 @@ function toKeyInfo(row) {
|
|
|
2010
3012
|
enabled: row.enabled,
|
|
2011
3013
|
createdAt: row.createdAt,
|
|
2012
3014
|
lastUsedAt: row.lastUsedAt,
|
|
2013
|
-
revoked: row.revokedAt !== null
|
|
3015
|
+
revoked: row.revokedAt !== null,
|
|
3016
|
+
maxConcurrency: row.maxConcurrency,
|
|
3017
|
+
// Key-policy envelope (outbound-key-policy) — all secret-free scalar fields;
|
|
3018
|
+
// the UI reads them to render + pre-fill the policy editor.
|
|
3019
|
+
expiresAt: row.expiresAt,
|
|
3020
|
+
activationMode: row.activationMode,
|
|
3021
|
+
activationDays: row.activationDays,
|
|
3022
|
+
activatedAt: row.activatedAt,
|
|
3023
|
+
dailyCostLimitUsd: row.dailyCostLimitUsd,
|
|
3024
|
+
totalCostLimitUsd: row.totalCostLimitUsd,
|
|
3025
|
+
weeklyCostLimitUsd: row.weeklyCostLimitUsd,
|
|
3026
|
+
rateLimitMaxRequests: row.rateLimitMaxRequests,
|
|
3027
|
+
rateLimitWindowMs: row.rateLimitWindowMs,
|
|
3028
|
+
// Per-key model restriction (#6) — secret-free scalars the UI pre-fills.
|
|
3029
|
+
enableModelRestriction: row.enableModelRestriction,
|
|
3030
|
+
restrictionMode: row.restrictionMode,
|
|
3031
|
+
restrictedModels: row.restrictedModels
|
|
2014
3032
|
};
|
|
2015
3033
|
}
|
|
2016
3034
|
function toProviderView(row) {
|
|
@@ -2072,6 +3090,8 @@ async function handleAdminApi(req, res, path2, deps) {
|
|
|
2072
3090
|
return handlePresets(res, method);
|
|
2073
3091
|
case "keys":
|
|
2074
3092
|
return await handleKeys(req, res, method, rest, deps);
|
|
3093
|
+
case "voucher":
|
|
3094
|
+
return await handleVoucher(req, res, method, rest, deps);
|
|
2075
3095
|
case "server":
|
|
2076
3096
|
return await handleServer(req, res, method, deps);
|
|
2077
3097
|
case "accounts":
|
|
@@ -2088,6 +3108,8 @@ async function handleAdminApi(req, res, path2, deps) {
|
|
|
2088
3108
|
return await handleMigrationImport(req, res, method, deps);
|
|
2089
3109
|
case "usage":
|
|
2090
3110
|
return await handleUsage(req, res, method, rest, deps);
|
|
3111
|
+
case "dashboard":
|
|
3112
|
+
return await handleDashboardRoute(res, method, deps);
|
|
2091
3113
|
case "pricing":
|
|
2092
3114
|
return await handlePricing(req, res, method, rest, deps);
|
|
2093
3115
|
default:
|
|
@@ -2103,17 +3125,22 @@ function requestQuery(req) {
|
|
|
2103
3125
|
return new URLSearchParams(qIdx >= 0 ? raw.slice(qIdx + 1) : "");
|
|
2104
3126
|
}
|
|
2105
3127
|
function writeResult(res, result) {
|
|
2106
|
-
|
|
3128
|
+
writeJson2(res, result.status, result.body);
|
|
2107
3129
|
}
|
|
2108
3130
|
async function handleUsage(req, res, method, rest, deps) {
|
|
2109
3131
|
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on usage`);
|
|
2110
3132
|
return writeResult(res, await handleUsageGet(rest[0], requestQuery(req), deps));
|
|
2111
3133
|
}
|
|
3134
|
+
async function handleDashboardRoute(res, method, deps) {
|
|
3135
|
+
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on dashboard`);
|
|
3136
|
+
const result = await handleDashboard(deps);
|
|
3137
|
+
return writeJson2(res, result.status, result.body);
|
|
3138
|
+
}
|
|
2112
3139
|
async function handlePricing(req, res, method, rest, deps) {
|
|
2113
3140
|
if (rest.length === 0) {
|
|
2114
3141
|
if (method === "GET") return writeResult(res, await handlePricingList(deps));
|
|
2115
3142
|
if (method === "PUT") {
|
|
2116
|
-
return writeResult(res, await handlePricingUpsert(await
|
|
3143
|
+
return writeResult(res, await handlePricingUpsert(await readJsonBody3(req), deps));
|
|
2117
3144
|
}
|
|
2118
3145
|
if (method === "DELETE") {
|
|
2119
3146
|
return writeResult(res, await handlePricingDelete(requestQuery(req), deps));
|
|
@@ -2124,7 +3151,7 @@ async function handlePricing(req, res, method, rest, deps) {
|
|
|
2124
3151
|
return writeResult(res, await handlePricingFetchLatest(deps));
|
|
2125
3152
|
}
|
|
2126
3153
|
if (method === "POST" && rest.length === 1 && rest[0] === "resolve-conflicts") {
|
|
2127
|
-
return writeResult(res, await handlePricingResolveConflicts(await
|
|
3154
|
+
return writeResult(res, await handlePricingResolveConflicts(await readJsonBody3(req), deps));
|
|
2128
3155
|
}
|
|
2129
3156
|
return writeJsonError(res, 404, `unknown pricing route '${rest.join("/")}'`);
|
|
2130
3157
|
}
|
|
@@ -2138,15 +3165,15 @@ function migrationDeps(deps) {
|
|
|
2138
3165
|
}
|
|
2139
3166
|
async function handleMigrationExport(req, res, method, deps) {
|
|
2140
3167
|
if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on export`);
|
|
2141
|
-
const body = await
|
|
3168
|
+
const body = await readJsonBody3(req);
|
|
2142
3169
|
const result = await handleExport(body, migrationDeps(deps));
|
|
2143
|
-
return
|
|
3170
|
+
return writeJson2(res, result.status, result.body);
|
|
2144
3171
|
}
|
|
2145
3172
|
async function handleMigrationImport(req, res, method, deps) {
|
|
2146
3173
|
if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on import`);
|
|
2147
|
-
const body = await
|
|
3174
|
+
const body = await readJsonBody3(req);
|
|
2148
3175
|
const result = await handleImport(body, migrationDeps(deps));
|
|
2149
|
-
return
|
|
3176
|
+
return writeJson2(res, result.status, result.body);
|
|
2150
3177
|
}
|
|
2151
3178
|
async function handleProviders(req, res, method, rest, deps) {
|
|
2152
3179
|
const cfg = loadConfig(deps.configPath);
|
|
@@ -2177,13 +3204,13 @@ async function handleProviders(req, res, method, rest, deps) {
|
|
|
2177
3204
|
if (method === "GET" && rest.length === 2 && rest[1] === "reveal-key") {
|
|
2178
3205
|
const row = cfg.providers.find((p) => p.id === rest[0]);
|
|
2179
3206
|
if (!row) return writeJsonError(res, 404, `provider '${rest[0]}' not found`);
|
|
2180
|
-
return
|
|
3207
|
+
return writeJson2(res, 200, { apiKey: row.apiKey ?? "" });
|
|
2181
3208
|
}
|
|
2182
3209
|
if (method === "GET") {
|
|
2183
|
-
return
|
|
3210
|
+
return writeJson2(res, 200, { providers: cfg.providers.map(toProviderView) });
|
|
2184
3211
|
}
|
|
2185
3212
|
if (method === "POST") {
|
|
2186
|
-
const body = await
|
|
3213
|
+
const body = await readJsonBody3(req);
|
|
2187
3214
|
const provider = parseProviderInput(body, void 0);
|
|
2188
3215
|
if (!provider) return writeJsonError(res, 400, "invalid provider (id, apiFormat, baseUrl required)");
|
|
2189
3216
|
if (cfg.providers.some((p) => p.id === provider.id)) {
|
|
@@ -2191,25 +3218,25 @@ async function handleProviders(req, res, method, rest, deps) {
|
|
|
2191
3218
|
}
|
|
2192
3219
|
cfg.providers.push(provider);
|
|
2193
3220
|
persistProviders(cfg, deps);
|
|
2194
|
-
return
|
|
3221
|
+
return writeJson2(res, 201, { provider: toProviderView(provider) });
|
|
2195
3222
|
}
|
|
2196
3223
|
const id = rest[0];
|
|
2197
3224
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
2198
3225
|
const idx = cfg.providers.findIndex((p) => p.id === id);
|
|
2199
3226
|
if (idx < 0) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
2200
3227
|
if (method === "PUT") {
|
|
2201
|
-
const body = await
|
|
3228
|
+
const body = await readJsonBody3(req);
|
|
2202
3229
|
const existing = cfg.providers[idx];
|
|
2203
3230
|
const updated = parseProviderInput(body, existing);
|
|
2204
3231
|
if (!updated) return writeJsonError(res, 400, "invalid provider (apiFormat, baseUrl required)");
|
|
2205
3232
|
cfg.providers[idx] = updated;
|
|
2206
3233
|
persistProviders(cfg, deps);
|
|
2207
|
-
return
|
|
3234
|
+
return writeJson2(res, 200, { provider: toProviderView(updated) });
|
|
2208
3235
|
}
|
|
2209
3236
|
if (method === "DELETE") {
|
|
2210
3237
|
cfg.providers.splice(idx, 1);
|
|
2211
3238
|
persistProviders(cfg, deps);
|
|
2212
|
-
return
|
|
3239
|
+
return writeJson2(res, 200, { ok: true });
|
|
2213
3240
|
}
|
|
2214
3241
|
return writeJsonError(res, 405, `method ${method} not allowed on providers`);
|
|
2215
3242
|
}
|
|
@@ -2218,7 +3245,7 @@ function persistProviders(cfg, deps) {
|
|
|
2218
3245
|
deps.llmConfig.reload(cfg);
|
|
2219
3246
|
}
|
|
2220
3247
|
async function handleProviderReorder(req, res, cfg, deps) {
|
|
2221
|
-
const body = await
|
|
3248
|
+
const body = await readJsonBody3(req);
|
|
2222
3249
|
const rawOrder = body["order"];
|
|
2223
3250
|
if (!Array.isArray(rawOrder)) {
|
|
2224
3251
|
return writeJsonError(res, 400, "reorder requires { order: string[] }");
|
|
@@ -2242,14 +3269,14 @@ async function handleProviderReorder(req, res, cfg, deps) {
|
|
|
2242
3269
|
}
|
|
2243
3270
|
cfg.providers = reordered;
|
|
2244
3271
|
persistProviders(cfg, deps);
|
|
2245
|
-
return
|
|
3272
|
+
return writeJson2(res, 200, { ok: true, providers: cfg.providers.map(toProviderView) });
|
|
2246
3273
|
}
|
|
2247
3274
|
async function handleDiscoverModels(res, id, cfg) {
|
|
2248
3275
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
2249
3276
|
const row = cfg.providers.find((p) => p.id === id);
|
|
2250
3277
|
if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
2251
3278
|
if (row.apiFormat !== "openai") {
|
|
2252
|
-
return
|
|
3279
|
+
return writeJson2(res, 200, { models: [], unsupportedFormat: true });
|
|
2253
3280
|
}
|
|
2254
3281
|
const resolvedKey = resolveEnvKey(row.apiKey);
|
|
2255
3282
|
const base = row.baseUrl.replace(/\/+$/, "");
|
|
@@ -2257,7 +3284,7 @@ async function handleDiscoverModels(res, id, cfg) {
|
|
|
2257
3284
|
try {
|
|
2258
3285
|
const headers = { Accept: "application/json" };
|
|
2259
3286
|
if (resolvedKey) headers["Authorization"] = `Bearer ${resolvedKey}`;
|
|
2260
|
-
const response = await
|
|
3287
|
+
const response = await fetchUpstream(url, { method: "GET", headers }, { providerId: "byo" });
|
|
2261
3288
|
if (!response.ok) {
|
|
2262
3289
|
const text = await response.text().catch(() => "");
|
|
2263
3290
|
let message = text.slice(0, 300);
|
|
@@ -2266,32 +3293,32 @@ async function handleDiscoverModels(res, id, cfg) {
|
|
|
2266
3293
|
message = parsed?.error?.message || parsed?.message || message;
|
|
2267
3294
|
} catch {
|
|
2268
3295
|
}
|
|
2269
|
-
return
|
|
3296
|
+
return writeJson2(res, 200, {
|
|
2270
3297
|
models: [],
|
|
2271
3298
|
error: `discovery failed (${response.status})${message ? `: ${message}` : ""}`
|
|
2272
3299
|
});
|
|
2273
3300
|
}
|
|
2274
3301
|
const data = await response.json();
|
|
2275
3302
|
const models = Array.isArray(data?.data) ? data.data.map((m) => typeof m?.id === "string" ? m.id : "").filter((m) => m.length > 0) : [];
|
|
2276
|
-
return
|
|
3303
|
+
return writeJson2(res, 200, { models });
|
|
2277
3304
|
} catch (err5) {
|
|
2278
3305
|
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
2279
|
-
return
|
|
3306
|
+
return writeJson2(res, 200, { models: [], error: `discovery failed: ${message}` });
|
|
2280
3307
|
}
|
|
2281
3308
|
}
|
|
2282
3309
|
async function handleTestModel(req, res, id, cfg) {
|
|
2283
3310
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
2284
3311
|
const row = cfg.providers.find((p) => p.id === id);
|
|
2285
3312
|
if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
2286
|
-
const body = await
|
|
3313
|
+
const body = await readJsonBody3(req);
|
|
2287
3314
|
const model = typeof body["model"] === "string" ? body["model"].trim() : "";
|
|
2288
3315
|
if (!model) return writeJsonError(res, 400, "test requires a { model } string");
|
|
2289
3316
|
if (row.apiFormat === "gemini") {
|
|
2290
|
-
return
|
|
3317
|
+
return writeJson2(res, 200, { ok: false, unsupportedFormat: true });
|
|
2291
3318
|
}
|
|
2292
3319
|
const resolvedKey = resolveEnvKey(row.apiKey);
|
|
2293
3320
|
if (!resolvedKey) {
|
|
2294
|
-
return
|
|
3321
|
+
return writeJson2(res, 200, { ok: false, message: "no API key configured for this provider" });
|
|
2295
3322
|
}
|
|
2296
3323
|
const url = row.baseUrl.replace(/\/+$/, "");
|
|
2297
3324
|
const prompt = "Reply with the single word: OK.";
|
|
@@ -2312,11 +3339,11 @@ async function handleTestModel(req, res, id, cfg) {
|
|
|
2312
3339
|
}
|
|
2313
3340
|
const startedAt = Date.now();
|
|
2314
3341
|
try {
|
|
2315
|
-
const response = await
|
|
2316
|
-
|
|
2317
|
-
headers,
|
|
2318
|
-
|
|
2319
|
-
|
|
3342
|
+
const response = await fetchUpstream(
|
|
3343
|
+
url,
|
|
3344
|
+
{ method: "POST", headers, body: JSON.stringify(payload) },
|
|
3345
|
+
{ providerId: "byo" }
|
|
3346
|
+
);
|
|
2320
3347
|
const latencyMs = Date.now() - startedAt;
|
|
2321
3348
|
const text = await response.text().catch(() => "");
|
|
2322
3349
|
if (!response.ok) {
|
|
@@ -2326,9 +3353,9 @@ async function handleTestModel(req, res, id, cfg) {
|
|
|
2326
3353
|
message = parsed?.error?.message || parsed?.message || message;
|
|
2327
3354
|
} catch {
|
|
2328
3355
|
}
|
|
2329
|
-
return
|
|
3356
|
+
return writeJson2(res, 200, { ok: false, status: response.status, latencyMs, message });
|
|
2330
3357
|
}
|
|
2331
|
-
return
|
|
3358
|
+
return writeJson2(res, 200, {
|
|
2332
3359
|
ok: true,
|
|
2333
3360
|
status: response.status,
|
|
2334
3361
|
latencyMs,
|
|
@@ -2336,7 +3363,7 @@ async function handleTestModel(req, res, id, cfg) {
|
|
|
2336
3363
|
});
|
|
2337
3364
|
} catch (err5) {
|
|
2338
3365
|
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
2339
|
-
return
|
|
3366
|
+
return writeJson2(res, 200, { ok: false, latencyMs: Date.now() - startedAt, message });
|
|
2340
3367
|
}
|
|
2341
3368
|
}
|
|
2342
3369
|
function extractSampleText(text, apiFormat) {
|
|
@@ -2358,9 +3385,9 @@ function toPoolKeyView(row, cooldown, deps) {
|
|
|
2358
3385
|
return entries.map((e) => {
|
|
2359
3386
|
const auto = deps.autoDisableStore.get(e.id);
|
|
2360
3387
|
const cd = cooldown[e.id];
|
|
2361
|
-
const
|
|
2362
|
-
if (cd)
|
|
2363
|
-
if (auto)
|
|
3388
|
+
const health2 = {};
|
|
3389
|
+
if (cd) health2.cooldown = cd;
|
|
3390
|
+
if (auto) health2.autoDisabled = { status: auto.status, at: auto.at, reason: auto.reason };
|
|
2364
3391
|
return {
|
|
2365
3392
|
id: e.id,
|
|
2366
3393
|
label: e.label && e.label.length > 0 ? e.label : e.id,
|
|
@@ -2368,7 +3395,7 @@ function toPoolKeyView(row, cooldown, deps) {
|
|
|
2368
3395
|
enabled: e.enabled !== false && !deps.autoDisableStore.isDisabled(e.id),
|
|
2369
3396
|
weight: typeof e.weight === "number" && Number.isFinite(e.weight) ? e.weight : 1,
|
|
2370
3397
|
apiKeyMasked: maskProviderApiKey(e.apiKey),
|
|
2371
|
-
...Object.keys(
|
|
3398
|
+
...Object.keys(health2).length > 0 ? { health: health2 } : {}
|
|
2372
3399
|
};
|
|
2373
3400
|
});
|
|
2374
3401
|
}
|
|
@@ -2377,7 +3404,7 @@ async function handleProviderKeys(res, id, cfg, deps) {
|
|
|
2377
3404
|
const row = cfg.providers.find((p) => p.id === id);
|
|
2378
3405
|
if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
2379
3406
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2380
|
-
return
|
|
3407
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2381
3408
|
}
|
|
2382
3409
|
function parsePoolKeyInput(body, existing) {
|
|
2383
3410
|
const out = {};
|
|
@@ -2396,7 +3423,7 @@ async function handleAddProviderKey(req, res, id, cfg, deps) {
|
|
|
2396
3423
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
2397
3424
|
const idx = cfg.providers.findIndex((p) => p.id === id);
|
|
2398
3425
|
if (idx < 0) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
2399
|
-
const body = await
|
|
3426
|
+
const body = await readJsonBody3(req);
|
|
2400
3427
|
const parsed = parsePoolKeyInput(body);
|
|
2401
3428
|
if (!parsed.apiKey) return writeJsonError(res, 400, "add requires a non-empty apiKey");
|
|
2402
3429
|
const keyId = `key-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -2408,7 +3435,7 @@ async function handleAddProviderKey(req, res, id, cfg, deps) {
|
|
|
2408
3435
|
row.apiKeys = [...row.apiKeys ?? [], entry];
|
|
2409
3436
|
persistProviders(cfg, deps);
|
|
2410
3437
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2411
|
-
return
|
|
3438
|
+
return writeJson2(res, 201, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2412
3439
|
}
|
|
2413
3440
|
async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
|
|
2414
3441
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
@@ -2418,7 +3445,7 @@ async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
|
|
|
2418
3445
|
const row = cfg.providers[idx];
|
|
2419
3446
|
const keyIdx = (row.apiKeys ?? []).findIndex((k) => k.id === keyId);
|
|
2420
3447
|
if (keyIdx < 0) return writeJsonError(res, 404, `pool key '${keyId}' not found`);
|
|
2421
|
-
const body = await
|
|
3448
|
+
const body = await readJsonBody3(req);
|
|
2422
3449
|
const existing = row.apiKeys[keyIdx];
|
|
2423
3450
|
const parsed = parsePoolKeyInput(body, existing);
|
|
2424
3451
|
const entry = { id: keyId, apiKey: parsed.apiKey || existing.apiKey };
|
|
@@ -2428,7 +3455,7 @@ async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
|
|
|
2428
3455
|
row.apiKeys[keyIdx] = entry;
|
|
2429
3456
|
persistProviders(cfg, deps);
|
|
2430
3457
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2431
|
-
return
|
|
3458
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2432
3459
|
}
|
|
2433
3460
|
async function handleDeleteProviderKey(res, id, keyId, cfg, deps) {
|
|
2434
3461
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
@@ -2442,7 +3469,7 @@ async function handleDeleteProviderKey(res, id, keyId, cfg, deps) {
|
|
|
2442
3469
|
if (row.apiKeys.length === 0) row.apiKeys = void 0;
|
|
2443
3470
|
persistProviders(cfg, deps);
|
|
2444
3471
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2445
|
-
return
|
|
3472
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2446
3473
|
}
|
|
2447
3474
|
async function handleToggleProviderKey(req, res, id, keyId, cfg, deps) {
|
|
2448
3475
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
@@ -2452,11 +3479,11 @@ async function handleToggleProviderKey(req, res, id, keyId, cfg, deps) {
|
|
|
2452
3479
|
const row = cfg.providers[idx];
|
|
2453
3480
|
const keyIdx = (row.apiKeys ?? []).findIndex((k) => k.id === keyId);
|
|
2454
3481
|
if (keyIdx < 0) return writeJsonError(res, 404, `pool key '${keyId}' not found`);
|
|
2455
|
-
const body = await
|
|
3482
|
+
const body = await readJsonBody3(req);
|
|
2456
3483
|
row.apiKeys[keyIdx].enabled = Boolean(body["enabled"]);
|
|
2457
3484
|
persistProviders(cfg, deps);
|
|
2458
3485
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2459
|
-
return
|
|
3486
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2460
3487
|
}
|
|
2461
3488
|
function parseApiKeysInput(raw, existing) {
|
|
2462
3489
|
if (!Array.isArray(raw)) return existing;
|
|
@@ -2627,18 +3654,31 @@ function handlePresets(res, method) {
|
|
|
2627
3654
|
baseUrl: p.baseUrl,
|
|
2628
3655
|
models: p.models
|
|
2629
3656
|
}));
|
|
2630
|
-
return
|
|
3657
|
+
return writeJson2(res, 200, { presets, excluded });
|
|
2631
3658
|
}
|
|
2632
3659
|
async function handleKeys(req, res, method, rest, deps) {
|
|
2633
3660
|
if (method === "GET" && rest.length === 0) {
|
|
2634
3661
|
const rows = await deps.keyDb.outboundApiKeysList();
|
|
2635
|
-
|
|
3662
|
+
const reader = deps.keySpendReader;
|
|
3663
|
+
if (!reader) return writeJson2(res, 200, { keys: rows.map(toKeyInfo) });
|
|
3664
|
+
const now = Date.now();
|
|
3665
|
+
const keys = await Promise.all(
|
|
3666
|
+
rows.map(async (row) => {
|
|
3667
|
+
const info = toKeyInfo(row);
|
|
3668
|
+
if (row.revokedAt === null) {
|
|
3669
|
+
const s = await reader.getSpend(row.id, now);
|
|
3670
|
+
info.spend = { dailyUsd: s.dailyUsd, weeklyUsd: s.weeklyUsd, totalUsd: s.totalUsd };
|
|
3671
|
+
}
|
|
3672
|
+
return info;
|
|
3673
|
+
})
|
|
3674
|
+
);
|
|
3675
|
+
return writeJson2(res, 200, { keys });
|
|
2636
3676
|
}
|
|
2637
3677
|
if (method === "POST" && rest.length === 0) {
|
|
2638
|
-
const body = await
|
|
3678
|
+
const body = await readJsonBody3(req);
|
|
2639
3679
|
const name = typeof body["name"] === "string" && body["name"].trim() ? body["name"].trim() : "key";
|
|
2640
3680
|
const created = await createNamedKey2(deps.keyDb, name);
|
|
2641
|
-
return
|
|
3681
|
+
return writeJson2(res, 201, {
|
|
2642
3682
|
id: created.id,
|
|
2643
3683
|
name: created.name,
|
|
2644
3684
|
keyPrefix: created.keyPrefix,
|
|
@@ -2650,46 +3690,185 @@ async function handleKeys(req, res, method, rest, deps) {
|
|
|
2650
3690
|
const action = rest[1];
|
|
2651
3691
|
if (method === "POST" && id && action === "revoke") {
|
|
2652
3692
|
const ok = await deps.keyDb.outboundApiKeysRevoke(id);
|
|
2653
|
-
return
|
|
3693
|
+
return writeJson2(res, ok ? 200 : 404, { ok });
|
|
2654
3694
|
}
|
|
2655
3695
|
if (method === "POST" && id && action === "enabled") {
|
|
2656
|
-
const body = await
|
|
3696
|
+
const body = await readJsonBody3(req);
|
|
2657
3697
|
const enabled = body["enabled"] === true;
|
|
2658
3698
|
const ok = await deps.keyDb.outboundApiKeysSetEnabled(id, enabled);
|
|
2659
|
-
return
|
|
3699
|
+
return writeJson2(res, ok ? 200 : 404, { ok, enabled });
|
|
3700
|
+
}
|
|
3701
|
+
if (method === "POST" && id && action === "max-concurrency") {
|
|
3702
|
+
const body = await readJsonBody3(req);
|
|
3703
|
+
const raw = body["maxConcurrency"];
|
|
3704
|
+
let value;
|
|
3705
|
+
if (raw === null) {
|
|
3706
|
+
value = null;
|
|
3707
|
+
} else if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 1e3) {
|
|
3708
|
+
value = raw;
|
|
3709
|
+
} else {
|
|
3710
|
+
return writeJsonError(
|
|
3711
|
+
res,
|
|
3712
|
+
400,
|
|
3713
|
+
"maxConcurrency must be an integer 1..1000 or null"
|
|
3714
|
+
);
|
|
3715
|
+
}
|
|
3716
|
+
const ok = await deps.keyDb.outboundApiKeysSetMaxConcurrency(id, value);
|
|
3717
|
+
return writeJson2(res, ok ? 200 : 404, { ok, maxConcurrency: value });
|
|
3718
|
+
}
|
|
3719
|
+
if (method === "POST" && id && action === "policy") {
|
|
3720
|
+
const body = await readJsonBody3(req);
|
|
3721
|
+
const parsed = parseKeyPolicyBody(body);
|
|
3722
|
+
if (!parsed.ok) return writeJsonError(res, 400, parsed.message);
|
|
3723
|
+
const ok = await deps.keyDb.outboundApiKeysSetPolicy(id, parsed.policy);
|
|
3724
|
+
return writeJson2(res, ok ? 200 : 404, { ok });
|
|
2660
3725
|
}
|
|
2661
3726
|
return writeJsonError(res, 405, `method ${method} not allowed on keys`);
|
|
2662
3727
|
}
|
|
3728
|
+
function validateQueueSegments(patch) {
|
|
3729
|
+
const errors = [];
|
|
3730
|
+
const checkNum = (label, value, min, max) => {
|
|
3731
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
|
|
3732
|
+
errors.push(`${label} must be a number ${min}..${max}`);
|
|
3733
|
+
}
|
|
3734
|
+
};
|
|
3735
|
+
const isPlainObject4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3736
|
+
const umq = patch.userMessageQueue;
|
|
3737
|
+
if (umq !== void 0) {
|
|
3738
|
+
if (!isPlainObject4(umq)) {
|
|
3739
|
+
errors.push("userMessageQueue must be an object");
|
|
3740
|
+
} else {
|
|
3741
|
+
if (typeof umq.enabled !== "boolean") {
|
|
3742
|
+
errors.push("userMessageQueue.enabled must be a boolean");
|
|
3743
|
+
}
|
|
3744
|
+
checkNum("userMessageQueue.delayMs", umq.delayMs, 0, 1e4);
|
|
3745
|
+
checkNum("userMessageQueue.waitTimeoutMs", umq.waitTimeoutMs, 1e3, 3e5);
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
const cq = patch.concurrencyQueue;
|
|
3749
|
+
if (cq !== void 0) {
|
|
3750
|
+
if (!isPlainObject4(cq)) {
|
|
3751
|
+
errors.push("concurrencyQueue must be an object");
|
|
3752
|
+
} else {
|
|
3753
|
+
checkNum("concurrencyQueue.maxQueueSizeFactor", cq.maxQueueSizeFactor, 1, 10);
|
|
3754
|
+
checkNum("concurrencyQueue.minQueueSize", cq.minQueueSize, 1, 100);
|
|
3755
|
+
checkNum("concurrencyQueue.waitTimeoutMs", cq.waitTimeoutMs, 1e3, 3e5);
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
const ah = patch.accountHealth;
|
|
3759
|
+
if (ah !== void 0) {
|
|
3760
|
+
if (!isPlainObject4(ah)) {
|
|
3761
|
+
errors.push("accountHealth must be an object");
|
|
3762
|
+
} else {
|
|
3763
|
+
if (typeof ah.overloadCooldownEnabled !== "boolean") {
|
|
3764
|
+
errors.push("accountHealth.overloadCooldownEnabled must be a boolean");
|
|
3765
|
+
}
|
|
3766
|
+
checkNum("accountHealth.overloadCooldownMs", ah.overloadCooldownMs, 6e4, 36e5);
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
return errors;
|
|
3770
|
+
}
|
|
2663
3771
|
async function handleServer(req, res, method, deps) {
|
|
2664
3772
|
if (method === "GET") {
|
|
2665
|
-
const config = await
|
|
2666
|
-
|
|
3773
|
+
const config = await loadServerConfig2(deps.settingsStore);
|
|
3774
|
+
let server = config;
|
|
3775
|
+
if (config.proxy) server = { ...server, proxy: redactOutboundProxy(config.proxy) };
|
|
3776
|
+
if (config.webhook) server = { ...server, webhook: redactWebhookConfig(config.webhook) };
|
|
3777
|
+
if (config.billing) server = { ...server, billing: redactBillingConfig(config.billing) };
|
|
3778
|
+
return writeJson2(res, 200, { server });
|
|
2667
3779
|
}
|
|
2668
3780
|
if (method === "PUT") {
|
|
2669
|
-
const patch = await
|
|
2670
|
-
const
|
|
2671
|
-
|
|
3781
|
+
const patch = await readJsonBody3(req);
|
|
3782
|
+
const queueErrors = validateQueueSegments(patch);
|
|
3783
|
+
if (queueErrors.length > 0) {
|
|
3784
|
+
return writeJsonError(res, 400, `invalid queue config: ${queueErrors.join("; ")}`);
|
|
3785
|
+
}
|
|
3786
|
+
const webhookErrors = validateWebhookSegment(patch);
|
|
3787
|
+
if (webhookErrors.length > 0) {
|
|
3788
|
+
return writeJsonError(res, 400, `invalid webhook config: ${webhookErrors.join("; ")}`);
|
|
3789
|
+
}
|
|
3790
|
+
const auditErrors = validateAuditSegment(patch);
|
|
3791
|
+
if (auditErrors.length > 0) {
|
|
3792
|
+
return writeJsonError(res, 400, `invalid audit config: ${auditErrors.join("; ")}`);
|
|
3793
|
+
}
|
|
3794
|
+
const billingErrors = validateBillingSegment(patch);
|
|
3795
|
+
if (billingErrors.length > 0) {
|
|
3796
|
+
return writeJsonError(res, 400, `invalid billing config: ${billingErrors.join("; ")}`);
|
|
3797
|
+
}
|
|
3798
|
+
const current = await loadServerConfig2(deps.settingsStore);
|
|
3799
|
+
let effectivePatch = patch;
|
|
3800
|
+
if (patch.proxy) {
|
|
3801
|
+
effectivePatch = { ...effectivePatch, proxy: preserveOutboundProxySecrets(patch.proxy, current.proxy) };
|
|
3802
|
+
}
|
|
3803
|
+
if (patch.webhook) {
|
|
3804
|
+
effectivePatch = { ...effectivePatch, webhook: preserveWebhookSecrets(patch.webhook, current.webhook) };
|
|
3805
|
+
}
|
|
3806
|
+
if (patch.billing) {
|
|
3807
|
+
effectivePatch = { ...effectivePatch, billing: preserveBillingSecret(patch.billing, current.billing) };
|
|
3808
|
+
}
|
|
3809
|
+
const merged = mergeServerConfig(current, effectivePatch);
|
|
2672
3810
|
await saveServerConfig(deps.settingsStore, merged);
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
3811
|
+
setServerProxyConfig(merged.proxy);
|
|
3812
|
+
applyWebhookConfig(merged.webhook);
|
|
3813
|
+
applyAuditConfig(merged.audit);
|
|
3814
|
+
applyBillingConfig(merged.billing);
|
|
3815
|
+
if (merged.enabled) {
|
|
3816
|
+
const missing = validateServerModelConfig(merged);
|
|
3817
|
+
if (missing.length > 0) {
|
|
3818
|
+
if (deps.outboundApiServer.getStatus().running) {
|
|
3819
|
+
await deps.outboundApiServer.stop();
|
|
3820
|
+
}
|
|
3821
|
+
return writeJson2(res, 200, {
|
|
3822
|
+
server: merged,
|
|
3823
|
+
error: { code: "incomplete-model-config", missing }
|
|
3824
|
+
});
|
|
3825
|
+
}
|
|
3826
|
+
}
|
|
3827
|
+
try {
|
|
3828
|
+
await deps.outboundApiServer.applyConfig({
|
|
3829
|
+
enabled: merged.enabled,
|
|
3830
|
+
networkBinding: merged.networkBinding,
|
|
3831
|
+
endpoints: merged.endpoints,
|
|
3832
|
+
port: merged.port,
|
|
3833
|
+
userMessageQueue: merged.userMessageQueue,
|
|
3834
|
+
concurrencyQueue: merged.concurrencyQueue,
|
|
3835
|
+
// voucher-redemption #9: hot-apply the voucher flag so enabling the product
|
|
3836
|
+
// takes effect without a restart.
|
|
3837
|
+
voucher: merged.voucher
|
|
3838
|
+
});
|
|
3839
|
+
} catch (err5) {
|
|
3840
|
+
const missing = incompleteConfigMissing(err5);
|
|
3841
|
+
if (missing) {
|
|
3842
|
+
return writeJson2(res, 200, {
|
|
3843
|
+
server: merged,
|
|
3844
|
+
error: { code: "incomplete-model-config", missing }
|
|
3845
|
+
});
|
|
3846
|
+
}
|
|
3847
|
+
throw err5;
|
|
3848
|
+
}
|
|
3849
|
+
return writeJson2(res, 200, { server: merged });
|
|
2680
3850
|
}
|
|
2681
3851
|
return writeJsonError(res, 405, `method ${method} not allowed on server`);
|
|
2682
3852
|
}
|
|
3853
|
+
function incompleteConfigMissing(err5) {
|
|
3854
|
+
if (typeof err5 !== "object" || err5 === null) return null;
|
|
3855
|
+
const missing = err5.missing;
|
|
3856
|
+
return Array.isArray(missing) ? missing : null;
|
|
3857
|
+
}
|
|
2683
3858
|
async function handleAccounts(req, res, method, rest, deps) {
|
|
2684
3859
|
if (method === "GET" && rest.length === 0) {
|
|
2685
3860
|
const accounts = await deps.subscriptionAccounts.listAll();
|
|
2686
3861
|
const providerAccounts = await deps.subscriptionTokenWriter.listSanitizedAccounts();
|
|
2687
3862
|
const externalCli = await deps.subscriptionTokenWriter.listExternalCliAvailability();
|
|
2688
|
-
return
|
|
3863
|
+
return writeJson2(res, 200, { accounts, providerAccounts, externalCli });
|
|
2689
3864
|
}
|
|
2690
3865
|
if (method === "GET" && rest[0] === "codex" && rest[1] === "oauth" && rest[3] === "status") {
|
|
2691
3866
|
const result = handleCodexOAuthStatus(rest[2], deps);
|
|
2692
|
-
return
|
|
3867
|
+
return writeJson2(res, result.status, result.body);
|
|
3868
|
+
}
|
|
3869
|
+
if (method === "DELETE" && rest[0] === "codex" && rest[1] === "oauth" && rest[2]) {
|
|
3870
|
+
const result = handleCodexOAuthCancel(rest[2], deps);
|
|
3871
|
+
return writeJson2(res, result.status, result.body);
|
|
2693
3872
|
}
|
|
2694
3873
|
if (method === "PUT" || method === "POST" || method === "DELETE") {
|
|
2695
3874
|
const providerId = asSubscriptionProviderId(rest[0]);
|
|
@@ -2698,15 +3877,15 @@ async function handleAccounts(req, res, method, rest, deps) {
|
|
|
2698
3877
|
}
|
|
2699
3878
|
if (method === "POST" && rest[1] === "oauth" && rest[2] === "start") {
|
|
2700
3879
|
const result = providerId === "codex" ? handleCodexOAuthStart(deps) : handleOAuthStart(providerId, deps);
|
|
2701
|
-
return
|
|
3880
|
+
return writeJson2(res, result.status, result.body);
|
|
2702
3881
|
}
|
|
2703
3882
|
if (method === "POST" && rest[1] === "oauth" && rest[2] === "complete") {
|
|
2704
|
-
const body2 = await
|
|
3883
|
+
const body2 = await readJsonBody3(req);
|
|
2705
3884
|
const result = await handleOAuthComplete(providerId, body2, deps);
|
|
2706
|
-
return
|
|
3885
|
+
return writeJson2(res, result.status, result.body);
|
|
2707
3886
|
}
|
|
2708
3887
|
if (method === "POST" && rest[1] === "accounts") {
|
|
2709
|
-
const body2 = await
|
|
3888
|
+
const body2 = await readJsonBody3(req);
|
|
2710
3889
|
const block = validateTokenBody(providerId, body2);
|
|
2711
3890
|
if (!block) {
|
|
2712
3891
|
return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
|
|
@@ -2714,79 +3893,113 @@ async function handleAccounts(req, res, method, rest, deps) {
|
|
|
2714
3893
|
const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
|
|
2715
3894
|
await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block, label);
|
|
2716
3895
|
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2717
|
-
return
|
|
3896
|
+
return writeJson2(res, 200, status2 ? { account: status2 } : { ok: true });
|
|
2718
3897
|
}
|
|
2719
3898
|
if (method === "POST" && rest[1] === "import-external") {
|
|
2720
3899
|
if (providerId !== "claude" && providerId !== "codex") {
|
|
2721
3900
|
return writeJsonError(res, 400, `provider '${providerId}' has no external CLI store`);
|
|
2722
3901
|
}
|
|
2723
|
-
const body2 = await
|
|
3902
|
+
const body2 = await readJsonBody3(req);
|
|
2724
3903
|
const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
|
|
2725
3904
|
const result = await deps.subscriptionTokenWriter.importExternalCliAccount(providerId, label);
|
|
2726
3905
|
if (!result.ok) {
|
|
2727
3906
|
return writeJsonError(res, 409, `no usable external ${providerId} CLI credential found`);
|
|
2728
3907
|
}
|
|
2729
3908
|
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2730
|
-
return
|
|
3909
|
+
return writeJson2(res, 200, { ok: true, account: status2 ?? void 0 });
|
|
2731
3910
|
}
|
|
2732
3911
|
if (method === "POST" && rest[1] === "refresh") {
|
|
2733
3912
|
if (providerId === "opencodego") {
|
|
2734
3913
|
return writeJsonError(res, 400, "opencodego credentials are not refreshable");
|
|
2735
3914
|
}
|
|
2736
|
-
const
|
|
2737
|
-
const ok = providerId === "claude" ? await
|
|
3915
|
+
const writer2 = deps.subscriptionTokenWriter;
|
|
3916
|
+
const ok = providerId === "claude" ? await writer2.refreshClaudeToken() : providerId === "codex" ? await writer2.refreshCodexToken() : await writer2.refreshGeminiToken();
|
|
2738
3917
|
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2739
|
-
return
|
|
3918
|
+
return writeJson2(res, 200, { ok, account: status2 ?? void 0 });
|
|
2740
3919
|
}
|
|
2741
3920
|
if (method === "POST" && rest[2] === "label") {
|
|
2742
3921
|
const accountId = rest[1];
|
|
2743
|
-
const body2 = await
|
|
3922
|
+
const body2 = await readJsonBody3(req);
|
|
2744
3923
|
const label = typeof body2["label"] === "string" ? body2["label"].trim() : "";
|
|
2745
3924
|
const result = await deps.subscriptionTokenWriter.renameAccount(providerId, accountId, label);
|
|
2746
3925
|
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
2747
|
-
return
|
|
3926
|
+
return writeJson2(res, 200, { ok: true });
|
|
3927
|
+
}
|
|
3928
|
+
if (method === "POST" && rest[2] === "priority") {
|
|
3929
|
+
const accountId = rest[1];
|
|
3930
|
+
const body2 = await readJsonBody3(req);
|
|
3931
|
+
const raw = body2["priority"];
|
|
3932
|
+
const priority = typeof raw === "number" ? raw : Number(raw);
|
|
3933
|
+
if (!Number.isFinite(priority)) {
|
|
3934
|
+
return writeJsonError(res, 400, "priority must be a finite number");
|
|
3935
|
+
}
|
|
3936
|
+
const result = await deps.subscriptionTokenWriter.setAccountPriority(providerId, accountId, priority);
|
|
3937
|
+
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
3938
|
+
return writeJson2(res, 200, { ok: true });
|
|
3939
|
+
}
|
|
3940
|
+
if (method === "POST" && rest[2] === "proxy") {
|
|
3941
|
+
const accountId = rest[1];
|
|
3942
|
+
const body2 = await readJsonBody3(req);
|
|
3943
|
+
const rawProxy = body2["proxy"];
|
|
3944
|
+
let proxy;
|
|
3945
|
+
if (rawProxy !== null && rawProxy !== void 0) {
|
|
3946
|
+
proxy = normalizeProxyConfig(rawProxy);
|
|
3947
|
+
if (!proxy) return writeJsonError(res, 400, "invalid proxy config");
|
|
3948
|
+
}
|
|
3949
|
+
const result = await deps.subscriptionTokenWriter.setAccountProxy(providerId, accountId, proxy);
|
|
3950
|
+
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
3951
|
+
return writeJson2(res, 200, { ok: true });
|
|
3952
|
+
}
|
|
3953
|
+
if (method === "POST" && rest[2] === "supported-models") {
|
|
3954
|
+
const accountId = rest[1];
|
|
3955
|
+
const body2 = await readJsonBody3(req);
|
|
3956
|
+
const parsed = validateSupportedModelsBody(body2["supportedModels"]);
|
|
3957
|
+
if (!parsed.ok) return writeJsonError(res, 400, "invalid supportedModels");
|
|
3958
|
+
const result = await deps.subscriptionTokenWriter.setAccountSupportedModels(providerId, accountId, parsed.value);
|
|
3959
|
+
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
3960
|
+
return writeJson2(res, 200, { ok: true });
|
|
2748
3961
|
}
|
|
2749
3962
|
if (method === "PUT" && rest[1] === "active") {
|
|
2750
|
-
const body2 = await
|
|
3963
|
+
const body2 = await readJsonBody3(req);
|
|
2751
3964
|
const id = typeof body2["id"] === "string" ? body2["id"] : "";
|
|
2752
3965
|
if (!id) return writeJsonError(res, 400, "active switch requires { id }");
|
|
2753
3966
|
const result = await deps.subscriptionTokenWriter.setActiveAccount(providerId, id);
|
|
2754
3967
|
if (!result.ok) return writeJsonError(res, 404, `account '${id}' not found`);
|
|
2755
|
-
return
|
|
3968
|
+
return writeJson2(res, 200, { ok: true });
|
|
2756
3969
|
}
|
|
2757
3970
|
if (method === "DELETE" && rest.length >= 2) {
|
|
2758
3971
|
const accountId = rest[1];
|
|
2759
3972
|
const result = await deps.subscriptionTokenWriter.removeAccount(providerId, accountId);
|
|
2760
3973
|
if (!result.removed) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
2761
|
-
return
|
|
3974
|
+
return writeJson2(res, 200, { ok: true });
|
|
2762
3975
|
}
|
|
2763
3976
|
if (method === "DELETE") {
|
|
2764
3977
|
await deps.subscriptionTokenWriter.clearProvider(providerId);
|
|
2765
|
-
return
|
|
3978
|
+
return writeJson2(res, 200, { ok: true });
|
|
2766
3979
|
}
|
|
2767
|
-
const body = await
|
|
3980
|
+
const body = await readJsonBody3(req);
|
|
2768
3981
|
const config = validateTokenBody(providerId, body);
|
|
2769
3982
|
if (!config) {
|
|
2770
3983
|
return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
|
|
2771
3984
|
}
|
|
2772
3985
|
await deps.subscriptionTokenWriter.writeProviderTokens(providerId, config);
|
|
2773
3986
|
const status = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2774
|
-
return
|
|
3987
|
+
return writeJson2(res, 200, status ? { account: status } : { ok: true });
|
|
2775
3988
|
}
|
|
2776
3989
|
return writeJsonError(res, 405, `method ${method} not allowed on accounts`);
|
|
2777
3990
|
}
|
|
2778
3991
|
async function handleCli(req, res, method, rest, deps) {
|
|
2779
3992
|
if (method === "GET" && rest.length === 0) {
|
|
2780
3993
|
const result = handleCliList(process.platform, deps.cliPathProbe);
|
|
2781
|
-
return
|
|
3994
|
+
return writeJson2(res, result.status, result.body);
|
|
2782
3995
|
}
|
|
2783
3996
|
if (method === "GET" && rest[0] === "sessions") {
|
|
2784
3997
|
const result = handleCliSessions();
|
|
2785
|
-
return
|
|
3998
|
+
return writeJson2(res, result.status, result.body);
|
|
2786
3999
|
}
|
|
2787
4000
|
if (method === "DELETE" && rest[0] === "sessions" && rest[1]) {
|
|
2788
4001
|
const result = handleCliStop(rest[1]);
|
|
2789
|
-
return
|
|
4002
|
+
return writeJson2(res, result.status, result.body);
|
|
2790
4003
|
}
|
|
2791
4004
|
if (method === "POST" && rest[1] === "install") {
|
|
2792
4005
|
const cli = rest[0];
|
|
@@ -2794,14 +4007,14 @@ async function handleCli(req, res, method, rest, deps) {
|
|
|
2794
4007
|
return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
|
|
2795
4008
|
}
|
|
2796
4009
|
const result = await handleCliInstall(cli, deps.cliCommandRunner);
|
|
2797
|
-
return
|
|
4010
|
+
return writeJson2(res, result.status, result.body);
|
|
2798
4011
|
}
|
|
2799
4012
|
if (method === "POST" && rest[1] === "launch") {
|
|
2800
4013
|
const cli = rest[0];
|
|
2801
4014
|
if (!isLaunchCliId(cli)) {
|
|
2802
4015
|
return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
|
|
2803
4016
|
}
|
|
2804
|
-
const body = await
|
|
4017
|
+
const body = await readJsonBody3(req);
|
|
2805
4018
|
const providers = loadConfig(deps.configPath).providers ?? [];
|
|
2806
4019
|
const result = await handleCliLaunch(cli, body, {
|
|
2807
4020
|
llmConfig: deps.llmConfig,
|
|
@@ -2809,20 +4022,28 @@ async function handleCli(req, res, method, rest, deps) {
|
|
|
2809
4022
|
opener: deps.cliTerminalOpener,
|
|
2810
4023
|
probe: deps.cliPathProbe
|
|
2811
4024
|
});
|
|
2812
|
-
return
|
|
4025
|
+
return writeJson2(res, result.status, result.body);
|
|
2813
4026
|
}
|
|
2814
4027
|
return writeJsonError(res, 405, `method ${method} not allowed on cli`);
|
|
2815
4028
|
}
|
|
2816
4029
|
async function handleStatus(res, method, deps) {
|
|
2817
4030
|
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on status`);
|
|
2818
4031
|
const status = deps.outboundApiServer.getStatus();
|
|
2819
|
-
const serverConfig = await
|
|
2820
|
-
const endpoints = serverConfig.endpoints.map((e) =>
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
4032
|
+
const serverConfig = await loadServerConfig2(deps.settingsStore);
|
|
4033
|
+
const endpoints = serverConfig.endpoints.map((e) => {
|
|
4034
|
+
if (isKindMappedEndpoint(e.endpoint)) {
|
|
4035
|
+
return { endpoint: e.endpoint, kinds: e.modelMap ?? {}, useSubscription: e.useSubscription };
|
|
4036
|
+
}
|
|
4037
|
+
if (e.endpoint === "chat") {
|
|
4038
|
+
return { endpoint: e.endpoint, models: e.models ?? [], useSubscription: e.useSubscription };
|
|
4039
|
+
}
|
|
4040
|
+
return { endpoint: e.endpoint, model: e.defaultModel ?? "", useSubscription: e.useSubscription };
|
|
4041
|
+
});
|
|
4042
|
+
if (status.running) {
|
|
4043
|
+
const queueStatus = deps.outboundApiServer.getQueueStatus();
|
|
4044
|
+
return writeJson2(res, 200, { ...status, endpoints, queueStatus });
|
|
4045
|
+
}
|
|
4046
|
+
return writeJson2(res, 200, { ...status, endpoints });
|
|
2826
4047
|
}
|
|
2827
4048
|
function resolvePlaygroundPath(endpoint, body) {
|
|
2828
4049
|
switch (endpoint) {
|
|
@@ -2842,7 +4063,7 @@ function resolvePlaygroundPath(endpoint, body) {
|
|
|
2842
4063
|
}
|
|
2843
4064
|
async function handlePlayground(req, res, method, deps) {
|
|
2844
4065
|
if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on playground`);
|
|
2845
|
-
const body = await
|
|
4066
|
+
const body = await readJsonBody3(req);
|
|
2846
4067
|
const endpoint = typeof body["endpoint"] === "string" ? body["endpoint"] : "";
|
|
2847
4068
|
const key = typeof body["key"] === "string" ? body["key"] : "";
|
|
2848
4069
|
const payload = body["body"];
|
|
@@ -2987,10 +4208,12 @@ async function handleUiStatic(req, res, urlPath, uiDist) {
|
|
|
2987
4208
|
return true;
|
|
2988
4209
|
}
|
|
2989
4210
|
|
|
4211
|
+
// src/admin/version.ts
|
|
4212
|
+
var DAEMON_VERSION = true ? "0.1.4" : "0.0.0-dev";
|
|
4213
|
+
|
|
2990
4214
|
// src/admin/AdminServer.ts
|
|
2991
4215
|
var LOOPBACK_ADDR = "127.0.0.1";
|
|
2992
4216
|
var LAN_ADDR = "0.0.0.0";
|
|
2993
|
-
var DAEMON_VERSION = true ? "0.1.2" : "0.0.0-dev";
|
|
2994
4217
|
var AdminServer = class {
|
|
2995
4218
|
constructor(deps) {
|
|
2996
4219
|
this.deps = deps;
|
|
@@ -3011,7 +4234,7 @@ var AdminServer = class {
|
|
|
3011
4234
|
const cfg = this.deps.getAdminConfig();
|
|
3012
4235
|
if (!cfg.enabled) return 0;
|
|
3013
4236
|
if (cfg.networkBinding && !cfg.token) {
|
|
3014
|
-
|
|
4237
|
+
this.deps.logger.error(
|
|
3015
4238
|
"[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)."
|
|
3016
4239
|
);
|
|
3017
4240
|
return 0;
|
|
@@ -3020,7 +4243,7 @@ var AdminServer = class {
|
|
|
3020
4243
|
const actualPort = await this.listen(bindAddr, cfg.port);
|
|
3021
4244
|
this.boundAddr = bindAddr;
|
|
3022
4245
|
this.boundPort = actualPort;
|
|
3023
|
-
|
|
4246
|
+
this.deps.logger.info(`[AdminServer] Dashboard listening on ${bindAddr}:${actualPort}`);
|
|
3024
4247
|
return actualPort;
|
|
3025
4248
|
}
|
|
3026
4249
|
/** Bind once; on EADDRINUSE retry with an ephemeral port (port 0). */
|
|
@@ -3042,7 +4265,7 @@ var AdminServer = class {
|
|
|
3042
4265
|
const addr = server.address();
|
|
3043
4266
|
if (addr && typeof addr === "object") {
|
|
3044
4267
|
server.removeListener("error", onError);
|
|
3045
|
-
server.on("error", (e) =>
|
|
4268
|
+
server.on("error", (e) => this.deps.logger.error("[AdminServer] server error", e));
|
|
3046
4269
|
this.server = server;
|
|
3047
4270
|
resolve(addr.port);
|
|
3048
4271
|
} else {
|
|
@@ -3055,7 +4278,7 @@ var AdminServer = class {
|
|
|
3055
4278
|
onRequest(req, res) {
|
|
3056
4279
|
void this.dispatch(req, res).catch((err5) => {
|
|
3057
4280
|
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
3058
|
-
|
|
4281
|
+
this.deps.logger.error("[AdminServer] unhandled error:", message);
|
|
3059
4282
|
if (!res.headersSent) {
|
|
3060
4283
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3061
4284
|
res.end(JSON.stringify({ error: { type: "admin_error", message } }));
|
|
@@ -3066,18 +4289,42 @@ var AdminServer = class {
|
|
|
3066
4289
|
const cfg = this.deps.getAdminConfig();
|
|
3067
4290
|
res.setHeader("x-omnicross-daemon", DAEMON_VERSION);
|
|
3068
4291
|
res.setHeader("x-omnicross-pid", String(process.pid));
|
|
4292
|
+
const url = req.url ?? "/";
|
|
4293
|
+
const path2 = url.split("?")[0];
|
|
4294
|
+
const healthPath = path2.replace(/\/+$/, "") || "/";
|
|
4295
|
+
if ((req.method === "GET" || req.method === "HEAD") && (healthPath === "/health" || healthPath === "/healthz")) {
|
|
4296
|
+
const report = this.deps.getHealthReport();
|
|
4297
|
+
const code = healthHttpStatus(report.status);
|
|
4298
|
+
res.writeHead(code, { "Content-Type": "application/json" });
|
|
4299
|
+
res.end(req.method === "HEAD" ? void 0 : JSON.stringify(report));
|
|
4300
|
+
return;
|
|
4301
|
+
}
|
|
3069
4302
|
if (cfg.token && !this.isAuthorized(req, cfg.token)) {
|
|
3070
4303
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
3071
4304
|
res.end(JSON.stringify({ error: { type: "unauthorized", message: "admin token required" } }));
|
|
3072
4305
|
return;
|
|
3073
4306
|
}
|
|
3074
|
-
const url = req.url ?? "/";
|
|
3075
|
-
const path2 = url.split("?")[0];
|
|
3076
4307
|
if ((req.method === "GET" || req.method === "HEAD") && (path2 === "/" || path2 === "/admin")) {
|
|
3077
4308
|
res.writeHead(302, { Location: "/ui/" });
|
|
3078
4309
|
res.end();
|
|
3079
4310
|
return;
|
|
3080
4311
|
}
|
|
4312
|
+
if (path2 === "/admin/api/account-probes" && (req.method === "GET" || req.method === "HEAD")) {
|
|
4313
|
+
handleAccountProbes(res, this.deps.probeHistoryReader);
|
|
4314
|
+
return;
|
|
4315
|
+
}
|
|
4316
|
+
if (path2 === "/admin/api/audit" && (req.method === "GET" || req.method === "HEAD")) {
|
|
4317
|
+
handleAuditQuery(req, res, this.deps.auditReader);
|
|
4318
|
+
return;
|
|
4319
|
+
}
|
|
4320
|
+
if (path2 === "/admin/api/billing-status" && (req.method === "GET" || req.method === "HEAD")) {
|
|
4321
|
+
handleBillingStatus(res, this.deps.billingStatusReader);
|
|
4322
|
+
return;
|
|
4323
|
+
}
|
|
4324
|
+
if (path2 === "/admin/api/webhook-test" && req.method === "POST") {
|
|
4325
|
+
await handleWebhookTest(req, res);
|
|
4326
|
+
return;
|
|
4327
|
+
}
|
|
3081
4328
|
if (path2.startsWith("/admin/api/")) {
|
|
3082
4329
|
await handleAdminApi(req, res, path2, this.deps);
|
|
3083
4330
|
return;
|
|
@@ -3121,6 +4368,51 @@ function constantTimeEquals(a, b) {
|
|
|
3121
4368
|
return timingSafeEqual(bufA, bufB);
|
|
3122
4369
|
}
|
|
3123
4370
|
|
|
4371
|
+
// src/admin/health.ts
|
|
4372
|
+
var CRITICAL_CHECKS = ["config", "credentialStore"];
|
|
4373
|
+
var READINESS_CHECKS = ["outboundServer"];
|
|
4374
|
+
function safeBool(fn) {
|
|
4375
|
+
try {
|
|
4376
|
+
return fn() === true;
|
|
4377
|
+
} catch {
|
|
4378
|
+
return false;
|
|
4379
|
+
}
|
|
4380
|
+
}
|
|
4381
|
+
function toMb(bytes) {
|
|
4382
|
+
return Math.round(bytes / (1024 * 1024) * 10) / 10;
|
|
4383
|
+
}
|
|
4384
|
+
function buildHealthReport(deps) {
|
|
4385
|
+
const checks = {
|
|
4386
|
+
config: safeBool(deps.configPresent),
|
|
4387
|
+
credentialStore: safeBool(deps.credentialStoreReadable),
|
|
4388
|
+
outboundServer: safeBool(deps.outboundServerRunning),
|
|
4389
|
+
adminServer: safeBool(deps.adminServerRunning)
|
|
4390
|
+
};
|
|
4391
|
+
if (deps.subscriptionAccountsHealthy) {
|
|
4392
|
+
let probeHealthy;
|
|
4393
|
+
try {
|
|
4394
|
+
probeHealthy = deps.subscriptionAccountsHealthy();
|
|
4395
|
+
} catch {
|
|
4396
|
+
probeHealthy = false;
|
|
4397
|
+
}
|
|
4398
|
+
if (probeHealthy !== void 0) checks.subscriptionAccountsHealthy = probeHealthy;
|
|
4399
|
+
}
|
|
4400
|
+
const criticalOk = CRITICAL_CHECKS.every((k) => checks[k]);
|
|
4401
|
+
const readinessOk = READINESS_CHECKS.every((k) => checks[k]);
|
|
4402
|
+
const status = !criticalOk ? "error" : readinessOk ? "ok" : "degraded";
|
|
4403
|
+
const mem = (deps.memoryUsage ?? process.memoryUsage)();
|
|
4404
|
+
const uptime = (deps.uptimeSeconds ?? process.uptime)();
|
|
4405
|
+
const nowMs = (deps.now ?? Date.now)();
|
|
4406
|
+
return {
|
|
4407
|
+
status,
|
|
4408
|
+
version: deps.version,
|
|
4409
|
+
uptimeSeconds: Math.floor(uptime),
|
|
4410
|
+
timestamp: new Date(nowMs).toISOString(),
|
|
4411
|
+
memory: { rssMb: toMb(mem.rss), heapUsedMb: toMb(mem.heapUsed) },
|
|
4412
|
+
checks
|
|
4413
|
+
};
|
|
4414
|
+
}
|
|
4415
|
+
|
|
3124
4416
|
// src/admin/oauthSessions.ts
|
|
3125
4417
|
import crypto2 from "crypto";
|
|
3126
4418
|
var DEFAULT_OAUTH_SESSION_TTL_MS = 10 * 60 * 1e3;
|
|
@@ -3172,7 +4464,7 @@ var DEFAULT_TIMEOUT_MS = 5 * 6e4;
|
|
|
3172
4464
|
function pageHtml(message) {
|
|
3173
4465
|
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>`;
|
|
3174
4466
|
}
|
|
3175
|
-
function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
4467
|
+
function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS, signal) {
|
|
3176
4468
|
return new Promise((resolve, reject) => {
|
|
3177
4469
|
let settled = false;
|
|
3178
4470
|
const finish = (server2, fn) => {
|
|
@@ -3206,6 +4498,12 @@ function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
3206
4498
|
res.end(pageHtml("Login complete."));
|
|
3207
4499
|
finish(server, () => resolve(code));
|
|
3208
4500
|
});
|
|
4501
|
+
const abort = () => finish(server, () => reject(new Error("login: cancelled")));
|
|
4502
|
+
if (signal?.aborted) {
|
|
4503
|
+
abort();
|
|
4504
|
+
return;
|
|
4505
|
+
}
|
|
4506
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
3209
4507
|
server.on("error", (err5) => {
|
|
3210
4508
|
if (settled) return;
|
|
3211
4509
|
settled = true;
|
|
@@ -3452,46 +4750,199 @@ function toLLMProvider(row) {
|
|
|
3452
4750
|
};
|
|
3453
4751
|
}
|
|
3454
4752
|
|
|
3455
|
-
// src/ports/
|
|
3456
|
-
|
|
4753
|
+
// src/ports/ConfigurableLogger.ts
|
|
4754
|
+
import { createWriteStream } from "fs";
|
|
4755
|
+
var LEVEL_ORDER = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
4756
|
+
var RESERVED_JSON_KEYS = /* @__PURE__ */ new Set(["ts", "level", "msg", "error"]);
|
|
4757
|
+
var ConfigurableLogger = class {
|
|
4758
|
+
threshold;
|
|
4759
|
+
format;
|
|
4760
|
+
filePath;
|
|
4761
|
+
fileStream = null;
|
|
4762
|
+
fileDisabled = false;
|
|
4763
|
+
constructor(cfg) {
|
|
4764
|
+
this.threshold = LEVEL_ORDER[cfg?.level ?? "debug"];
|
|
4765
|
+
this.format = cfg?.format ?? "text";
|
|
4766
|
+
this.filePath = cfg?.file && cfg.file.length > 0 ? cfg.file : void 0;
|
|
4767
|
+
}
|
|
3457
4768
|
info(message, meta) {
|
|
3458
|
-
|
|
3459
|
-
else console.info(message, meta);
|
|
4769
|
+
this.emit("info", message, void 0, meta);
|
|
3460
4770
|
}
|
|
3461
4771
|
warn(message, meta) {
|
|
3462
|
-
|
|
3463
|
-
else console.warn(message, meta);
|
|
4772
|
+
this.emit("warn", message, void 0, meta);
|
|
3464
4773
|
}
|
|
3465
4774
|
error(message, error, meta) {
|
|
3466
|
-
|
|
3467
|
-
else if (meta === void 0) console.error(message, error);
|
|
3468
|
-
else console.error(message, error, meta);
|
|
4775
|
+
this.emit("error", message, error, meta);
|
|
3469
4776
|
}
|
|
3470
4777
|
debug(message, meta) {
|
|
3471
|
-
|
|
3472
|
-
|
|
4778
|
+
this.emit("debug", message, void 0, meta);
|
|
4779
|
+
}
|
|
4780
|
+
/**
|
|
4781
|
+
* Flush + close the file sink (tests / graceful shutdown). Resolves once the
|
|
4782
|
+
* append stream has finished flushing to disk. No-op when no file sink is open.
|
|
4783
|
+
*/
|
|
4784
|
+
close() {
|
|
4785
|
+
const stream = this.fileStream;
|
|
4786
|
+
this.fileStream = null;
|
|
4787
|
+
if (!stream) return Promise.resolve();
|
|
4788
|
+
return new Promise((resolve) => stream.end(() => resolve()));
|
|
4789
|
+
}
|
|
4790
|
+
emit(level, message, error, meta) {
|
|
4791
|
+
if (LEVEL_ORDER[level] > this.threshold) return;
|
|
4792
|
+
this.writeConsole(level, message, error, meta);
|
|
4793
|
+
if (this.filePath) this.writeFile(level, message, error, meta);
|
|
4794
|
+
}
|
|
4795
|
+
/**
|
|
4796
|
+
* Console sink. In `text` format this reproduces the legacy `ConsoleLogger`
|
|
4797
|
+
* EXACTLY (same method + arg shape) so the unconfigured default is a byte-for-
|
|
4798
|
+
* byte drop-in; in `json` format it prints the structured line.
|
|
4799
|
+
*/
|
|
4800
|
+
writeConsole(level, message, error, meta) {
|
|
4801
|
+
if (this.format === "json") {
|
|
4802
|
+
this.consoleFn(level)(this.jsonLine(level, message, error, meta));
|
|
4803
|
+
return;
|
|
4804
|
+
}
|
|
4805
|
+
if (level === "error") {
|
|
4806
|
+
if (error === void 0 && meta === void 0) console.error(message);
|
|
4807
|
+
else if (meta === void 0) console.error(message, error);
|
|
4808
|
+
else console.error(message, error, meta);
|
|
4809
|
+
return;
|
|
4810
|
+
}
|
|
4811
|
+
const fn = this.consoleFn(level);
|
|
4812
|
+
if (meta === void 0) fn(message);
|
|
4813
|
+
else fn(message, meta);
|
|
4814
|
+
}
|
|
4815
|
+
/** Append one line to the file sink; a failure disables the sink (swallowed). */
|
|
4816
|
+
writeFile(level, message, error, meta) {
|
|
4817
|
+
const stream = this.getFileStream();
|
|
4818
|
+
if (!stream) return;
|
|
4819
|
+
try {
|
|
4820
|
+
const line = this.format === "json" ? this.jsonLine(level, message, error, meta) : this.textLine(level, message, error, meta);
|
|
4821
|
+
stream.write(line + "\n");
|
|
4822
|
+
} catch {
|
|
4823
|
+
}
|
|
4824
|
+
}
|
|
4825
|
+
/** Lazily open the append-only file stream; disable the sink on any error. */
|
|
4826
|
+
getFileStream() {
|
|
4827
|
+
if (this.fileDisabled || !this.filePath) return null;
|
|
4828
|
+
if (this.fileStream) return this.fileStream;
|
|
4829
|
+
try {
|
|
4830
|
+
const stream = createWriteStream(this.filePath, { flags: "a" });
|
|
4831
|
+
stream.on("error", () => {
|
|
4832
|
+
this.fileDisabled = true;
|
|
4833
|
+
this.fileStream = null;
|
|
4834
|
+
});
|
|
4835
|
+
this.fileStream = stream;
|
|
4836
|
+
return stream;
|
|
4837
|
+
} catch {
|
|
4838
|
+
this.fileDisabled = true;
|
|
4839
|
+
return null;
|
|
4840
|
+
}
|
|
4841
|
+
}
|
|
4842
|
+
consoleFn(level) {
|
|
4843
|
+
switch (level) {
|
|
4844
|
+
case "error":
|
|
4845
|
+
return console.error;
|
|
4846
|
+
case "warn":
|
|
4847
|
+
return console.warn;
|
|
4848
|
+
case "info":
|
|
4849
|
+
return console.info;
|
|
4850
|
+
case "debug":
|
|
4851
|
+
return console.debug;
|
|
4852
|
+
}
|
|
4853
|
+
}
|
|
4854
|
+
/** `{ ts, level, msg, ...meta }` (+ `error` when present) as a single line. */
|
|
4855
|
+
jsonLine(level, message, error, meta) {
|
|
4856
|
+
const obj = {
|
|
4857
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4858
|
+
level,
|
|
4859
|
+
msg: message
|
|
4860
|
+
};
|
|
4861
|
+
if (error !== void 0) obj["error"] = reduceError(error);
|
|
4862
|
+
if (meta !== void 0) {
|
|
4863
|
+
if (meta instanceof Error) obj["meta"] = reduceError(meta);
|
|
4864
|
+
else if (meta && typeof meta === "object") {
|
|
4865
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
4866
|
+
if (!RESERVED_JSON_KEYS.has(k)) obj[k] = v;
|
|
4867
|
+
}
|
|
4868
|
+
} else obj["meta"] = meta;
|
|
4869
|
+
}
|
|
4870
|
+
try {
|
|
4871
|
+
return JSON.stringify(obj);
|
|
4872
|
+
} catch {
|
|
4873
|
+
return JSON.stringify({ ts: obj["ts"], level, msg: message });
|
|
4874
|
+
}
|
|
4875
|
+
}
|
|
4876
|
+
/** Human-readable file line: `ISO [level] message {metaJson}`. */
|
|
4877
|
+
textLine(level, message, error, meta) {
|
|
4878
|
+
const parts = [`${(/* @__PURE__ */ new Date()).toISOString()} [${level}] ${message}`];
|
|
4879
|
+
if (error !== void 0) parts.push(safeStringify(reduceError(error)));
|
|
4880
|
+
if (meta !== void 0) parts.push(safeStringify(meta instanceof Error ? reduceError(meta) : meta));
|
|
4881
|
+
return parts.join(" ");
|
|
3473
4882
|
}
|
|
3474
4883
|
};
|
|
4884
|
+
function reduceError(error) {
|
|
4885
|
+
if (error instanceof Error) {
|
|
4886
|
+
return error.stack ? { message: error.message, stack: error.stack } : { message: error.message };
|
|
4887
|
+
}
|
|
4888
|
+
return { value: String(error) };
|
|
4889
|
+
}
|
|
4890
|
+
function safeStringify(value) {
|
|
4891
|
+
try {
|
|
4892
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
4893
|
+
} catch {
|
|
4894
|
+
return "[unserializable]";
|
|
4895
|
+
}
|
|
4896
|
+
}
|
|
3475
4897
|
|
|
3476
4898
|
// src/ports/JsonApiServerSettingsStore.ts
|
|
3477
4899
|
import { readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
3478
4900
|
import { OUTBOUND_API_SERVER_CONFIG_KEY } from "@omnicross/core/outbound-api";
|
|
3479
4901
|
var JsonApiServerSettingsStore = class {
|
|
3480
|
-
|
|
4902
|
+
/**
|
|
4903
|
+
* @param configPath the daemon config.json whose `server` field is backed.
|
|
4904
|
+
* @param box OPTIONAL at-rest `SecretBox` (upstream-proxy). When set, the
|
|
4905
|
+
* `server.proxy.*` passwords are encrypted-on-`set` /
|
|
4906
|
+
* decrypted-on-`get` (the settings-store path is otherwise not
|
|
4907
|
+
* secret-aware — every OTHER server field is non-secret). Null
|
|
4908
|
+
* ⇒ passthrough (legacy/pure tests unchanged).
|
|
4909
|
+
*/
|
|
4910
|
+
constructor(configPath, box = null) {
|
|
3481
4911
|
this.configPath = configPath;
|
|
4912
|
+
this.box = box;
|
|
3482
4913
|
}
|
|
3483
4914
|
configPath;
|
|
4915
|
+
box;
|
|
3484
4916
|
async get(key) {
|
|
3485
4917
|
if (key !== OUTBOUND_API_SERVER_CONFIG_KEY) return void 0;
|
|
3486
4918
|
const file = this.readFile();
|
|
3487
|
-
|
|
4919
|
+
if (file.server === void 0) return void 0;
|
|
4920
|
+
return this.decryptSecrets(file.server);
|
|
3488
4921
|
}
|
|
3489
4922
|
async set(key, value) {
|
|
3490
4923
|
if (key !== OUTBOUND_API_SERVER_CONFIG_KEY) return;
|
|
3491
4924
|
const file = this.readFile();
|
|
3492
|
-
file.server = value;
|
|
4925
|
+
file.server = this.encryptSecrets(value);
|
|
3493
4926
|
writeFileSync4(this.configPath, JSON.stringify(file, null, 2) + "\n", "utf8");
|
|
3494
4927
|
}
|
|
4928
|
+
/** Encrypt the proxy passwords + webhook + billing secrets before persisting (no-op without a box). */
|
|
4929
|
+
encryptSecrets(config) {
|
|
4930
|
+
if (!this.box) return config;
|
|
4931
|
+
let out = config;
|
|
4932
|
+
if (out?.proxy) out = { ...out, proxy: encryptProxySegment(out.proxy, this.box) };
|
|
4933
|
+
if (out?.webhook) out = { ...out, webhook: encryptWebhookSegment(out.webhook, this.box) };
|
|
4934
|
+
if (out?.billing) out = { ...out, billing: encryptBillingSegment(out.billing, this.box) };
|
|
4935
|
+
return out;
|
|
4936
|
+
}
|
|
4937
|
+
/** Decrypt the proxy passwords + webhook + billing secrets on read (no-op without a box). */
|
|
4938
|
+
decryptSecrets(config) {
|
|
4939
|
+
if (!this.box) return config;
|
|
4940
|
+
let out = config;
|
|
4941
|
+
if (out?.proxy) out = { ...out, proxy: decryptProxySegment(out.proxy, this.box) };
|
|
4942
|
+
if (out?.webhook) out = { ...out, webhook: decryptWebhookSegment(out.webhook, this.box) };
|
|
4943
|
+
if (out?.billing) out = { ...out, billing: decryptBillingSegment(out.billing, this.box) };
|
|
4944
|
+
return out;
|
|
4945
|
+
}
|
|
3495
4946
|
/** Read the config.json, tolerating a missing/corrupt file (→ empty shape). */
|
|
3496
4947
|
readFile() {
|
|
3497
4948
|
try {
|
|
@@ -3610,6 +5061,57 @@ var JsonlUsageEventStore = class {
|
|
|
3610
5061
|
}
|
|
3611
5062
|
return Array.from(groups.values());
|
|
3612
5063
|
}
|
|
5064
|
+
/**
|
|
5065
|
+
* ONE pass over a single key's events (`ts < endTs`) summing its `costUsd` into
|
|
5066
|
+
* `totalUsd` / `dailyUsd` (`ts >= dayStartTs`) / `weeklyUsd` (`ts >= weekStartTs`).
|
|
5067
|
+
* Used to lazily seed the outbound key-policy spend tracker (once per key). A
|
|
5068
|
+
* key with no attributed events yields all zeros.
|
|
5069
|
+
*/
|
|
5070
|
+
async getSpendByKey(query) {
|
|
5071
|
+
let totalUsd = 0;
|
|
5072
|
+
let dailyUsd = 0;
|
|
5073
|
+
let weeklyUsd = 0;
|
|
5074
|
+
for (const row of this.readRows({ startTs: 0, endTs: query.endTs })) {
|
|
5075
|
+
if (row.apiKeyId !== query.apiKeyId) continue;
|
|
5076
|
+
totalUsd += row.costUsd;
|
|
5077
|
+
if (row.ts >= query.dayStartTs) dailyUsd += row.costUsd;
|
|
5078
|
+
if (row.ts >= query.weekStartTs) weeklyUsd += row.costUsd;
|
|
5079
|
+
}
|
|
5080
|
+
return { totalUsd, dailyUsd, weeklyUsd };
|
|
5081
|
+
}
|
|
5082
|
+
/**
|
|
5083
|
+
* Time-series aggregation over LOCAL-time bucket boundaries. Every bucket in
|
|
5084
|
+
* `[floor(startTs), endTs)` is present (empty ones zero-filled), ascending by
|
|
5085
|
+
* `bucketStartTs`; an empty range (`startTs >= endTs`) returns `[]`. Reuses
|
|
5086
|
+
* `readRows` so malformed lines are skipped and only in-range rows contribute.
|
|
5087
|
+
*/
|
|
5088
|
+
async getTimeSeries(range, bucket) {
|
|
5089
|
+
if (range.startTs >= range.endTs) return [];
|
|
5090
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
5091
|
+
for (let b = floorToBucket(range.startTs, bucket); b < range.endTs; b = nextBoundary(b, bucket)) {
|
|
5092
|
+
buckets.set(b, {
|
|
5093
|
+
bucketStartTs: b,
|
|
5094
|
+
label: bucketLabel(b, bucket),
|
|
5095
|
+
requests: 0,
|
|
5096
|
+
inputTokens: 0,
|
|
5097
|
+
outputTokens: 0,
|
|
5098
|
+
cacheReadTokens: 0,
|
|
5099
|
+
cacheCreationTokens: 0,
|
|
5100
|
+
costUsd: 0
|
|
5101
|
+
});
|
|
5102
|
+
}
|
|
5103
|
+
for (const row of this.readRows(range)) {
|
|
5104
|
+
const g = buckets.get(floorToBucket(row.ts, bucket));
|
|
5105
|
+
if (!g) continue;
|
|
5106
|
+
g.requests += 1;
|
|
5107
|
+
g.inputTokens += row.inputTokens;
|
|
5108
|
+
g.outputTokens += row.outputTokens;
|
|
5109
|
+
g.cacheReadTokens += row.cacheReadTokens;
|
|
5110
|
+
g.cacheCreationTokens += row.cacheCreationTokens;
|
|
5111
|
+
g.costUsd += row.costUsd;
|
|
5112
|
+
}
|
|
5113
|
+
return Array.from(buckets.values());
|
|
5114
|
+
}
|
|
3613
5115
|
async getMessagesForSession(sessionId) {
|
|
3614
5116
|
return this.readAllRows().filter((r) => r.sessionId === sessionId).sort((a, b) => a.ts - b.ts).map((r) => ({
|
|
3615
5117
|
id: r.id,
|
|
@@ -3678,6 +5180,43 @@ var JsonlUsageEventStore = class {
|
|
|
3678
5180
|
return rows;
|
|
3679
5181
|
}
|
|
3680
5182
|
};
|
|
5183
|
+
function floorToBucket(ts, bucket) {
|
|
5184
|
+
const d = new Date(ts);
|
|
5185
|
+
switch (bucket) {
|
|
5186
|
+
case "hour":
|
|
5187
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()).getTime();
|
|
5188
|
+
case "day":
|
|
5189
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
5190
|
+
case "month":
|
|
5191
|
+
return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
|
|
5192
|
+
}
|
|
5193
|
+
}
|
|
5194
|
+
function nextBoundary(ts, bucket) {
|
|
5195
|
+
const d = new Date(ts);
|
|
5196
|
+
switch (bucket) {
|
|
5197
|
+
case "hour":
|
|
5198
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours() + 1).getTime();
|
|
5199
|
+
case "day":
|
|
5200
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime();
|
|
5201
|
+
case "month":
|
|
5202
|
+
return new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
|
|
5203
|
+
}
|
|
5204
|
+
}
|
|
5205
|
+
var pad2 = (n) => String(n).padStart(2, "0");
|
|
5206
|
+
function bucketLabel(bucketStartTs, bucket) {
|
|
5207
|
+
const d = new Date(bucketStartTs);
|
|
5208
|
+
const y = d.getFullYear();
|
|
5209
|
+
const mo = pad2(d.getMonth() + 1);
|
|
5210
|
+
const day = pad2(d.getDate());
|
|
5211
|
+
switch (bucket) {
|
|
5212
|
+
case "hour":
|
|
5213
|
+
return `${mo}-${day} ${pad2(d.getHours())}:00`;
|
|
5214
|
+
case "day":
|
|
5215
|
+
return `${y}-${mo}-${day}`;
|
|
5216
|
+
case "month":
|
|
5217
|
+
return `${y}-${mo}`;
|
|
5218
|
+
}
|
|
5219
|
+
}
|
|
3681
5220
|
var NUMERIC_FIELDS = [
|
|
3682
5221
|
"ts",
|
|
3683
5222
|
"inputTokens",
|
|
@@ -3832,48 +5371,143 @@ var JsonPricingStore = class {
|
|
|
3832
5371
|
}
|
|
3833
5372
|
};
|
|
3834
5373
|
|
|
3835
|
-
// src/ports/
|
|
3836
|
-
import { existsSync as
|
|
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
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
5374
|
+
// src/ports/JsonVoucherDb.ts
|
|
5375
|
+
import { existsSync as existsSync7, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "fs";
|
|
5376
|
+
var JsonVoucherDb = class {
|
|
5377
|
+
constructor(vouchersPath) {
|
|
5378
|
+
this.vouchersPath = vouchersPath;
|
|
5379
|
+
}
|
|
5380
|
+
vouchersPath;
|
|
5381
|
+
async voucherCreate(input) {
|
|
5382
|
+
const rows = this.readRows();
|
|
5383
|
+
const row = {
|
|
5384
|
+
id: input.id,
|
|
5385
|
+
codeHash: input.codeHash,
|
|
5386
|
+
codePrefix: input.codePrefix,
|
|
5387
|
+
type: input.type,
|
|
5388
|
+
status: "unredeemed",
|
|
5389
|
+
createdAt: input.createdAt ?? Date.now()
|
|
5390
|
+
};
|
|
5391
|
+
if (input.creditUsd != null) row.creditUsd = input.creditUsd;
|
|
5392
|
+
if (input.renewalDays != null) row.renewalDays = input.renewalDays;
|
|
5393
|
+
if (input.maxTotalCostLimitUsd != null) row.maxTotalCostLimitUsd = input.maxTotalCostLimitUsd;
|
|
5394
|
+
if (input.maxExpiryDays != null) row.maxExpiryDays = input.maxExpiryDays;
|
|
5395
|
+
rows.push(row);
|
|
5396
|
+
this.writeRows(rows);
|
|
5397
|
+
return row;
|
|
5398
|
+
}
|
|
5399
|
+
async voucherGetByHash(codeHash) {
|
|
5400
|
+
const rows = this.readRows();
|
|
5401
|
+
return rows.find((r) => r.codeHash === codeHash) ?? null;
|
|
5402
|
+
}
|
|
5403
|
+
async voucherRedeemCas(id, keyId, granted, now) {
|
|
5404
|
+
const rows = this.readRows();
|
|
5405
|
+
const row = rows.find((r) => r.id === id);
|
|
5406
|
+
if (!row || row.status !== "unredeemed") return false;
|
|
5407
|
+
row.status = "redeemed";
|
|
5408
|
+
row.redeemedAt = now;
|
|
5409
|
+
row.redeemedByKeyId = keyId;
|
|
5410
|
+
row.grantApplied = false;
|
|
5411
|
+
if (granted.totalCostLimitUsd != null) row.grantedTotalCostLimitUsd = granted.totalCostLimitUsd;
|
|
5412
|
+
if (granted.expiresAt != null) row.grantedExpiresAt = granted.expiresAt;
|
|
5413
|
+
this.writeRows(rows);
|
|
5414
|
+
return true;
|
|
5415
|
+
}
|
|
5416
|
+
async voucherMarkGrantApplied(id) {
|
|
5417
|
+
const rows = this.readRows();
|
|
5418
|
+
const row = rows.find((r) => r.id === id);
|
|
5419
|
+
if (!row || row.status !== "redeemed") return false;
|
|
5420
|
+
if (row.grantApplied === true) return true;
|
|
5421
|
+
row.grantApplied = true;
|
|
5422
|
+
this.writeRows(rows);
|
|
5423
|
+
return true;
|
|
5424
|
+
}
|
|
5425
|
+
async voucherRevertRedeem(id, keyId) {
|
|
5426
|
+
const rows = this.readRows();
|
|
5427
|
+
const row = rows.find((r) => r.id === id);
|
|
5428
|
+
if (!row || row.status !== "redeemed" || row.grantApplied === true) return false;
|
|
5429
|
+
if (row.redeemedByKeyId !== keyId) return false;
|
|
5430
|
+
row.status = "unredeemed";
|
|
5431
|
+
delete row.redeemedAt;
|
|
5432
|
+
delete row.redeemedByKeyId;
|
|
5433
|
+
delete row.grantApplied;
|
|
5434
|
+
delete row.grantedTotalCostLimitUsd;
|
|
5435
|
+
delete row.grantedExpiresAt;
|
|
5436
|
+
this.writeRows(rows);
|
|
5437
|
+
return true;
|
|
5438
|
+
}
|
|
5439
|
+
async voucherRevokeCas(id, now) {
|
|
5440
|
+
const rows = this.readRows();
|
|
5441
|
+
const row = rows.find((r) => r.id === id);
|
|
5442
|
+
if (!row || row.status !== "unredeemed") return false;
|
|
5443
|
+
row.status = "revoked";
|
|
5444
|
+
row.revokedAt = now;
|
|
5445
|
+
this.writeRows(rows);
|
|
5446
|
+
return true;
|
|
5447
|
+
}
|
|
5448
|
+
async voucherList() {
|
|
5449
|
+
return this.readRows();
|
|
5450
|
+
}
|
|
5451
|
+
/** Read the voucher rows, tolerating a missing/corrupt file (→ empty list). */
|
|
5452
|
+
readRows() {
|
|
5453
|
+
if (!existsSync7(this.vouchersPath)) return [];
|
|
5454
|
+
try {
|
|
5455
|
+
const parsed = JSON.parse(readFileSync8(this.vouchersPath, "utf8"));
|
|
5456
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
5457
|
+
} catch {
|
|
5458
|
+
return [];
|
|
5459
|
+
}
|
|
5460
|
+
}
|
|
5461
|
+
writeRows(rows) {
|
|
5462
|
+
writeFileSync6(this.vouchersPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
|
|
5463
|
+
}
|
|
5464
|
+
};
|
|
5465
|
+
|
|
5466
|
+
// src/ports/JsonSubscriptionCredentialStore.ts
|
|
5467
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync3, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "fs";
|
|
5468
|
+
import { dirname as dirname4 } from "path";
|
|
5469
|
+
import { getSharedAccountHealth } from "@omnicross/core/pipeline/SubscriptionAccountHealth";
|
|
5470
|
+
import { fetchUpstream as fetchUpstream2 } from "@omnicross/core/pipeline/upstreamFetch";
|
|
5471
|
+
import { getSharedIdentityStore } from "@omnicross/core/provider-proxy/identity/SubscriptionIdentityStore";
|
|
5472
|
+
import {
|
|
5473
|
+
claudeOAuth as claudeOAuth2,
|
|
5474
|
+
codexOAuth as codexOAuth2,
|
|
5475
|
+
geminiOAuth as geminiOAuth2
|
|
5476
|
+
} from "@omnicross/subscriptions";
|
|
5477
|
+
|
|
5478
|
+
// src/ports/account-sync.ts
|
|
5479
|
+
var IMPORT_EXPIRY_MARGIN_MS = 6e4;
|
|
5480
|
+
function viewOf(tokens) {
|
|
5481
|
+
return tokens;
|
|
5482
|
+
}
|
|
5483
|
+
function decideExternalImport(captured, external, now = Date.now()) {
|
|
5484
|
+
if (!external?.accessToken) return "no-credential";
|
|
5485
|
+
const capturedRt = viewOf(captured).refreshToken;
|
|
5486
|
+
const hasNewRefresh = Boolean(external.refreshToken && external.refreshToken !== capturedRt);
|
|
5487
|
+
const accessStillValid = external.expiresAt ? Date.parse(external.expiresAt) > now + IMPORT_EXPIRY_MARGIN_MS : true;
|
|
5488
|
+
return hasNewRefresh || accessStillValid ? "import" : "not-rotated";
|
|
5489
|
+
}
|
|
5490
|
+
function buildImportedTokens(captured, external) {
|
|
5491
|
+
const imported = {
|
|
5492
|
+
...captured,
|
|
5493
|
+
accessToken: external.accessToken,
|
|
5494
|
+
status: "authorized",
|
|
5495
|
+
errorMessage: void 0,
|
|
5496
|
+
syncWarning: void 0,
|
|
5497
|
+
lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5498
|
+
};
|
|
5499
|
+
if (external.refreshToken) imported.refreshToken = external.refreshToken;
|
|
5500
|
+
if (external.expiresAt) imported.expiresAt = external.expiresAt;
|
|
5501
|
+
else delete imported.expiresAt;
|
|
5502
|
+
if (external.idToken) imported.idToken = external.idToken;
|
|
5503
|
+
if (external.scopes) imported.scopes = external.scopes;
|
|
5504
|
+
return imported;
|
|
5505
|
+
}
|
|
5506
|
+
function buildTokensFromExternal(provider, external) {
|
|
5507
|
+
const base = {
|
|
5508
|
+
authMethod: "oauth",
|
|
5509
|
+
status: "authorized",
|
|
5510
|
+
accessToken: external.accessToken,
|
|
3877
5511
|
lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3878
5512
|
};
|
|
3879
5513
|
if (provider === "claude") {
|
|
@@ -3915,7 +5549,7 @@ function findDuplicateCredentialIds(accounts) {
|
|
|
3915
5549
|
}
|
|
3916
5550
|
|
|
3917
5551
|
// src/ports/external-cli-credentials.ts
|
|
3918
|
-
import { existsSync as
|
|
5552
|
+
import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
|
|
3919
5553
|
import { homedir as homedir2 } from "os";
|
|
3920
5554
|
import { join as join4 } from "path";
|
|
3921
5555
|
function externalStorePath(provider, home = homedir2()) {
|
|
@@ -3968,10 +5602,10 @@ function parseCodexTokensEnvelope(raw) {
|
|
|
3968
5602
|
}
|
|
3969
5603
|
function readExternalCliCredentials(provider, home = homedir2()) {
|
|
3970
5604
|
const path2 = externalStorePath(provider, home);
|
|
3971
|
-
if (!
|
|
5605
|
+
if (!existsSync8(path2)) return null;
|
|
3972
5606
|
let raw;
|
|
3973
5607
|
try {
|
|
3974
|
-
const parsed = JSON.parse(
|
|
5608
|
+
const parsed = JSON.parse(readFileSync9(path2, "utf8"));
|
|
3975
5609
|
raw = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
|
|
3976
5610
|
} catch {
|
|
3977
5611
|
return null;
|
|
@@ -3980,7 +5614,7 @@ function readExternalCliCredentials(provider, home = homedir2()) {
|
|
|
3980
5614
|
}
|
|
3981
5615
|
|
|
3982
5616
|
// src/ports/external-cli-store.ts
|
|
3983
|
-
import { copyFileSync, existsSync as
|
|
5617
|
+
import { copyFileSync, existsSync as existsSync9, mkdirSync as mkdirSync2, readFileSync as readFileSync10, renameSync, writeFileSync as writeFileSync7 } from "fs";
|
|
3984
5618
|
import { homedir as homedir3 } from "os";
|
|
3985
5619
|
import { dirname as dirname3 } from "path";
|
|
3986
5620
|
function markerPath(provider, home) {
|
|
@@ -4008,9 +5642,9 @@ function buildCodexTokensEnvelope(tokens) {
|
|
|
4008
5642
|
return envelope;
|
|
4009
5643
|
}
|
|
4010
5644
|
function readExistingObject(path2) {
|
|
4011
|
-
if (!
|
|
5645
|
+
if (!existsSync9(path2)) return {};
|
|
4012
5646
|
try {
|
|
4013
|
-
const parsed = JSON.parse(
|
|
5647
|
+
const parsed = JSON.parse(readFileSync10(path2, "utf8"));
|
|
4014
5648
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
4015
5649
|
} catch {
|
|
4016
5650
|
return {};
|
|
@@ -4019,16 +5653,16 @@ function readExistingObject(path2) {
|
|
|
4019
5653
|
function writeAtomic(path2, content) {
|
|
4020
5654
|
mkdirSync2(dirname3(path2), { recursive: true });
|
|
4021
5655
|
const temp = `${path2}.omnicross-tmp`;
|
|
4022
|
-
|
|
5656
|
+
writeFileSync7(temp, content, "utf8");
|
|
4023
5657
|
renameSync(temp, path2);
|
|
4024
5658
|
}
|
|
4025
5659
|
function createExternalCliStore(home = homedir3()) {
|
|
4026
5660
|
return {
|
|
4027
5661
|
readMarkerAccountId(provider) {
|
|
4028
5662
|
const path2 = markerPath(provider, home);
|
|
4029
|
-
if (!
|
|
5663
|
+
if (!existsSync9(path2)) return void 0;
|
|
4030
5664
|
try {
|
|
4031
|
-
const parsed = JSON.parse(
|
|
5665
|
+
const parsed = JSON.parse(readFileSync10(path2, "utf8"));
|
|
4032
5666
|
return typeof parsed.accountId === "string" && parsed.accountId ? parsed.accountId : void 0;
|
|
4033
5667
|
} catch {
|
|
4034
5668
|
return void 0;
|
|
@@ -4046,7 +5680,7 @@ function createExternalCliStore(home = homedir3()) {
|
|
|
4046
5680
|
const envelope = provider === "claude" ? buildClaudeOAuthEnvelope(tokens) : buildCodexTokensEnvelope(tokens);
|
|
4047
5681
|
if (!envelope) return false;
|
|
4048
5682
|
const storePath = externalStorePath(provider, home);
|
|
4049
|
-
if (
|
|
5683
|
+
if (existsSync9(storePath) && !existsSync9(backupPath(provider, home))) {
|
|
4050
5684
|
copyFileSync(storePath, backupPath(provider, home));
|
|
4051
5685
|
}
|
|
4052
5686
|
const existing = readExistingObject(storePath);
|
|
@@ -4058,16 +5692,21 @@ function createExternalCliStore(home = homedir3()) {
|
|
|
4058
5692
|
}
|
|
4059
5693
|
|
|
4060
5694
|
// src/ports/JsonSubscriptionCredentialStore.ts
|
|
5695
|
+
var ACCOUNT_REFRESH_LEAD_MS = 5 * 6e4;
|
|
4061
5696
|
var JsonSubscriptionCredentialStore = class {
|
|
4062
5697
|
/**
|
|
4063
5698
|
* @param tokensPath on-disk `tokens.json` location.
|
|
4064
5699
|
* @param box at-rest `SecretBox` (encrypt-on-write / decrypt-on-read).
|
|
4065
|
-
* @param fetchImpl injectable HTTP port for the OAuth refresh
|
|
4066
|
-
* (oauth design D4).
|
|
4067
|
-
*
|
|
4068
|
-
*
|
|
5700
|
+
* @param fetchImpl OPTIONAL injectable HTTP port for the OAuth refresh
|
|
5701
|
+
* round-trips (oauth design D4). A TEST-injected transport is
|
|
5702
|
+
* used verbatim. When ABSENT (production), each refresh uses a
|
|
5703
|
+
* proxy-aware {@link fetchUpstream} that threads the
|
|
5704
|
+
* `{ providerId, accountId }` ctx (upstream-proxy M1) so a
|
|
5705
|
+
* per-account/per-provider proxy is honored on refresh exactly
|
|
5706
|
+
* as on relay — refresh egresses from the SAME proxy IP as the
|
|
5707
|
+
* account's traffic. NOT used by any read/write path.
|
|
4069
5708
|
*/
|
|
4070
|
-
constructor(tokensPath, box, fetchImpl =
|
|
5709
|
+
constructor(tokensPath, box, fetchImpl = void 0, externalCliReader = readExternalCliCredentials, externalCliStore = createExternalCliStore()) {
|
|
4071
5710
|
this.tokensPath = tokensPath;
|
|
4072
5711
|
this.box = box;
|
|
4073
5712
|
this.fetchImpl = fetchImpl;
|
|
@@ -4079,6 +5718,15 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4079
5718
|
fetchImpl;
|
|
4080
5719
|
externalCliReader;
|
|
4081
5720
|
externalCliStore;
|
|
5721
|
+
/**
|
|
5722
|
+
* The proxy-aware `FetchLike` for one refresh round-trip (upstream-proxy M1). A
|
|
5723
|
+
* TEST-injected `fetchImpl` is returned verbatim; otherwise the refresh routes
|
|
5724
|
+
* through {@link fetchUpstream} with the account's `{ providerId, accountId }`
|
|
5725
|
+
* ctx so the per-account/provider proxy applies. `@internal` — also a test seam.
|
|
5726
|
+
*/
|
|
5727
|
+
buildRefreshFetch(providerId, accountId) {
|
|
5728
|
+
return this.fetchImpl ?? ((url, init) => fetchUpstream2(url, init, { providerId, accountId }));
|
|
5729
|
+
}
|
|
4082
5730
|
/**
|
|
4083
5731
|
* In-flight refresh coalescing (external-cli-sync). OAuth refresh tokens are
|
|
4084
5732
|
* SINGLE-USE: two concurrent refreshes of one account each spend the same
|
|
@@ -4110,6 +5758,19 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4110
5758
|
async getValidOpenCodeGoApiKey() {
|
|
4111
5759
|
return this.readConfig().opencodego?.apiKey ?? null;
|
|
4112
5760
|
}
|
|
5761
|
+
/**
|
|
5762
|
+
* DAEMON-ONLY per-account proxy lookup by id (upstream-proxy). Returns the
|
|
5763
|
+
* DECRYPTED `ProxyConfig` for the account (`readConfig` decrypts on read), or
|
|
5764
|
+
* `undefined` for an unknown provider/account or no per-account proxy. Feeds the
|
|
5765
|
+
* winning per-account layer of the upstream-proxy resolver. Synchronous like the
|
|
5766
|
+
* other hot reads. Never returns token material.
|
|
5767
|
+
*/
|
|
5768
|
+
getAccountProxy(providerId, accountId) {
|
|
5769
|
+
if (providerId !== "claude" && providerId !== "codex" && providerId !== "gemini" && providerId !== "opencodego") {
|
|
5770
|
+
return void 0;
|
|
5771
|
+
}
|
|
5772
|
+
return getAccountProxy(this.readConfig(), providerId, accountId);
|
|
5773
|
+
}
|
|
4113
5774
|
/**
|
|
4114
5775
|
* DAEMON-ONLY sanitized accounts list (design D8, NOT on the port). Projects
|
|
4115
5776
|
* each provider's accounts to the secret-free `SubscriptionAccountSanitized`
|
|
@@ -4118,10 +5779,25 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4118
5779
|
*/
|
|
4119
5780
|
async listSanitizedAccounts() {
|
|
4120
5781
|
const config = this.readConfig();
|
|
5782
|
+
const health2 = getSharedAccountHealth();
|
|
5783
|
+
const identityStore = getSharedIdentityStore();
|
|
5784
|
+
const fingerprintOn = identityStore.isEnabled();
|
|
5785
|
+
const now = Date.now();
|
|
4121
5786
|
const out = {};
|
|
4122
5787
|
for (const provider of ["claude", "codex", "gemini", "opencodego"]) {
|
|
4123
5788
|
const sanitized = sanitizeAccounts(config, provider);
|
|
4124
|
-
if (sanitized.length
|
|
5789
|
+
if (sanitized.length === 0) continue;
|
|
5790
|
+
for (const account of sanitized) {
|
|
5791
|
+
const status = health2.getStatus(provider, account.id, now);
|
|
5792
|
+
account.health = status.state;
|
|
5793
|
+
account.cooldownUntil = status.cooldownUntil !== void 0 ? new Date(status.cooldownUntil).toISOString() : void 0;
|
|
5794
|
+
if (fingerprintOn && provider === "claude") {
|
|
5795
|
+
account.identityCaptured = identityStore.hasIdentity(provider, account.id);
|
|
5796
|
+
const capturedAt = identityStore.capturedAt(provider, account.id);
|
|
5797
|
+
account.identityCapturedAt = capturedAt !== void 0 ? new Date(capturedAt).toISOString() : void 0;
|
|
5798
|
+
}
|
|
5799
|
+
}
|
|
5800
|
+
out[provider] = this.attachSyncWarnings(config, provider, sanitized);
|
|
4125
5801
|
}
|
|
4126
5802
|
return out;
|
|
4127
5803
|
}
|
|
@@ -4172,8 +5848,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4172
5848
|
if (!active || !claude?.refreshToken) return false;
|
|
4173
5849
|
const capturedId = active.id;
|
|
4174
5850
|
this.materializeMigration(config);
|
|
5851
|
+
const refreshFetch = this.buildRefreshFetch("claude", capturedId);
|
|
4175
5852
|
try {
|
|
4176
|
-
const result = await claudeOAuth2.refreshAccessToken(claude.refreshToken,
|
|
5853
|
+
const result = await claudeOAuth2.refreshAccessToken(claude.refreshToken, refreshFetch);
|
|
4177
5854
|
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
4178
5855
|
const next = {
|
|
4179
5856
|
...claude,
|
|
@@ -4190,7 +5867,7 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4190
5867
|
return true;
|
|
4191
5868
|
} catch (error) {
|
|
4192
5869
|
if (await this.tryExternalImport("claude", capturedId, claude, async (rt) => {
|
|
4193
|
-
const r = await claudeOAuth2.refreshAccessToken(rt,
|
|
5870
|
+
const r = await claudeOAuth2.refreshAccessToken(rt, refreshFetch);
|
|
4194
5871
|
return {
|
|
4195
5872
|
accessToken: r.accessToken,
|
|
4196
5873
|
refreshToken: r.refreshToken,
|
|
@@ -4217,8 +5894,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4217
5894
|
if (!active || !codex?.refreshToken) return false;
|
|
4218
5895
|
const capturedId = active.id;
|
|
4219
5896
|
this.materializeMigration(config);
|
|
5897
|
+
const refreshFetch = this.buildRefreshFetch("codex", capturedId);
|
|
4220
5898
|
try {
|
|
4221
|
-
const result = await codexOAuth2.refreshAccessToken(codex.refreshToken,
|
|
5899
|
+
const result = await codexOAuth2.refreshAccessToken(codex.refreshToken, refreshFetch);
|
|
4222
5900
|
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
4223
5901
|
const next = {
|
|
4224
5902
|
...codex,
|
|
@@ -4236,7 +5914,7 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4236
5914
|
return true;
|
|
4237
5915
|
} catch (error) {
|
|
4238
5916
|
if (await this.tryExternalImport("codex", capturedId, codex, async (rt) => {
|
|
4239
|
-
const r = await codexOAuth2.refreshAccessToken(rt,
|
|
5917
|
+
const r = await codexOAuth2.refreshAccessToken(rt, refreshFetch);
|
|
4240
5918
|
return {
|
|
4241
5919
|
accessToken: r.accessToken,
|
|
4242
5920
|
refreshToken: r.refreshToken,
|
|
@@ -4266,8 +5944,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4266
5944
|
if (!active || !gemini?.refreshToken) return false;
|
|
4267
5945
|
const capturedId = active.id;
|
|
4268
5946
|
this.materializeMigration(config);
|
|
5947
|
+
const refreshFetch = this.buildRefreshFetch("gemini", capturedId);
|
|
4269
5948
|
try {
|
|
4270
|
-
const result = await geminiOAuth2.refreshAccessToken(gemini.refreshToken,
|
|
5949
|
+
const result = await geminiOAuth2.refreshAccessToken(gemini.refreshToken, refreshFetch);
|
|
4271
5950
|
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
4272
5951
|
const next = {
|
|
4273
5952
|
...gemini,
|
|
@@ -4301,7 +5980,7 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4301
5980
|
if (!account || !captured?.refreshToken) return false;
|
|
4302
5981
|
this.materializeMigration(config);
|
|
4303
5982
|
try {
|
|
4304
|
-
const refreshed = await this.refreshUpstream(provider, captured.refreshToken);
|
|
5983
|
+
const refreshed = await this.refreshUpstream(provider, captured.refreshToken, id);
|
|
4305
5984
|
const next = {
|
|
4306
5985
|
...captured,
|
|
4307
5986
|
accessToken: refreshed.accessToken,
|
|
@@ -4323,10 +6002,114 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4323
6002
|
}
|
|
4324
6003
|
});
|
|
4325
6004
|
}
|
|
6005
|
+
// ── By-id account-pool surface (subscription-account-scheduling, design D6) ──
|
|
6006
|
+
/**
|
|
6007
|
+
* Resolve a SPECIFIC account's access token by id (design D6). Mirrors each
|
|
6008
|
+
* provider's ACTIVE-getter policy, keyed by id: claude returns the stored token
|
|
6009
|
+
* (refresh is 401-driven, like `getValidClaudeAccessToken`); codex/gemini refresh
|
|
6010
|
+
* a near-expiry token via `refreshAccountById` (like `resolveAccessToken`);
|
|
6011
|
+
* opencodego returns the account's static key. `null` when unknown/expired/
|
|
6012
|
+
* tokenless.
|
|
6013
|
+
*/
|
|
6014
|
+
async getAccessTokenForAccount(providerId, accountId) {
|
|
6015
|
+
const account = getAccountById(this.readConfig(), providerId, accountId);
|
|
6016
|
+
if (!account) return null;
|
|
6017
|
+
if (providerId === "opencodego") {
|
|
6018
|
+
return account.tokens.apiKey ?? null;
|
|
6019
|
+
}
|
|
6020
|
+
const oauth = account.tokens;
|
|
6021
|
+
if (!oauth.accessToken) return null;
|
|
6022
|
+
if (providerId === "codex" || providerId === "gemini") {
|
|
6023
|
+
const expiresAtMs = oauth.expiresAt ? Date.parse(oauth.expiresAt) : 0;
|
|
6024
|
+
const expiringSoon = expiresAtMs > 0 && Date.now() >= expiresAtMs - ACCOUNT_REFRESH_LEAD_MS;
|
|
6025
|
+
if (expiringSoon && oauth.refreshToken) {
|
|
6026
|
+
const ok = await this.refreshAccountById(providerId, accountId);
|
|
6027
|
+
if (!ok) return null;
|
|
6028
|
+
const fresh = getAccountById(this.readConfig(), providerId, accountId);
|
|
6029
|
+
return fresh?.tokens?.accessToken ?? null;
|
|
6030
|
+
}
|
|
6031
|
+
}
|
|
6032
|
+
if (oauth.status === "expired") return null;
|
|
6033
|
+
return oauth.accessToken;
|
|
6034
|
+
}
|
|
6035
|
+
/**
|
|
6036
|
+
* Refresh a SPECIFIC account's OAuth token by id (design D6/D7). Delegates to
|
|
6037
|
+
* `refreshAccountById` (coalesced per `provider:id`); opencodego is a static key
|
|
6038
|
+
* → `false` (no refresh affordance).
|
|
6039
|
+
*/
|
|
6040
|
+
async refreshAccountToken(providerId, accountId) {
|
|
6041
|
+
if (providerId === "opencodego") return false;
|
|
6042
|
+
return this.refreshAccountById(providerId, accountId);
|
|
6043
|
+
}
|
|
6044
|
+
/**
|
|
6045
|
+
* Best-effort record of a selection time onto the account's `lastUsedAt` by id
|
|
6046
|
+
* (design D4). Entry-metadata only (the token mirror is untouched); a no-op for
|
|
6047
|
+
* an unknown id. The selector throttles the call frequency, so this stays cheap.
|
|
6048
|
+
*/
|
|
6049
|
+
async touchAccountLastUsed(providerId, accountId, iso) {
|
|
6050
|
+
const config = this.readConfig();
|
|
6051
|
+
const result = setAccountLastUsed(config, providerId, accountId, iso);
|
|
6052
|
+
if (!result.ok) return;
|
|
6053
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6054
|
+
}
|
|
6055
|
+
/**
|
|
6056
|
+
* Best-effort write-through of a per-account client `identity`
|
|
6057
|
+
* (subscription-client-fingerprint #7, P2). Entry-metadata only (NON-secret
|
|
6058
|
+
* whitelisted fingerprint headers; the token mirror is untouched); a no-op for
|
|
6059
|
+
* an unknown id. Called by the identity store's persistence port on a first-seen
|
|
6060
|
+
* freeze / TTL refresh, so it stays infrequent. Never throws to the caller — the
|
|
6061
|
+
* store's port wrapper swallows a rejection so the relay hot path is unaffected.
|
|
6062
|
+
*/
|
|
6063
|
+
async setAccountIdentity(providerId, accountId, identity) {
|
|
6064
|
+
const config = this.readConfig();
|
|
6065
|
+
const result = setAccountIdentity(config, providerId, accountId, identity);
|
|
6066
|
+
if (!result.ok) return;
|
|
6067
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6068
|
+
}
|
|
6069
|
+
/**
|
|
6070
|
+
* DAEMON-ONLY set-priority (subscription-account-scheduling, admin write, NOT on
|
|
6071
|
+
* the port). Set one account's scheduling `priority` by id. Secret-free
|
|
6072
|
+
* (entry-metadata only; the mirror invariant is untouched). Rejects an unknown id.
|
|
6073
|
+
*/
|
|
6074
|
+
async setAccountPriority(providerId, accountId, priority) {
|
|
6075
|
+
const config = this.readConfig();
|
|
6076
|
+
const result = setAccountPriority(config, providerId, accountId, priority);
|
|
6077
|
+
if (!result.ok) return result;
|
|
6078
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6079
|
+
return result;
|
|
6080
|
+
}
|
|
6081
|
+
/**
|
|
6082
|
+
* DAEMON-ONLY set/clear per-account proxy (upstream-proxy, admin write, NOT on
|
|
6083
|
+
* the port). Passing `undefined` clears the override. Write-only password: when
|
|
6084
|
+
* the incoming structured proxy omits the password but the account already had
|
|
6085
|
+
* one, the current (decrypted) password is preserved — editing host/port never
|
|
6086
|
+
* wipes the secret. Persist re-encrypts `proxy.password` via the tokens SecretBox.
|
|
6087
|
+
*/
|
|
6088
|
+
async setAccountProxy(providerId, accountId, proxy) {
|
|
6089
|
+
const config = this.readConfig();
|
|
6090
|
+
const merged = proxy ? preserveProxyConfigSecret(proxy, getAccountProxy(config, providerId, accountId)) : void 0;
|
|
6091
|
+
const result = setAccountProxy(config, providerId, accountId, merged);
|
|
6092
|
+
if (!result.ok) return result;
|
|
6093
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6094
|
+
return result;
|
|
6095
|
+
}
|
|
6096
|
+
/**
|
|
6097
|
+
* DAEMON-ONLY set/clear per-account `supportedModels` (subscription-account-
|
|
6098
|
+
* model-map, admin write, NOT on the port). Passing `undefined` clears it.
|
|
6099
|
+
* Secret-free (model ids only; the mirror invariant is untouched). Rejects an
|
|
6100
|
+
* unknown id.
|
|
6101
|
+
*/
|
|
6102
|
+
async setAccountSupportedModels(providerId, accountId, supportedModels) {
|
|
6103
|
+
const config = this.readConfig();
|
|
6104
|
+
const result = setAccountSupportedModels(config, providerId, accountId, supportedModels);
|
|
6105
|
+
if (!result.ok) return result;
|
|
6106
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6107
|
+
return result;
|
|
6108
|
+
}
|
|
4326
6109
|
/** Dispatch one OAuth refresh round-trip to the provider's shared flow. */
|
|
4327
|
-
async refreshUpstream(provider, refreshToken) {
|
|
6110
|
+
async refreshUpstream(provider, refreshToken, accountId) {
|
|
4328
6111
|
const flow = provider === "claude" ? claudeOAuth2 : provider === "codex" ? codexOAuth2 : geminiOAuth2;
|
|
4329
|
-
const r = await flow.refreshAccessToken(refreshToken, this.
|
|
6112
|
+
const r = await flow.refreshAccessToken(refreshToken, this.buildRefreshFetch(provider, accountId));
|
|
4330
6113
|
return {
|
|
4331
6114
|
accessToken: r.accessToken,
|
|
4332
6115
|
refreshToken: r.refreshToken,
|
|
@@ -4464,119 +6247,907 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4464
6247
|
});
|
|
4465
6248
|
}
|
|
4466
6249
|
/**
|
|
4467
|
-
* DAEMON-ONLY WRITE (design D1, NOT on the port). Read-merge the given
|
|
4468
|
-
* provider's token block into the current `AccountTokensConfig`, stamp a fresh
|
|
4469
|
-
* `updatedAt`, and re-persist `tokens.json` as pretty JSON. Preserves every
|
|
4470
|
-
* OTHER provider's existing block (read-merge-write, not overwrite). Reuses the
|
|
4471
|
-
* tolerate-on-read base (`{ updatedAt: '' }` when the file is absent/corrupt),
|
|
4472
|
-
* so a first-ever write still produces a valid config. No cache → the next read
|
|
4473
|
-
* sees this write.
|
|
6250
|
+
* DAEMON-ONLY WRITE (design D1, NOT on the port). Read-merge the given
|
|
6251
|
+
* provider's token block into the current `AccountTokensConfig`, stamp a fresh
|
|
6252
|
+
* `updatedAt`, and re-persist `tokens.json` as pretty JSON. Preserves every
|
|
6253
|
+
* OTHER provider's existing block (read-merge-write, not overwrite). Reuses the
|
|
6254
|
+
* tolerate-on-read base (`{ updatedAt: '' }` when the file is absent/corrupt),
|
|
6255
|
+
* so a first-ever write still produces a valid config. No cache → the next read
|
|
6256
|
+
* sees this write.
|
|
6257
|
+
*/
|
|
6258
|
+
async writeProviderTokens(providerId, config) {
|
|
6259
|
+
const current = this.readConfig();
|
|
6260
|
+
writeActiveTokens(current, providerId, config);
|
|
6261
|
+
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6262
|
+
}
|
|
6263
|
+
/**
|
|
6264
|
+
* DAEMON-ONLY login append (design D5, NOT on the port). Append a NEW account
|
|
6265
|
+
* (optional label) and set it active, then re-derive the mirror — used by
|
|
6266
|
+
* `omnicross login <provider> --label` to add an account instead of overwriting.
|
|
6267
|
+
*/
|
|
6268
|
+
async appendProviderAccount(providerId, config, label) {
|
|
6269
|
+
const current = this.readConfig();
|
|
6270
|
+
const result = addAccount(current, providerId, config, label);
|
|
6271
|
+
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6272
|
+
return result;
|
|
6273
|
+
}
|
|
6274
|
+
/**
|
|
6275
|
+
* DAEMON-ONLY active switch (design D5, NOT on the port). Switch the active
|
|
6276
|
+
* account for a provider; rejects an unknown id. Re-derives the mirror.
|
|
6277
|
+
*/
|
|
6278
|
+
async setActiveAccount(providerId, id) {
|
|
6279
|
+
const current = this.readConfig();
|
|
6280
|
+
const result = setActiveAccount(current, providerId, id);
|
|
6281
|
+
if (!result.ok) return result;
|
|
6282
|
+
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6283
|
+
return result;
|
|
6284
|
+
}
|
|
6285
|
+
/**
|
|
6286
|
+
* DAEMON-ONLY per-account remove (design D5, NOT on the port). Remove one
|
|
6287
|
+
* account; promote the most-recent remaining on active-removal (or clear the
|
|
6288
|
+
* mirror when none remain). Re-derives the mirror.
|
|
6289
|
+
*/
|
|
6290
|
+
async removeAccount(providerId, id) {
|
|
6291
|
+
const current = this.readConfig();
|
|
6292
|
+
const result = removeAccount(current, providerId, id);
|
|
6293
|
+
if (!result.removed) return result;
|
|
6294
|
+
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6295
|
+
return result;
|
|
6296
|
+
}
|
|
6297
|
+
/**
|
|
6298
|
+
* DAEMON-ONLY per-account rename (NOT on the port). Update one account's label;
|
|
6299
|
+
* rejects an unknown id. Label-only — no token material is read or written
|
|
6300
|
+
* (the secret-free invariant holds).
|
|
6301
|
+
*/
|
|
6302
|
+
async renameAccount(providerId, id, label) {
|
|
6303
|
+
const current = this.readConfig();
|
|
6304
|
+
const result = renameAccount(current, providerId, id, label);
|
|
6305
|
+
if (!result.ok) return result;
|
|
6306
|
+
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6307
|
+
return result;
|
|
6308
|
+
}
|
|
6309
|
+
/**
|
|
6310
|
+
* DAEMON-ONLY CLEAR (design D1/D3, NOT on the port). Remove a single provider's
|
|
6311
|
+
* block from `tokens.json` and re-persist (the strategies already tolerate an
|
|
6312
|
+
* absent block). Stamps a fresh `updatedAt`. A no-op-shaped write when the
|
|
6313
|
+
* provider was already absent (still re-stamps + persists).
|
|
6314
|
+
*/
|
|
6315
|
+
async clearProvider(providerId) {
|
|
6316
|
+
const current = this.readConfig();
|
|
6317
|
+
clearProvider(current, providerId);
|
|
6318
|
+
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6319
|
+
}
|
|
6320
|
+
/** Write the merged config to disk as pretty JSON (mkdir parent if needed).
|
|
6321
|
+
* Encrypt-on-write: the token-material fields are encrypted (legacy plaintext
|
|
6322
|
+
* → `enc:v1:`; already-`enc:`/`$ENV` untouched) before serializing, so any
|
|
6323
|
+
* write — incl. child 4's future refresh writes — lands encrypted. */
|
|
6324
|
+
persist(config) {
|
|
6325
|
+
mkdirSync3(dirname4(this.tokensPath), { recursive: true });
|
|
6326
|
+
const encrypted = encryptTokens(config, this.box);
|
|
6327
|
+
writeFileSync8(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
|
|
6328
|
+
}
|
|
6329
|
+
/**
|
|
6330
|
+
* Read + parse `tokens.json`, tolerating a missing/corrupt file, then DECRYPT
|
|
6331
|
+
* the token-material fields so every getter returns plaintext (the
|
|
6332
|
+
* subscription bearer path is byte-identical).
|
|
6333
|
+
*
|
|
6334
|
+
* The fs-read + JSON-parse tolerance is INSIDE the try (a missing or corrupt
|
|
6335
|
+
* file → empty `{ updatedAt: '' }`). The DECRYPT runs OUTSIDE the try, so a
|
|
6336
|
+
* wrong/missing master key or a tampered `enc:` envelope FAILS FAST with the
|
|
6337
|
+
* box's clear, secret-free error (secrets spec "错误密钥 / 篡改的解密失败 UX":
|
|
6338
|
+
* SHALL fail-fast, SHALL NOT 静默降级 — a swallowed decrypt would report "no
|
|
6339
|
+
* tokens" and silently send the WRONG bearer upstream → 401). Mirrors
|
|
6340
|
+
* `config.ts loadConfig`, which decrypts outside its parse try.
|
|
6341
|
+
*/
|
|
6342
|
+
readConfig() {
|
|
6343
|
+
if (!existsSync10(this.tokensPath)) return { updatedAt: "" };
|
|
6344
|
+
let parsed;
|
|
6345
|
+
try {
|
|
6346
|
+
const raw = JSON.parse(readFileSync11(this.tokensPath, "utf8"));
|
|
6347
|
+
parsed = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
|
|
6348
|
+
} catch {
|
|
6349
|
+
parsed = null;
|
|
6350
|
+
}
|
|
6351
|
+
if (!parsed) return { updatedAt: "" };
|
|
6352
|
+
const decrypted = decryptTokens(parsed, this.box);
|
|
6353
|
+
return migrateLazily(decrypted);
|
|
6354
|
+
}
|
|
6355
|
+
};
|
|
6356
|
+
|
|
6357
|
+
// src/AccountHealthProbeScheduler.ts
|
|
6358
|
+
import { fetchUpstream as fetchUpstream3 } from "@omnicross/core/pipeline/upstreamFetch";
|
|
6359
|
+
|
|
6360
|
+
// src/probe/ProbeStrategy.ts
|
|
6361
|
+
var PROVIDER_PROBE_PLANS = {
|
|
6362
|
+
claude: {
|
|
6363
|
+
kind: "upstream",
|
|
6364
|
+
// VERIFIED free authed list endpoint (no tokens billed). The anthropic OAuth
|
|
6365
|
+
// bearer is accepted here exactly as on the relay path.
|
|
6366
|
+
url: "https://api.anthropic.com/v1/models",
|
|
6367
|
+
buildInit: (token) => ({
|
|
6368
|
+
method: "GET",
|
|
6369
|
+
headers: {
|
|
6370
|
+
Authorization: `Bearer ${token}`,
|
|
6371
|
+
"anthropic-version": "2023-06-01"
|
|
6372
|
+
}
|
|
6373
|
+
})
|
|
6374
|
+
},
|
|
6375
|
+
// UNVERIFIED cheap authed GET — Phase 1 local-only (LEAD OQ1: do not guess a
|
|
6376
|
+
// billable/wrong endpoint). Upgrade to `{ kind:'upstream' }` once verified.
|
|
6377
|
+
codex: { kind: "local" },
|
|
6378
|
+
gemini: { kind: "local" },
|
|
6379
|
+
opencodego: { kind: "local" }
|
|
6380
|
+
};
|
|
6381
|
+
function probePlanFor(providerId) {
|
|
6382
|
+
return PROVIDER_PROBE_PLANS[providerId] ?? { kind: "local" };
|
|
6383
|
+
}
|
|
6384
|
+
|
|
6385
|
+
// src/AccountHealthProbeScheduler.ts
|
|
6386
|
+
var KEY_SEP = "\0";
|
|
6387
|
+
var MAX_BODY_SNIFF = 2048;
|
|
6388
|
+
var PROBE_PROVIDERS = [
|
|
6389
|
+
"claude",
|
|
6390
|
+
"codex",
|
|
6391
|
+
"gemini",
|
|
6392
|
+
"opencodego"
|
|
6393
|
+
];
|
|
6394
|
+
var AccountHealthProbeScheduler = class {
|
|
6395
|
+
constructor(store, health2, logger, config, opts = {}) {
|
|
6396
|
+
this.store = store;
|
|
6397
|
+
this.health = health2;
|
|
6398
|
+
this.logger = logger;
|
|
6399
|
+
this.config = config;
|
|
6400
|
+
this.now = opts.now ?? Date.now;
|
|
6401
|
+
this.fetchImpl = opts.fetchImpl ?? fetchUpstream3;
|
|
6402
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
6403
|
+
this.planFor = opts.planFor ?? probePlanFor;
|
|
6404
|
+
}
|
|
6405
|
+
store;
|
|
6406
|
+
health;
|
|
6407
|
+
logger;
|
|
6408
|
+
config;
|
|
6409
|
+
timer = null;
|
|
6410
|
+
sweeping = false;
|
|
6411
|
+
history = /* @__PURE__ */ new Map();
|
|
6412
|
+
now;
|
|
6413
|
+
fetchImpl;
|
|
6414
|
+
sleep;
|
|
6415
|
+
planFor;
|
|
6416
|
+
/** Whether probing is enabled by the current config. */
|
|
6417
|
+
get enabled() {
|
|
6418
|
+
return this.config.enabled;
|
|
6419
|
+
}
|
|
6420
|
+
/**
|
|
6421
|
+
* Re-apply config to the live instance (the async `start.ts` loads the persisted
|
|
6422
|
+
* `accountProbe` segment after `buildDaemon`). Call BEFORE `start()`.
|
|
6423
|
+
*/
|
|
6424
|
+
configure(config) {
|
|
6425
|
+
this.config = config;
|
|
6426
|
+
}
|
|
6427
|
+
/** Arm the probe interval. No-op when disabled (zero regression). Idempotent. */
|
|
6428
|
+
start() {
|
|
6429
|
+
if (this.timer || !this.config.enabled) return;
|
|
6430
|
+
this.timer = setInterval(() => void this.sweep(), this.config.intervalMs);
|
|
6431
|
+
this.timer.unref?.();
|
|
6432
|
+
}
|
|
6433
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6434
|
+
dispose() {
|
|
6435
|
+
if (this.timer) {
|
|
6436
|
+
clearInterval(this.timer);
|
|
6437
|
+
this.timer = null;
|
|
6438
|
+
}
|
|
6439
|
+
}
|
|
6440
|
+
/**
|
|
6441
|
+
* One sweep: probe every ELIGIBLE account SEQUENTIALLY with a `staggerMs` gap.
|
|
6442
|
+
* Disabled ⇒ no-op. `onlyMultiAccount` skips single-account providers. Exposed
|
|
6443
|
+
* for tests; never throws.
|
|
6444
|
+
*/
|
|
6445
|
+
async sweep() {
|
|
6446
|
+
if (!this.config.enabled || this.sweeping) return;
|
|
6447
|
+
this.sweeping = true;
|
|
6448
|
+
try {
|
|
6449
|
+
const config = await this.store.getFullConfig();
|
|
6450
|
+
let probed = 0;
|
|
6451
|
+
let marked = 0;
|
|
6452
|
+
for (const providerId of PROBE_PROVIDERS) {
|
|
6453
|
+
const accounts = listAccounts(config, providerId);
|
|
6454
|
+
if (this.config.onlyMultiAccount && accounts.length < 2) continue;
|
|
6455
|
+
for (const account of accounts) {
|
|
6456
|
+
if (probed > 0 && this.config.staggerMs > 0) await this.sleep(this.config.staggerMs);
|
|
6457
|
+
const outcome = await this.probeAccount(providerId, account.id);
|
|
6458
|
+
probed += 1;
|
|
6459
|
+
if (outcome.marked) marked += 1;
|
|
6460
|
+
}
|
|
6461
|
+
}
|
|
6462
|
+
this.logger.debug("account-probe sweep complete", { probed, marked });
|
|
6463
|
+
} catch (error) {
|
|
6464
|
+
this.logger.warn("account-probe sweep failed", {
|
|
6465
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6466
|
+
});
|
|
6467
|
+
} finally {
|
|
6468
|
+
this.sweeping = false;
|
|
6469
|
+
}
|
|
6470
|
+
}
|
|
6471
|
+
/**
|
|
6472
|
+
* Probe ONE account (design D1). Local tier first (dead token → synthesized 401,
|
|
6473
|
+
* no upstream); else the upstream tier when a verified endpoint exists. Records
|
|
6474
|
+
* the rolling history entry either way; returns whether the tracker was MARKED.
|
|
6475
|
+
*/
|
|
6476
|
+
async probeAccount(providerId, accountId) {
|
|
6477
|
+
const now = this.now();
|
|
6478
|
+
let token = null;
|
|
6479
|
+
let readThrew = false;
|
|
6480
|
+
try {
|
|
6481
|
+
token = await this.store.getAccessTokenForAccount(providerId, accountId);
|
|
6482
|
+
} catch {
|
|
6483
|
+
readThrew = true;
|
|
6484
|
+
}
|
|
6485
|
+
if (readThrew) {
|
|
6486
|
+
this.record(providerId, accountId, { ts: now, ok: false, status: null, tier: "local" });
|
|
6487
|
+
return { ok: false, marked: false };
|
|
6488
|
+
}
|
|
6489
|
+
if (!token) {
|
|
6490
|
+
this.health.recordUpstreamOutcome(providerId, accountId, { status: 401, now });
|
|
6491
|
+
this.record(providerId, accountId, { ts: now, ok: false, status: 401, tier: "local" });
|
|
6492
|
+
return { ok: false, marked: true };
|
|
6493
|
+
}
|
|
6494
|
+
const plan = this.planFor(providerId);
|
|
6495
|
+
if (plan.kind === "local") {
|
|
6496
|
+
this.record(providerId, accountId, { ts: now, ok: true, tier: "local" });
|
|
6497
|
+
return { ok: true, marked: false };
|
|
6498
|
+
}
|
|
6499
|
+
const start = this.now();
|
|
6500
|
+
let status = null;
|
|
6501
|
+
let bodyText;
|
|
6502
|
+
try {
|
|
6503
|
+
const res = await this.fetchImpl(
|
|
6504
|
+
plan.url,
|
|
6505
|
+
{ ...plan.buildInit(token), signal: AbortSignal.timeout(this.config.timeoutMs) },
|
|
6506
|
+
{ providerId, accountId }
|
|
6507
|
+
);
|
|
6508
|
+
status = res.status;
|
|
6509
|
+
if (status === 403) bodyText = await this.readBounded(res);
|
|
6510
|
+
} catch {
|
|
6511
|
+
status = null;
|
|
6512
|
+
}
|
|
6513
|
+
const latencyMs = this.now() - start;
|
|
6514
|
+
const marked = this.applyOutcome(providerId, accountId, status, bodyText, now);
|
|
6515
|
+
this.record(providerId, accountId, {
|
|
6516
|
+
ts: now,
|
|
6517
|
+
ok: status !== null && status >= 200 && status < 300,
|
|
6518
|
+
status,
|
|
6519
|
+
latencyMs,
|
|
6520
|
+
tier: "upstream"
|
|
6521
|
+
});
|
|
6522
|
+
return { ok: status !== null && status < 400, marked };
|
|
6523
|
+
}
|
|
6524
|
+
/** Per-account rolling history for the authed admin surface (design D5). */
|
|
6525
|
+
getAllHistory() {
|
|
6526
|
+
const out = [];
|
|
6527
|
+
for (const [key, records] of this.history) {
|
|
6528
|
+
const [providerId, accountId] = this.parseKey(key);
|
|
6529
|
+
out.push({ providerId, accountId, records: records.slice() });
|
|
6530
|
+
}
|
|
6531
|
+
return out;
|
|
6532
|
+
}
|
|
6533
|
+
/**
|
|
6534
|
+
* The coarse, account-ANONYMOUS `/health` signal (design D5): `true` when no
|
|
6535
|
+
* probed account is currently unhealthy (per #2's tracker). No ids, no counts —
|
|
6536
|
+
* safe for the unauthenticated `/health`. Vacuously `true` when nothing probed.
|
|
6537
|
+
*/
|
|
6538
|
+
probedAccountsHealthy(now = this.now()) {
|
|
6539
|
+
for (const key of this.history.keys()) {
|
|
6540
|
+
const [providerId, accountId] = this.parseKey(key);
|
|
6541
|
+
if (!this.health.isSchedulable(providerId, accountId, now)) return false;
|
|
6542
|
+
}
|
|
6543
|
+
return true;
|
|
6544
|
+
}
|
|
6545
|
+
/**
|
|
6546
|
+
* Feed ONLY the account/auth-decisive statuses to #2 (LEAD constraint):
|
|
6547
|
+
* 401/403 → mark; 2xx → clear transient; 429 / 5xx / other 4xx / null →
|
|
6548
|
+
* NOT forwarded (never a mark). Returns whether a NEGATIVE mark was applied.
|
|
6549
|
+
*/
|
|
6550
|
+
applyOutcome(providerId, accountId, status, bodyText, now) {
|
|
6551
|
+
if (status === null) return false;
|
|
6552
|
+
if (status === 401 || status === 403) {
|
|
6553
|
+
this.health.recordUpstreamOutcome(providerId, accountId, { status, bodyText, now });
|
|
6554
|
+
return true;
|
|
6555
|
+
}
|
|
6556
|
+
if (status >= 200 && status < 300) {
|
|
6557
|
+
this.health.clearTransientMark(providerId, accountId);
|
|
6558
|
+
return false;
|
|
6559
|
+
}
|
|
6560
|
+
return false;
|
|
6561
|
+
}
|
|
6562
|
+
/** Append a record, capping the ring at `historySize` (drop oldest). */
|
|
6563
|
+
record(providerId, accountId, rec) {
|
|
6564
|
+
const key = this.key(providerId, accountId);
|
|
6565
|
+
const list = this.history.get(key) ?? [];
|
|
6566
|
+
list.push(rec);
|
|
6567
|
+
const overflow = list.length - this.config.historySize;
|
|
6568
|
+
if (overflow > 0) list.splice(0, overflow);
|
|
6569
|
+
this.history.set(key, list);
|
|
6570
|
+
}
|
|
6571
|
+
/** Read a bounded slice of the response body for the 403-ban sniff (never throws). */
|
|
6572
|
+
async readBounded(res) {
|
|
6573
|
+
try {
|
|
6574
|
+
return (await res.text()).slice(0, MAX_BODY_SNIFF);
|
|
6575
|
+
} catch {
|
|
6576
|
+
return "";
|
|
6577
|
+
}
|
|
6578
|
+
}
|
|
6579
|
+
key(providerId, accountId) {
|
|
6580
|
+
return `${providerId}${KEY_SEP}${accountId}`;
|
|
6581
|
+
}
|
|
6582
|
+
parseKey(key) {
|
|
6583
|
+
const idx = key.indexOf(KEY_SEP);
|
|
6584
|
+
return [key.slice(0, idx), key.slice(idx + 1)];
|
|
6585
|
+
}
|
|
6586
|
+
};
|
|
6587
|
+
|
|
6588
|
+
// src/AccountHealthSweeper.ts
|
|
6589
|
+
var REFRESH_LEAD_MS = 5 * 6e4;
|
|
6590
|
+
var SWEEP_INTERVAL_MS = 6e4;
|
|
6591
|
+
var OAUTH_PROVIDERS = ["claude", "codex", "gemini"];
|
|
6592
|
+
function isOAuthProvider(providerId) {
|
|
6593
|
+
return OAUTH_PROVIDERS.includes(providerId);
|
|
6594
|
+
}
|
|
6595
|
+
var AccountHealthSweeper = class {
|
|
6596
|
+
constructor(store, health2, logger, intervalMs = SWEEP_INTERVAL_MS, leadMs = REFRESH_LEAD_MS) {
|
|
6597
|
+
this.store = store;
|
|
6598
|
+
this.health = health2;
|
|
6599
|
+
this.logger = logger;
|
|
6600
|
+
this.intervalMs = intervalMs;
|
|
6601
|
+
this.leadMs = leadMs;
|
|
6602
|
+
}
|
|
6603
|
+
store;
|
|
6604
|
+
health;
|
|
6605
|
+
logger;
|
|
6606
|
+
intervalMs;
|
|
6607
|
+
leadMs;
|
|
6608
|
+
timer = null;
|
|
6609
|
+
sweeping = false;
|
|
6610
|
+
/** Arm the sweep interval. Idempotent. The timer never holds the loop open. */
|
|
6611
|
+
start() {
|
|
6612
|
+
if (this.timer) return;
|
|
6613
|
+
this.timer = setInterval(() => void this.sweep(), this.intervalMs);
|
|
6614
|
+
this.timer.unref?.();
|
|
6615
|
+
}
|
|
6616
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6617
|
+
dispose() {
|
|
6618
|
+
if (this.timer) {
|
|
6619
|
+
clearInterval(this.timer);
|
|
6620
|
+
this.timer = null;
|
|
6621
|
+
}
|
|
6622
|
+
}
|
|
6623
|
+
/**
|
|
6624
|
+
* One sweep: surface accounts that just recovered (emits the recovery signal
|
|
6625
|
+
* through the tracker's hook) and nudge a fresh token for any recovered OAuth
|
|
6626
|
+
* account whose token is near expiry. Exposed for tests. Never throws.
|
|
6627
|
+
*/
|
|
6628
|
+
async sweep(now = Date.now()) {
|
|
6629
|
+
if (this.sweeping) return;
|
|
6630
|
+
this.sweeping = true;
|
|
6631
|
+
try {
|
|
6632
|
+
const recovered = this.health.sweepRecoveries(now);
|
|
6633
|
+
if (recovered.length === 0) return;
|
|
6634
|
+
const config = await this.store.getFullConfig();
|
|
6635
|
+
for (const event of recovered) {
|
|
6636
|
+
if (!isOAuthProvider(event.providerId)) continue;
|
|
6637
|
+
const account = getAccountById(config, event.providerId, event.accountId);
|
|
6638
|
+
if (!account || !this.needsRefresh(account.tokens, now)) continue;
|
|
6639
|
+
await this.refreshOne(event.providerId, event.accountId);
|
|
6640
|
+
}
|
|
6641
|
+
} catch (error) {
|
|
6642
|
+
this.logger.warn("account-health sweep failed", {
|
|
6643
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6644
|
+
});
|
|
6645
|
+
} finally {
|
|
6646
|
+
this.sweeping = false;
|
|
6647
|
+
}
|
|
6648
|
+
}
|
|
6649
|
+
/** Expiring within the lead window, refreshable, and not already dead. */
|
|
6650
|
+
needsRefresh(tokens, now) {
|
|
6651
|
+
const t = tokens;
|
|
6652
|
+
if (!t.refreshToken || t.status === "expired" || t.status === "error") return false;
|
|
6653
|
+
if (!t.expiresAt) return false;
|
|
6654
|
+
const expiresAt = Date.parse(t.expiresAt);
|
|
6655
|
+
return Number.isFinite(expiresAt) && now >= expiresAt - this.leadMs;
|
|
6656
|
+
}
|
|
6657
|
+
/** Refresh one recovered account by id; failures are logged, never thrown. */
|
|
6658
|
+
async refreshOne(provider, id) {
|
|
6659
|
+
try {
|
|
6660
|
+
const ok = await this.store.refreshAccountById(provider, id);
|
|
6661
|
+
if (ok) this.logger.info("account-health recovery refresh succeeded", { provider, accountId: id });
|
|
6662
|
+
else this.logger.warn("account-health recovery refresh failed", { provider, accountId: id });
|
|
6663
|
+
} catch (error) {
|
|
6664
|
+
this.logger.warn("account-health recovery refresh threw", {
|
|
6665
|
+
provider,
|
|
6666
|
+
accountId: id,
|
|
6667
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6668
|
+
});
|
|
6669
|
+
}
|
|
6670
|
+
}
|
|
6671
|
+
};
|
|
6672
|
+
|
|
6673
|
+
// src/audit/AuditPruneSweeper.ts
|
|
6674
|
+
import { existsSync as existsSync11, readdirSync, unlinkSync } from "fs";
|
|
6675
|
+
import { join as join5 } from "path";
|
|
6676
|
+
|
|
6677
|
+
// src/audit/auditFiles.ts
|
|
6678
|
+
var AUDIT_FILE_RE = /^audit-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
6679
|
+
var pad22 = (n) => String(n).padStart(2, "0");
|
|
6680
|
+
function auditFileName(ts) {
|
|
6681
|
+
const d = new Date(ts);
|
|
6682
|
+
return `audit-${d.getFullYear()}-${pad22(d.getMonth() + 1)}-${pad22(d.getDate())}.jsonl`;
|
|
6683
|
+
}
|
|
6684
|
+
function auditFileDateMs(fileName) {
|
|
6685
|
+
const m = AUDIT_FILE_RE.exec(fileName);
|
|
6686
|
+
if (!m) return null;
|
|
6687
|
+
const year = Number(m[1]);
|
|
6688
|
+
const month = Number(m[2]);
|
|
6689
|
+
const day = Number(m[3]);
|
|
6690
|
+
const d = new Date(year, month - 1, day);
|
|
6691
|
+
if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) {
|
|
6692
|
+
return null;
|
|
6693
|
+
}
|
|
6694
|
+
return d.getTime();
|
|
6695
|
+
}
|
|
6696
|
+
|
|
6697
|
+
// src/audit/AuditPruneSweeper.ts
|
|
6698
|
+
var DAY_MS = 24 * 60 * 6e4;
|
|
6699
|
+
var SWEEP_INTERVAL_MS2 = 60 * 6e4;
|
|
6700
|
+
var AuditPruneSweeper = class {
|
|
6701
|
+
constructor(auditDir, logger, config, intervalMs = SWEEP_INTERVAL_MS2, now = Date.now) {
|
|
6702
|
+
this.auditDir = auditDir;
|
|
6703
|
+
this.logger = logger;
|
|
6704
|
+
this.config = config;
|
|
6705
|
+
this.intervalMs = intervalMs;
|
|
6706
|
+
this.now = now;
|
|
6707
|
+
}
|
|
6708
|
+
auditDir;
|
|
6709
|
+
logger;
|
|
6710
|
+
config;
|
|
6711
|
+
intervalMs;
|
|
6712
|
+
now;
|
|
6713
|
+
timer = null;
|
|
6714
|
+
sweeping = false;
|
|
6715
|
+
/** Whether pruning is active (audit enabled). */
|
|
6716
|
+
get enabled() {
|
|
6717
|
+
return this.config.enabled;
|
|
6718
|
+
}
|
|
6719
|
+
/** Re-apply config to the live instance (boot + admin PUT hot-reload). */
|
|
6720
|
+
configure(config) {
|
|
6721
|
+
this.config = config;
|
|
6722
|
+
}
|
|
6723
|
+
/**
|
|
6724
|
+
* Arm the prune interval AND run one prune immediately (boot cleanup). No-op
|
|
6725
|
+
* when audit is disabled (zero regression). Idempotent.
|
|
6726
|
+
*/
|
|
6727
|
+
start() {
|
|
6728
|
+
if (this.timer || !this.config.enabled) return;
|
|
6729
|
+
void this.sweep();
|
|
6730
|
+
this.timer = setInterval(() => void this.sweep(), this.intervalMs);
|
|
6731
|
+
this.timer.unref?.();
|
|
6732
|
+
}
|
|
6733
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6734
|
+
dispose() {
|
|
6735
|
+
if (this.timer) {
|
|
6736
|
+
clearInterval(this.timer);
|
|
6737
|
+
this.timer = null;
|
|
6738
|
+
}
|
|
6739
|
+
}
|
|
6740
|
+
/**
|
|
6741
|
+
* One prune: unlink every audit date file strictly OLDER than the retention
|
|
6742
|
+
* cutoff (`now - retentionDays` days, at local-midnight granularity). Exposed
|
|
6743
|
+
* for tests; never throws. Returns the number of files removed.
|
|
6744
|
+
*/
|
|
6745
|
+
async sweep() {
|
|
6746
|
+
if (!this.config.enabled || this.sweeping) return 0;
|
|
6747
|
+
this.sweeping = true;
|
|
6748
|
+
try {
|
|
6749
|
+
if (!existsSync11(this.auditDir)) return 0;
|
|
6750
|
+
const today = new Date(this.now());
|
|
6751
|
+
const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
|
|
6752
|
+
const cutoff = todayMidnight - (this.config.retentionDays - 1) * DAY_MS;
|
|
6753
|
+
let removed = 0;
|
|
6754
|
+
for (const file of readdirSync(this.auditDir)) {
|
|
6755
|
+
const dateMs = auditFileDateMs(file);
|
|
6756
|
+
if (dateMs === null || dateMs >= cutoff) continue;
|
|
6757
|
+
try {
|
|
6758
|
+
unlinkSync(join5(this.auditDir, file));
|
|
6759
|
+
removed += 1;
|
|
6760
|
+
} catch (error) {
|
|
6761
|
+
this.logger.warn("[AuditPruneSweeper] failed to unlink expired audit file", {
|
|
6762
|
+
file,
|
|
6763
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6764
|
+
});
|
|
6765
|
+
}
|
|
6766
|
+
}
|
|
6767
|
+
if (removed > 0) this.logger.debug("audit prune complete", { removed });
|
|
6768
|
+
return removed;
|
|
6769
|
+
} catch (error) {
|
|
6770
|
+
this.logger.warn("audit prune sweep failed", {
|
|
6771
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6772
|
+
});
|
|
6773
|
+
return 0;
|
|
6774
|
+
} finally {
|
|
6775
|
+
this.sweeping = false;
|
|
6776
|
+
}
|
|
6777
|
+
}
|
|
6778
|
+
};
|
|
6779
|
+
|
|
6780
|
+
// src/audit/auditReader.ts
|
|
6781
|
+
import { existsSync as existsSync12, readdirSync as readdirSync2, readFileSync as readFileSync12 } from "fs";
|
|
6782
|
+
import { join as join6 } from "path";
|
|
6783
|
+
var DEFAULT_LIMIT = 200;
|
|
6784
|
+
var MAX_LIMIT = 2e3;
|
|
6785
|
+
function readAuditRecords(auditDir, query = {}) {
|
|
6786
|
+
if (!existsSync12(auditDir)) return [];
|
|
6787
|
+
let files;
|
|
6788
|
+
try {
|
|
6789
|
+
files = readdirSync2(auditDir).filter((f) => AUDIT_FILE_RE.test(f));
|
|
6790
|
+
} catch {
|
|
6791
|
+
return [];
|
|
6792
|
+
}
|
|
6793
|
+
const from = typeof query.from === "number" ? query.from : -Infinity;
|
|
6794
|
+
const to = typeof query.to === "number" ? query.to : Infinity;
|
|
6795
|
+
const limit = Math.min(MAX_LIMIT, Math.max(1, Math.trunc(query.limit ?? DEFAULT_LIMIT)));
|
|
6796
|
+
const matched = [];
|
|
6797
|
+
for (const file of files.sort().reverse()) {
|
|
6798
|
+
let raw;
|
|
6799
|
+
try {
|
|
6800
|
+
raw = readFileSync12(join6(auditDir, file), "utf8");
|
|
6801
|
+
} catch {
|
|
6802
|
+
continue;
|
|
6803
|
+
}
|
|
6804
|
+
for (const line of raw.split("\n")) {
|
|
6805
|
+
const trimmed = line.trim();
|
|
6806
|
+
if (!trimmed) continue;
|
|
6807
|
+
let rec;
|
|
6808
|
+
try {
|
|
6809
|
+
rec = JSON.parse(trimmed);
|
|
6810
|
+
} catch {
|
|
6811
|
+
continue;
|
|
6812
|
+
}
|
|
6813
|
+
if (!isAuditRecord(rec)) continue;
|
|
6814
|
+
if (query.keyId !== void 0 && rec.keyId !== query.keyId) continue;
|
|
6815
|
+
if (rec.ts < from || rec.ts > to) continue;
|
|
6816
|
+
matched.push(rec);
|
|
6817
|
+
}
|
|
6818
|
+
}
|
|
6819
|
+
matched.sort((a, b) => b.ts - a.ts);
|
|
6820
|
+
return matched.slice(0, limit);
|
|
6821
|
+
}
|
|
6822
|
+
function isAuditRecord(value) {
|
|
6823
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
6824
|
+
const r = value;
|
|
6825
|
+
return typeof r["id"] === "string" && typeof r["ts"] === "number" && typeof r["method"] === "string" && typeof r["path"] === "string" && typeof r["status"] === "number";
|
|
6826
|
+
}
|
|
6827
|
+
|
|
6828
|
+
// src/audit/AuditWriter.ts
|
|
6829
|
+
import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync4 } from "fs";
|
|
6830
|
+
import { join as join7 } from "path";
|
|
6831
|
+
var AuditWriter = class {
|
|
6832
|
+
constructor(auditDir, logger, defer = (fn) => setTimeout(fn, 0)) {
|
|
6833
|
+
this.auditDir = auditDir;
|
|
6834
|
+
this.logger = logger;
|
|
6835
|
+
this.defer = defer;
|
|
6836
|
+
}
|
|
6837
|
+
auditDir;
|
|
6838
|
+
logger;
|
|
6839
|
+
defer;
|
|
6840
|
+
dirEnsured = false;
|
|
6841
|
+
/**
|
|
6842
|
+
* Enqueue one record for append. Returns IMMEDIATELY (fire-and-forget); the fs
|
|
6843
|
+
* write happens on the deferred tick. A failure is logged, never thrown.
|
|
6844
|
+
*/
|
|
6845
|
+
record(record) {
|
|
6846
|
+
this.defer(() => {
|
|
6847
|
+
try {
|
|
6848
|
+
this.appendNow(record);
|
|
6849
|
+
} catch (error) {
|
|
6850
|
+
this.logger.warn("[AuditWriter] failed to append audit record", {
|
|
6851
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6852
|
+
});
|
|
6853
|
+
}
|
|
6854
|
+
});
|
|
6855
|
+
}
|
|
6856
|
+
/**
|
|
6857
|
+
* Append synchronously — the awaitable form tests use to assert the line landed.
|
|
6858
|
+
* Ensures the `audit/` directory exists on first write (lazy, like the usage
|
|
6859
|
+
* store's lazy file creation).
|
|
4474
6860
|
*/
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
6861
|
+
appendNow(record) {
|
|
6862
|
+
if (!this.dirEnsured) {
|
|
6863
|
+
mkdirSync4(this.auditDir, { recursive: true });
|
|
6864
|
+
this.dirEnsured = true;
|
|
6865
|
+
}
|
|
6866
|
+
const file = join7(this.auditDir, auditFileName(record.ts));
|
|
6867
|
+
appendFileSync2(file, JSON.stringify(record) + "\n", "utf8");
|
|
6868
|
+
}
|
|
6869
|
+
};
|
|
6870
|
+
|
|
6871
|
+
// src/billing/BillingPublisher.ts
|
|
6872
|
+
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync5 } from "fs";
|
|
6873
|
+
import { createHmac } from "crypto";
|
|
6874
|
+
import { join as join8 } from "path";
|
|
6875
|
+
import { fetchUpstream as fetchUpstream4 } from "@omnicross/core/pipeline/upstreamFetch";
|
|
6876
|
+
|
|
6877
|
+
// src/billing/billingFiles.ts
|
|
6878
|
+
var BILLING_FILE_RE = /^billing-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
6879
|
+
var DELIVERED_FILE_RE = /^delivered-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
6880
|
+
var pad23 = (n) => String(n).padStart(2, "0");
|
|
6881
|
+
function dateStamp(ts) {
|
|
6882
|
+
const d = new Date(ts);
|
|
6883
|
+
return `${d.getFullYear()}-${pad23(d.getMonth() + 1)}-${pad23(d.getDate())}`;
|
|
6884
|
+
}
|
|
6885
|
+
function billingFileName(ts) {
|
|
6886
|
+
return `billing-${dateStamp(ts)}.jsonl`;
|
|
6887
|
+
}
|
|
6888
|
+
function deliveredFileName(ts) {
|
|
6889
|
+
return `delivered-${dateStamp(ts)}.jsonl`;
|
|
6890
|
+
}
|
|
6891
|
+
|
|
6892
|
+
// src/billing/BillingPublisher.ts
|
|
6893
|
+
var BILLING_POST_TIMEOUT_MS = 1e4;
|
|
6894
|
+
var BillingPublisher = class {
|
|
6895
|
+
constructor(billingDir, logger, opts = {}) {
|
|
6896
|
+
this.billingDir = billingDir;
|
|
6897
|
+
this.logger = logger;
|
|
6898
|
+
this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetchUpstream4(url, init));
|
|
6899
|
+
this.defer = opts.defer ?? ((fn) => setTimeout(fn, 0));
|
|
6900
|
+
this.timeoutMs = opts.timeoutMs ?? BILLING_POST_TIMEOUT_MS;
|
|
6901
|
+
this.now = opts.now ?? Date.now;
|
|
6902
|
+
}
|
|
6903
|
+
billingDir;
|
|
6904
|
+
logger;
|
|
6905
|
+
config;
|
|
6906
|
+
dirEnsured = false;
|
|
6907
|
+
fetchImpl;
|
|
6908
|
+
defer;
|
|
6909
|
+
timeoutMs;
|
|
6910
|
+
now;
|
|
6911
|
+
/** Install/replace the live billing config (endpoint + secret + retry bound). */
|
|
6912
|
+
setConfig(config) {
|
|
6913
|
+
this.config = config;
|
|
4479
6914
|
}
|
|
4480
6915
|
/**
|
|
4481
|
-
*
|
|
4482
|
-
*
|
|
4483
|
-
*
|
|
6916
|
+
* Record one billing event. DURABLE-FIRST: append synchronously (the event is
|
|
6917
|
+
* now on disk, never lost), THEN schedule a best-effort POST off the caller's
|
|
6918
|
+
* stack (non-blocking; ledger-only when no endpoint). Returns IMMEDIATELY and
|
|
6919
|
+
* NEVER throws — a failing append/POST is logged, never propagated.
|
|
4484
6920
|
*/
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
6921
|
+
record(event) {
|
|
6922
|
+
let appended = false;
|
|
6923
|
+
try {
|
|
6924
|
+
this.appendNow(event);
|
|
6925
|
+
appended = true;
|
|
6926
|
+
} catch (error) {
|
|
6927
|
+
this.logger.warn("[BillingPublisher] failed to append billing event", {
|
|
6928
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6929
|
+
});
|
|
6930
|
+
}
|
|
6931
|
+
if (appended && this.config?.endpoint) {
|
|
6932
|
+
this.defer(() => {
|
|
6933
|
+
void this.deliverNow(event).catch(() => {
|
|
6934
|
+
});
|
|
6935
|
+
});
|
|
6936
|
+
}
|
|
4490
6937
|
}
|
|
4491
6938
|
/**
|
|
4492
|
-
*
|
|
4493
|
-
*
|
|
6939
|
+
* Append the event as one JSON line to `billing-YYYY-MM-DD.jsonl` (the event's
|
|
6940
|
+
* LOCAL date). Synchronous — the awaitable form tests use to assert the ledger
|
|
6941
|
+
* line landed BEFORE any delivery. Ensures the `billing/` directory on first write.
|
|
4494
6942
|
*/
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
const
|
|
4498
|
-
|
|
4499
|
-
this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
4500
|
-
return result;
|
|
6943
|
+
appendNow(event) {
|
|
6944
|
+
this.ensureDir();
|
|
6945
|
+
const file = join8(this.billingDir, billingFileName(event.ts));
|
|
6946
|
+
appendFileSync3(file, JSON.stringify(event) + "\n", "utf8");
|
|
4501
6947
|
}
|
|
4502
6948
|
/**
|
|
4503
|
-
*
|
|
4504
|
-
*
|
|
4505
|
-
*
|
|
6949
|
+
* One best-effort delivery attempt for an event ALREADY in the ledger. POSTs the
|
|
6950
|
+
* event JSON to the configured endpoint (optionally HMAC-signed); on a 2xx ack
|
|
6951
|
+
* appends a delivery marker and returns `true`. Any non-2xx / thrown / timed-out
|
|
6952
|
+
* attempt returns `false` — the event stays UNdelivered in the ledger (never
|
|
6953
|
+
* lost). NEVER rejects. A no-op `false` when no endpoint is configured.
|
|
4506
6954
|
*/
|
|
4507
|
-
async
|
|
4508
|
-
const
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
6955
|
+
async deliverNow(event) {
|
|
6956
|
+
const endpoint = this.config?.endpoint;
|
|
6957
|
+
if (!endpoint) return false;
|
|
6958
|
+
try {
|
|
6959
|
+
const body = JSON.stringify(event);
|
|
6960
|
+
const headers = { "Content-Type": "application/json" };
|
|
6961
|
+
const secret = this.config?.secret;
|
|
6962
|
+
if (secret) {
|
|
6963
|
+
const hmac = createHmac("sha256", secret).update(body).digest("hex");
|
|
6964
|
+
headers["X-Omnicross-Billing-Signature"] = `sha256=${hmac}`;
|
|
6965
|
+
}
|
|
6966
|
+
const res = await this.fetchImpl(endpoint, {
|
|
6967
|
+
method: "POST",
|
|
6968
|
+
headers,
|
|
6969
|
+
body,
|
|
6970
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
6971
|
+
});
|
|
6972
|
+
if (!res.ok) {
|
|
6973
|
+
this.logger.debug(`[billing] delivery failed ${event.id} (HTTP ${res.status})`);
|
|
6974
|
+
return false;
|
|
6975
|
+
}
|
|
6976
|
+
this.markDelivered(event);
|
|
6977
|
+
this.logger.debug(`[billing] delivered ${event.id}`);
|
|
6978
|
+
return true;
|
|
6979
|
+
} catch (error) {
|
|
6980
|
+
this.logger.debug(
|
|
6981
|
+
`[billing] delivery error ${event.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
6982
|
+
);
|
|
6983
|
+
return false;
|
|
6984
|
+
}
|
|
4513
6985
|
}
|
|
4514
6986
|
/**
|
|
4515
|
-
*
|
|
4516
|
-
*
|
|
4517
|
-
*
|
|
6987
|
+
* Append a delivery marker `{ id, deliveredAt }` to `delivered-YYYY-MM-DD.jsonl`
|
|
6988
|
+
* (keyed by the EVENT's date so the reader finds both together). Idempotent at
|
|
6989
|
+
* the reconciliation layer — the reader unions marker ids into a delivered set,
|
|
6990
|
+
* so a duplicate marker is harmless. A marker-write failure is logged, never thrown.
|
|
4518
6991
|
*/
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
6992
|
+
markDelivered(event) {
|
|
6993
|
+
try {
|
|
6994
|
+
this.ensureDir();
|
|
6995
|
+
const file = join8(this.billingDir, deliveredFileName(event.ts));
|
|
6996
|
+
appendFileSync3(file, JSON.stringify({ id: event.id, deliveredAt: this.now() }) + "\n", "utf8");
|
|
6997
|
+
} catch (error) {
|
|
6998
|
+
this.logger.warn("[BillingPublisher] failed to append delivery marker", {
|
|
6999
|
+
error: error instanceof Error ? error.message : String(error)
|
|
7000
|
+
});
|
|
7001
|
+
}
|
|
7002
|
+
}
|
|
7003
|
+
ensureDir() {
|
|
7004
|
+
if (this.dirEnsured) return;
|
|
7005
|
+
mkdirSync5(this.billingDir, { recursive: true });
|
|
7006
|
+
this.dirEnsured = true;
|
|
7007
|
+
}
|
|
7008
|
+
};
|
|
7009
|
+
|
|
7010
|
+
// src/billing/billingReader.ts
|
|
7011
|
+
import { existsSync as existsSync13, readdirSync as readdirSync3, readFileSync as readFileSync13 } from "fs";
|
|
7012
|
+
import { join as join9 } from "path";
|
|
7013
|
+
function readBillingLedger(billingDir) {
|
|
7014
|
+
const view = { events: [], deliveredIds: /* @__PURE__ */ new Set() };
|
|
7015
|
+
if (!existsSync13(billingDir)) return view;
|
|
7016
|
+
let files;
|
|
7017
|
+
try {
|
|
7018
|
+
files = readdirSync3(billingDir);
|
|
7019
|
+
} catch {
|
|
7020
|
+
return view;
|
|
7021
|
+
}
|
|
7022
|
+
for (const file of files.sort()) {
|
|
7023
|
+
if (BILLING_FILE_RE.test(file)) {
|
|
7024
|
+
for (const rec of parseLines(billingDir, file)) {
|
|
7025
|
+
if (isBillingEvent(rec)) view.events.push(rec);
|
|
7026
|
+
}
|
|
7027
|
+
} else if (DELIVERED_FILE_RE.test(file)) {
|
|
7028
|
+
for (const rec of parseLines(billingDir, file)) {
|
|
7029
|
+
const id = rec.id;
|
|
7030
|
+
if (typeof id === "string") view.deliveredIds.add(id);
|
|
7031
|
+
}
|
|
7032
|
+
}
|
|
7033
|
+
}
|
|
7034
|
+
return view;
|
|
7035
|
+
}
|
|
7036
|
+
function readUndeliveredEvents(billingDir) {
|
|
7037
|
+
const { events, deliveredIds } = readBillingLedger(billingDir);
|
|
7038
|
+
return events.filter((e) => !deliveredIds.has(e.id)).sort((a, b) => a.ts - b.ts);
|
|
7039
|
+
}
|
|
7040
|
+
function readBillingStatus(billingDir) {
|
|
7041
|
+
const { events, deliveredIds } = readBillingLedger(billingDir);
|
|
7042
|
+
let delivered = 0;
|
|
7043
|
+
for (const e of events) if (deliveredIds.has(e.id)) delivered += 1;
|
|
7044
|
+
return { total: events.length, delivered, pending: events.length - delivered };
|
|
7045
|
+
}
|
|
7046
|
+
function parseLines(dir, file) {
|
|
7047
|
+
let raw;
|
|
7048
|
+
try {
|
|
7049
|
+
raw = readFileSync13(join9(dir, file), "utf8");
|
|
7050
|
+
} catch {
|
|
7051
|
+
return [];
|
|
7052
|
+
}
|
|
7053
|
+
const out = [];
|
|
7054
|
+
for (const line of raw.split("\n")) {
|
|
7055
|
+
const trimmed = line.trim();
|
|
7056
|
+
if (!trimmed) continue;
|
|
7057
|
+
try {
|
|
7058
|
+
out.push(JSON.parse(trimmed));
|
|
7059
|
+
} catch {
|
|
7060
|
+
}
|
|
7061
|
+
}
|
|
7062
|
+
return out;
|
|
7063
|
+
}
|
|
7064
|
+
function isBillingEvent(value) {
|
|
7065
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
7066
|
+
const r = value;
|
|
7067
|
+
return typeof r["id"] === "string" && typeof r["ts"] === "number" && typeof r["model"] === "string" && typeof r["status"] === "number";
|
|
7068
|
+
}
|
|
7069
|
+
|
|
7070
|
+
// src/billing/BillingRetrySweeper.ts
|
|
7071
|
+
var SWEEP_INTERVAL_MS3 = 5 * 6e4;
|
|
7072
|
+
var BillingRetrySweeper = class {
|
|
7073
|
+
constructor(billingDir, publisher2, logger, config, intervalMs = SWEEP_INTERVAL_MS3, now = Date.now) {
|
|
7074
|
+
this.billingDir = billingDir;
|
|
7075
|
+
this.publisher = publisher2;
|
|
7076
|
+
this.logger = logger;
|
|
7077
|
+
this.config = config;
|
|
7078
|
+
this.intervalMs = intervalMs;
|
|
7079
|
+
this.now = now;
|
|
7080
|
+
}
|
|
7081
|
+
billingDir;
|
|
7082
|
+
publisher;
|
|
7083
|
+
logger;
|
|
7084
|
+
config;
|
|
7085
|
+
intervalMs;
|
|
7086
|
+
now;
|
|
7087
|
+
timer = null;
|
|
7088
|
+
sweeping = false;
|
|
7089
|
+
/** Whether retrying is active: billing enabled AND an endpoint is configured. */
|
|
7090
|
+
get enabled() {
|
|
7091
|
+
return this.config.enabled && typeof this.config.endpoint === "string" && this.config.endpoint.length > 0;
|
|
7092
|
+
}
|
|
7093
|
+
/** Re-apply config to the live instance (boot + admin PUT hot-reload). */
|
|
7094
|
+
configure(config) {
|
|
7095
|
+
this.config = config;
|
|
4525
7096
|
}
|
|
4526
7097
|
/**
|
|
4527
|
-
*
|
|
4528
|
-
*
|
|
4529
|
-
*
|
|
4530
|
-
* provider was already absent (still re-stamps + persists).
|
|
7098
|
+
* Arm the retry interval AND run one sweep immediately (boot catch-up for events
|
|
7099
|
+
* that failed to deliver while the daemon was down). No-op when disabled or in
|
|
7100
|
+
* ledger-only mode (no endpoint to POST to). Idempotent.
|
|
4531
7101
|
*/
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
this.
|
|
7102
|
+
start() {
|
|
7103
|
+
if (this.timer || !this.enabled) return;
|
|
7104
|
+
void this.sweep();
|
|
7105
|
+
this.timer = setInterval(() => void this.sweep(), this.intervalMs);
|
|
7106
|
+
this.timer.unref?.();
|
|
4536
7107
|
}
|
|
4537
|
-
/**
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
const encrypted = encryptTokens(config, this.box);
|
|
4544
|
-
writeFileSync7(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
|
|
7108
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
7109
|
+
dispose() {
|
|
7110
|
+
if (this.timer) {
|
|
7111
|
+
clearInterval(this.timer);
|
|
7112
|
+
this.timer = null;
|
|
7113
|
+
}
|
|
4545
7114
|
}
|
|
4546
7115
|
/**
|
|
4547
|
-
*
|
|
4548
|
-
*
|
|
4549
|
-
*
|
|
4550
|
-
*
|
|
4551
|
-
* The fs-read + JSON-parse tolerance is INSIDE the try (a missing or corrupt
|
|
4552
|
-
* file → empty `{ updatedAt: '' }`). The DECRYPT runs OUTSIDE the try, so a
|
|
4553
|
-
* wrong/missing master key or a tampered `enc:` envelope FAILS FAST with the
|
|
4554
|
-
* box's clear, secret-free error (secrets spec "错误密钥 / 篡改的解密失败 UX":
|
|
4555
|
-
* SHALL fail-fast, SHALL NOT 静默降级 — a swallowed decrypt would report "no
|
|
4556
|
-
* tokens" and silently send the WRONG bearer upstream → 401). Mirrors
|
|
4557
|
-
* `config.ts loadConfig`, which decrypts outside its parse try.
|
|
7116
|
+
* One sweep: re-POST every UNdelivered ledger event still within
|
|
7117
|
+
* `maxRetryAgeMs`; leave over-age undelivered events for reconciliation (NEVER
|
|
7118
|
+
* deleted). Exposed for tests; never throws. Returns the number of events a
|
|
7119
|
+
* re-POST was attempted for.
|
|
4558
7120
|
*/
|
|
4559
|
-
|
|
4560
|
-
if (!
|
|
4561
|
-
|
|
7121
|
+
async sweep() {
|
|
7122
|
+
if (!this.enabled || this.sweeping) return 0;
|
|
7123
|
+
this.sweeping = true;
|
|
4562
7124
|
try {
|
|
4563
|
-
const
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
7125
|
+
const cutoff = this.now() - this.config.maxRetryAgeMs;
|
|
7126
|
+
let attempted = 0;
|
|
7127
|
+
for (const event of readUndeliveredEvents(this.billingDir)) {
|
|
7128
|
+
if (event.ts < cutoff) continue;
|
|
7129
|
+
attempted += 1;
|
|
7130
|
+
await this.publisher.deliverNow(event);
|
|
7131
|
+
}
|
|
7132
|
+
if (attempted > 0) this.logger.debug("billing retry sweep complete", { attempted });
|
|
7133
|
+
return attempted;
|
|
7134
|
+
} catch (error) {
|
|
7135
|
+
this.logger.warn("billing retry sweep failed", {
|
|
7136
|
+
error: error instanceof Error ? error.message : String(error)
|
|
7137
|
+
});
|
|
7138
|
+
return 0;
|
|
7139
|
+
} finally {
|
|
7140
|
+
this.sweeping = false;
|
|
4567
7141
|
}
|
|
4568
|
-
if (!parsed) return { updatedAt: "" };
|
|
4569
|
-
const decrypted = decryptTokens(parsed, this.box);
|
|
4570
|
-
return migrateLazily(decrypted);
|
|
4571
7142
|
}
|
|
4572
7143
|
};
|
|
4573
7144
|
|
|
4574
7145
|
// src/TokenRefreshScheduler.ts
|
|
4575
|
-
var
|
|
4576
|
-
var
|
|
4577
|
-
var
|
|
7146
|
+
var REFRESH_LEAD_MS2 = 5 * 6e4;
|
|
7147
|
+
var SWEEP_INTERVAL_MS4 = 6e4;
|
|
7148
|
+
var OAUTH_PROVIDERS2 = ["claude", "codex", "gemini"];
|
|
4578
7149
|
var TokenRefreshScheduler = class {
|
|
4579
|
-
constructor(store, logger, intervalMs =
|
|
7150
|
+
constructor(store, logger, intervalMs = SWEEP_INTERVAL_MS4, leadMs = REFRESH_LEAD_MS2) {
|
|
4580
7151
|
this.store = store;
|
|
4581
7152
|
this.logger = logger;
|
|
4582
7153
|
this.intervalMs = intervalMs;
|
|
@@ -4607,7 +7178,7 @@ var TokenRefreshScheduler = class {
|
|
|
4607
7178
|
this.sweeping = true;
|
|
4608
7179
|
try {
|
|
4609
7180
|
const config = await this.store.getFullConfig();
|
|
4610
|
-
for (const provider of
|
|
7181
|
+
for (const provider of OAUTH_PROVIDERS2) {
|
|
4611
7182
|
const activeId = getActiveAccount(config, provider)?.id;
|
|
4612
7183
|
for (const account of listAccounts(config, provider)) {
|
|
4613
7184
|
if (!this.needsRefresh(account.tokens, now)) continue;
|
|
@@ -4660,16 +7231,186 @@ var TokenRefreshScheduler = class {
|
|
|
4660
7231
|
}
|
|
4661
7232
|
};
|
|
4662
7233
|
|
|
7234
|
+
// src/webhook/WebhookDispatcher.ts
|
|
7235
|
+
import { createHmac as createHmac2 } from "crypto";
|
|
7236
|
+
import { fetchUpstream as fetchUpstream5 } from "@omnicross/core/pipeline/upstreamFetch";
|
|
7237
|
+
var WEBHOOK_MAX_ATTEMPTS = 3;
|
|
7238
|
+
var WEBHOOK_QUEUE_MAX = 1e3;
|
|
7239
|
+
var WEBHOOK_SEND_TIMEOUT_MS = 1e4;
|
|
7240
|
+
var WEBHOOK_BASE_BACKOFF_MS = 200;
|
|
7241
|
+
var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
7242
|
+
var WebhookDispatcher = class {
|
|
7243
|
+
config;
|
|
7244
|
+
queue = [];
|
|
7245
|
+
draining = false;
|
|
7246
|
+
warnedFull = false;
|
|
7247
|
+
fetchImpl;
|
|
7248
|
+
logger;
|
|
7249
|
+
maxAttempts;
|
|
7250
|
+
queueMax;
|
|
7251
|
+
timeoutMs;
|
|
7252
|
+
baseBackoffMs;
|
|
7253
|
+
sleep;
|
|
7254
|
+
now;
|
|
7255
|
+
constructor(opts = {}) {
|
|
7256
|
+
this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetchUpstream5(url, init));
|
|
7257
|
+
this.logger = opts.logger;
|
|
7258
|
+
this.maxAttempts = opts.maxAttempts ?? WEBHOOK_MAX_ATTEMPTS;
|
|
7259
|
+
this.queueMax = opts.queueMax ?? WEBHOOK_QUEUE_MAX;
|
|
7260
|
+
this.timeoutMs = opts.timeoutMs ?? WEBHOOK_SEND_TIMEOUT_MS;
|
|
7261
|
+
this.baseBackoffMs = opts.baseBackoffMs ?? WEBHOOK_BASE_BACKOFF_MS;
|
|
7262
|
+
this.sleep = opts.sleep ?? defaultSleep;
|
|
7263
|
+
this.now = opts.now ?? Date.now;
|
|
7264
|
+
}
|
|
7265
|
+
/** Install/replace the live webhook config (destinations + master switch). */
|
|
7266
|
+
setConfig(config) {
|
|
7267
|
+
this.config = config;
|
|
7268
|
+
}
|
|
7269
|
+
/**
|
|
7270
|
+
* Enqueue an event and return IMMEDIATELY (fire-and-forget). NEVER awaits a
|
|
7271
|
+
* send, NEVER throws — the drain loop does all sending on a side channel. A
|
|
7272
|
+
* full queue drops the OLDEST event (with a one-shot warn) so a runaway source
|
|
7273
|
+
* can't OOM the process.
|
|
7274
|
+
*/
|
|
7275
|
+
emit(event) {
|
|
7276
|
+
if (this.queue.length >= this.queueMax) {
|
|
7277
|
+
this.queue.shift();
|
|
7278
|
+
if (!this.warnedFull) {
|
|
7279
|
+
this.logger?.warn("[webhook] queue full \u2014 dropping oldest events");
|
|
7280
|
+
this.warnedFull = true;
|
|
7281
|
+
}
|
|
7282
|
+
}
|
|
7283
|
+
this.queue.push(event);
|
|
7284
|
+
if (!this.draining) {
|
|
7285
|
+
this.draining = true;
|
|
7286
|
+
queueMicrotask(() => void this.drain());
|
|
7287
|
+
}
|
|
7288
|
+
}
|
|
7289
|
+
/** Drain the queue, sending each event to its matching destinations concurrently. */
|
|
7290
|
+
async drain() {
|
|
7291
|
+
try {
|
|
7292
|
+
while (this.queue.length > 0) {
|
|
7293
|
+
const event = this.queue.shift();
|
|
7294
|
+
const destinations = this.matchingDestinations(event.kind);
|
|
7295
|
+
if (destinations.length === 0) continue;
|
|
7296
|
+
await Promise.all(destinations.map((d) => this.sendWithRetry(event, d)));
|
|
7297
|
+
}
|
|
7298
|
+
} finally {
|
|
7299
|
+
this.draining = false;
|
|
7300
|
+
if (this.queue.length > 0) {
|
|
7301
|
+
this.draining = true;
|
|
7302
|
+
queueMicrotask(() => void this.drain());
|
|
7303
|
+
}
|
|
7304
|
+
}
|
|
7305
|
+
}
|
|
7306
|
+
/** The enabled destinations whose event filter admits this kind (empty ⇒ all). */
|
|
7307
|
+
matchingDestinations(kind) {
|
|
7308
|
+
const cfg = this.config;
|
|
7309
|
+
if (!cfg || !cfg.enabled) return [];
|
|
7310
|
+
return cfg.destinations.filter(
|
|
7311
|
+
(d) => d.enabled && (!d.events || d.events.length === 0 || d.events.includes(kind))
|
|
7312
|
+
);
|
|
7313
|
+
}
|
|
7314
|
+
/** Send with bounded exponential backoff; log-and-drop after `maxAttempts`. */
|
|
7315
|
+
async sendWithRetry(event, dest) {
|
|
7316
|
+
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
|
7317
|
+
const result = await this.sendOnce(event, dest);
|
|
7318
|
+
if (result.ok) {
|
|
7319
|
+
this.logger?.debug(`[webhook] delivered ${event.kind} \u2192 ${dest.id} (${result.status})`);
|
|
7320
|
+
return;
|
|
7321
|
+
}
|
|
7322
|
+
if (attempt < this.maxAttempts) {
|
|
7323
|
+
await this.sleep(this.baseBackoffMs * 2 ** (attempt - 1));
|
|
7324
|
+
} else {
|
|
7325
|
+
this.logger?.warn(
|
|
7326
|
+
`[webhook] dropped ${event.kind} \u2192 ${dest.id} after ${this.maxAttempts} attempts: ${result.error ?? `HTTP ${result.status}`}`
|
|
7327
|
+
);
|
|
7328
|
+
}
|
|
7329
|
+
}
|
|
7330
|
+
}
|
|
7331
|
+
/** One POST attempt. Returns an outcome; a thrown error becomes `{ ok:false }`. */
|
|
7332
|
+
async sendOnce(event, dest) {
|
|
7333
|
+
try {
|
|
7334
|
+
const { body, headers } = buildRequest(event, dest, this.now());
|
|
7335
|
+
const res = await this.fetchImpl(dest.url, {
|
|
7336
|
+
method: "POST",
|
|
7337
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
7338
|
+
body,
|
|
7339
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
7340
|
+
});
|
|
7341
|
+
return res.ok ? { ok: true, status: res.status } : { ok: false, status: res.status };
|
|
7342
|
+
} catch (err5) {
|
|
7343
|
+
return { ok: false, error: err5 instanceof Error ? err5.message : String(err5) };
|
|
7344
|
+
}
|
|
7345
|
+
}
|
|
7346
|
+
/**
|
|
7347
|
+
* ADMIN test path (design D8): deliver a `test` event to ONE destination and
|
|
7348
|
+
* AWAIT the single-attempt result. This is the ONLY awaited send — it runs on
|
|
7349
|
+
* the admin request path (an operator clicking "Test"), NEVER on a relay path,
|
|
7350
|
+
* so awaiting it is safe. Finds the destination regardless of its `enabled`
|
|
7351
|
+
* flag or the master switch (an explicit operator action).
|
|
7352
|
+
*/
|
|
7353
|
+
async deliverTest(destinationId) {
|
|
7354
|
+
const dest = this.config?.destinations.find((d) => d.id === destinationId);
|
|
7355
|
+
if (!dest) return { ok: false, error: "destination not found" };
|
|
7356
|
+
return this.sendOnce({ kind: "test", at: this.now() }, dest);
|
|
7357
|
+
}
|
|
7358
|
+
};
|
|
7359
|
+
function buildRequest(event, dest, nowMs) {
|
|
7360
|
+
if (dest.type === "feishu") return buildFeishu(event, dest, nowMs);
|
|
7361
|
+
return buildCustom(event, dest);
|
|
7362
|
+
}
|
|
7363
|
+
function buildCustom(event, dest) {
|
|
7364
|
+
const body = JSON.stringify(event);
|
|
7365
|
+
const headers = {};
|
|
7366
|
+
if (dest.secret) {
|
|
7367
|
+
const hmac = createHmac2("sha256", dest.secret).update(body).digest("hex");
|
|
7368
|
+
headers["X-Omnicross-Signature"] = `sha256=${hmac}`;
|
|
7369
|
+
}
|
|
7370
|
+
return { body, headers };
|
|
7371
|
+
}
|
|
7372
|
+
function buildFeishu(event, dest, nowMs) {
|
|
7373
|
+
const payload = {
|
|
7374
|
+
msg_type: "text",
|
|
7375
|
+
content: { text: feishuText(event) }
|
|
7376
|
+
};
|
|
7377
|
+
if (dest.secret) {
|
|
7378
|
+
const timestamp = Math.floor(nowMs / 1e3).toString();
|
|
7379
|
+
const stringToSign = `${timestamp}
|
|
7380
|
+
${dest.secret}`;
|
|
7381
|
+
payload["timestamp"] = timestamp;
|
|
7382
|
+
payload["sign"] = createHmac2("sha256", stringToSign).digest("base64");
|
|
7383
|
+
}
|
|
7384
|
+
return { body: JSON.stringify(payload), headers: {} };
|
|
7385
|
+
}
|
|
7386
|
+
function feishuText(event) {
|
|
7387
|
+
switch (event.kind) {
|
|
7388
|
+
case "account.recovery":
|
|
7389
|
+
return `omnicross: account recovered \u2014 ${event.providerId}/${event.accountId}`;
|
|
7390
|
+
case "account.anomaly":
|
|
7391
|
+
return `omnicross: account anomaly [${event.state}] \u2014 ${event.providerId}/${event.accountId}`;
|
|
7392
|
+
case "key.quotaWarning":
|
|
7393
|
+
return `omnicross: key quota warning (${event.scope}) \u2014 $${event.spentUsd.toFixed(2)} of $${event.limitUsd.toFixed(2)} (key ${event.keyId})`;
|
|
7394
|
+
case "key.quotaExceeded":
|
|
7395
|
+
return `omnicross: key quota EXCEEDED (${event.scope}) \u2014 $${event.spentUsd.toFixed(2)} of $${event.limitUsd.toFixed(2)} (key ${event.keyId})`;
|
|
7396
|
+
case "server.error":
|
|
7397
|
+
return `omnicross: server error \u2014 ${event.message}`;
|
|
7398
|
+
case "test":
|
|
7399
|
+
return "omnicross: webhook test";
|
|
7400
|
+
}
|
|
7401
|
+
}
|
|
7402
|
+
|
|
4663
7403
|
// src/bootstrap.ts
|
|
4664
7404
|
function buildDaemon(config, paths) {
|
|
4665
|
-
const logger = new
|
|
7405
|
+
const logger = new ConfigurableLogger(config.logging);
|
|
4666
7406
|
const secretBox3 = new SecretBox(() => resolveMasterKey({ keyFilePath: paths.masterKeyFilePath }));
|
|
4667
7407
|
setSecretBox(secretBox3);
|
|
4668
7408
|
setSecretBox2(secretBox3);
|
|
4669
7409
|
const decryptedConfig = decryptConfigSecrets(config, secretBox3);
|
|
4670
7410
|
const llmConfig = new ConfigFileProviderConfigSource(decryptedConfig);
|
|
4671
7411
|
const keyDb = new JsonOutboundKeyDb(paths.keysPath);
|
|
4672
|
-
const
|
|
7412
|
+
const voucherDb = new JsonVoucherDb(defaultVouchersPath(paths.configPath));
|
|
7413
|
+
const settingsStore = new JsonApiServerSettingsStore(paths.configPath, secretBox3);
|
|
4673
7414
|
const credentialStore = new JsonSubscriptionCredentialStore(paths.tokensPath, secretBox3);
|
|
4674
7415
|
const subscriptionAccounts = new SubscriptionAccountService(credentialStore);
|
|
4675
7416
|
setSubscriptionAccountService(subscriptionAccounts);
|
|
@@ -4678,6 +7419,12 @@ function buildDaemon(config, paths) {
|
|
|
4678
7419
|
credentialStore
|
|
4679
7420
|
);
|
|
4680
7421
|
setSubscriptionProviderRegistry(subscriptionRegistry);
|
|
7422
|
+
setServerProxyConfig(decryptedConfig.server?.proxy);
|
|
7423
|
+
setUpstreamProxyResolver(
|
|
7424
|
+
createUpstreamProxyResolver({
|
|
7425
|
+
getAccountProxy: (providerId, accountId) => credentialStore.getAccountProxy(providerId, accountId)
|
|
7426
|
+
})
|
|
7427
|
+
);
|
|
4681
7428
|
setGeminiCodeAssistResolver(getGeminiCodeAssistProjectResolver());
|
|
4682
7429
|
const autoDisableStore = new AutoDisableStore();
|
|
4683
7430
|
const apiKeyPool = new ApiKeyPoolService(
|
|
@@ -4698,19 +7445,59 @@ function buildDaemon(config, paths) {
|
|
|
4698
7445
|
defaultUsageEventsPath(paths.configPath),
|
|
4699
7446
|
async (providerId, model) => await pricingEngine.getEntry(providerId, model) !== null
|
|
4700
7447
|
);
|
|
4701
|
-
const
|
|
7448
|
+
const keySpendTracker = new KeySpendTracker(usageEventStore);
|
|
7449
|
+
const usageRecorder = new UsageRecorder(usageEventStore, pricingEngine, logger, {
|
|
7450
|
+
onRecord: (apiKeyId, costUsd, at) => keySpendTracker.add(apiKeyId, costUsd, at)
|
|
7451
|
+
});
|
|
4702
7452
|
const providerProxy = getProviderProxy({ llmConfig, apiKeyPool, usageRecorder });
|
|
4703
7453
|
llmConfig.setReloadHook(() => apiKeyPool.invalidateCache());
|
|
7454
|
+
const accountHealthProbeScheduler = new AccountHealthProbeScheduler(
|
|
7455
|
+
credentialStore,
|
|
7456
|
+
getSharedAccountHealth2(),
|
|
7457
|
+
logger,
|
|
7458
|
+
DEFAULT_ACCOUNT_PROBE
|
|
7459
|
+
);
|
|
7460
|
+
const getHealthReport = () => buildHealthReport({
|
|
7461
|
+
version: DAEMON_VERSION,
|
|
7462
|
+
// CRITICAL: the config loaded with a providers array.
|
|
7463
|
+
configPresent: () => Array.isArray(decryptedConfig.providers),
|
|
7464
|
+
// CRITICAL: the credential store's tokens.json is readable WITHOUT
|
|
7465
|
+
// decrypting (a missing file is fine — no accounts yet). A stat/access
|
|
7466
|
+
// only; never reads or decrypts token material.
|
|
7467
|
+
credentialStoreReadable: () => isTokensStoreReadable(paths.tokensPath),
|
|
7468
|
+
outboundServerRunning: () => outboundApiServer.getStatus().running,
|
|
7469
|
+
adminServerRunning: () => adminServer.getStatus().running,
|
|
7470
|
+
// Coarse, account-anonymous probe signal (#8, D5) — added to `checks` ONLY
|
|
7471
|
+
// when probing is ENABLED; disabled ⇒ `undefined` ⇒ key omitted ⇒ the
|
|
7472
|
+
// `/health` body stays byte-identical (zero regression).
|
|
7473
|
+
subscriptionAccountsHealthy: () => accountHealthProbeScheduler.enabled ? accountHealthProbeScheduler.probedAccountsHealthy() : void 0
|
|
7474
|
+
});
|
|
4704
7475
|
const outboundApiServer = getOutboundApiServer({
|
|
4705
7476
|
db: keyDb,
|
|
7477
|
+
// voucher-redemption #9: the key-authenticated `POST /redeem` endpoint redeems
|
|
7478
|
+
// cards against the presenting key (gated on `voucher.enabled`).
|
|
7479
|
+
voucherDb,
|
|
4706
7480
|
llmConfig,
|
|
4707
7481
|
providerProxy,
|
|
4708
|
-
proxyDeps: providerProxy.getDeps()
|
|
7482
|
+
proxyDeps: providerProxy.getDeps(),
|
|
7483
|
+
healthReportProvider: getHealthReport,
|
|
7484
|
+
// outbound-key-policy: the wire layer's 402 cost check reads per-key spend.
|
|
7485
|
+
keySpendTracker,
|
|
7486
|
+
// configurable-logging: route the server's OWN lifecycle + relay dispatch-error
|
|
7487
|
+
// lines through the injected logger (honors level/format/file sink).
|
|
7488
|
+
logger
|
|
4709
7489
|
});
|
|
7490
|
+
const auditDir = defaultAuditDir(paths.configPath);
|
|
7491
|
+
const billingDir = defaultBillingDir(paths.configPath);
|
|
4710
7492
|
const adminServer = new AdminServer({
|
|
4711
7493
|
configPath: paths.configPath,
|
|
4712
7494
|
llmConfig,
|
|
4713
7495
|
keyDb,
|
|
7496
|
+
// voucher-redemption #9: the admin `/admin/api/voucher` surface generates/
|
|
7497
|
+
// lists/revokes redemption cards (gated on `voucher.enabled`).
|
|
7498
|
+
voucherDb,
|
|
7499
|
+
// outbound-key-policy: the admin key list surfaces each key's OWN spend.
|
|
7500
|
+
keySpendReader: keySpendTracker,
|
|
4714
7501
|
settingsStore,
|
|
4715
7502
|
outboundApiServer,
|
|
4716
7503
|
subscriptionAccounts,
|
|
@@ -4732,14 +7519,16 @@ function buildDaemon(config, paths) {
|
|
|
4732
7519
|
oauthSessions: new OAuthSessionStore(),
|
|
4733
7520
|
// Real global fetch by default; a test seam (`paths.oauthExchangeFetch`) can
|
|
4734
7521
|
// inject a mock so no real token endpoint is hit.
|
|
4735
|
-
|
|
7522
|
+
// upstream-proxy: default the OAuth token-exchange fetch to the proxy-aware
|
|
7523
|
+
// helper so interactive login honors a configured proxy (global/env layers).
|
|
7524
|
+
oauthExchangeFetch: paths.oauthExchangeFetch ?? ((url, init) => fetchUpstream6(url, init)),
|
|
4736
7525
|
subscriptionAccountAppender: credentialStore,
|
|
4737
7526
|
// Codex interactive OAuth (app-parity-2 child 5) — the async loopback flow store
|
|
4738
7527
|
// + the one-shot 127.0.0.1:1455 listener. Token captured + persisted daemon-side;
|
|
4739
7528
|
// the app polls the token-free status. A test seam (`paths.codexAwaitLoopback`)
|
|
4740
7529
|
// can inject a mock so no real port is bound.
|
|
4741
7530
|
codexSessions: new CodexOAuthSessionStore(),
|
|
4742
|
-
codexAwaitLoopback: paths.codexAwaitLoopback ?? ((state, timeoutMs) => awaitLoopbackCode(state, timeoutMs)),
|
|
7531
|
+
codexAwaitLoopback: paths.codexAwaitLoopback ?? ((state, timeoutMs, signal) => awaitLoopbackCode(state, timeoutMs, signal)),
|
|
4743
7532
|
// Migration pack (app-parity child 6, design D2/D3) — the concrete credential
|
|
4744
7533
|
// store provides BOTH the full DECRYPTED read (`getFullConfig`, export) and
|
|
4745
7534
|
// the multi-account append (`appendProviderAccount`, import re-encrypts at-
|
|
@@ -4759,9 +7548,48 @@ function buildDaemon(config, paths) {
|
|
|
4759
7548
|
pricingStore,
|
|
4760
7549
|
// Use the DECRYPTED config so `admin.token` (if stored as `enc:`) is the
|
|
4761
7550
|
// plaintext bearer the AdminServer's constant-time compare expects (D4).
|
|
4762
|
-
getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin)
|
|
7551
|
+
getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin),
|
|
7552
|
+
// Unauthenticated `/health` probe (daemon-health-endpoint) — the SAME shared
|
|
7553
|
+
// builder the outbound server uses, served before the admin auth gate.
|
|
7554
|
+
getHealthReport,
|
|
7555
|
+
// configurable-logging: the admin listener's lifecycle lines route through
|
|
7556
|
+
// the injected logger.
|
|
7557
|
+
logger,
|
|
7558
|
+
// subscription-account-probe #8: the AUTHED `GET /admin/api/account-probes`
|
|
7559
|
+
// reads per-account probe history from the scheduler (secret-free — ids +
|
|
7560
|
+
// status labels only). Routed in `AdminServer` (not `adminApi.ts`).
|
|
7561
|
+
probeHistoryReader: accountHealthProbeScheduler,
|
|
7562
|
+
// request-audit-log: the AUTHED `GET /admin/api/audit` reads + filters the
|
|
7563
|
+
// date-rotated audit store. Bound to the store dir here so the AdminServer
|
|
7564
|
+
// carries no path/store coupling. Records hold IP/UA/bodies → admin-only,
|
|
7565
|
+
// NEVER unauth, NEVER on `/health`. Routed in `AdminServer` (not `adminApi.ts`).
|
|
7566
|
+
auditReader: (query) => readAuditRecords(auditDir, query),
|
|
7567
|
+
// billing-event-stream: the AUTHED `GET /admin/api/billing-status` returns the
|
|
7568
|
+
// secret-free total/delivered/pending counts of the durable ledger.
|
|
7569
|
+
billingStatusReader: () => readBillingStatus(billingDir)
|
|
4763
7570
|
});
|
|
7571
|
+
const webhookDispatcher = new WebhookDispatcher({
|
|
7572
|
+
logger,
|
|
7573
|
+
fetchImpl: (url, init) => fetchUpstream6(url, init)
|
|
7574
|
+
});
|
|
7575
|
+
setWebhookRuntime(webhookDispatcher, getSharedAccountHealth2());
|
|
7576
|
+
const auditWriter = new AuditWriter(auditDir, logger);
|
|
7577
|
+
const auditPruneSweeper = new AuditPruneSweeper(auditDir, logger, DEFAULT_AUDIT_CONFIG);
|
|
7578
|
+
setAuditRuntime(auditWriter, auditPruneSweeper);
|
|
7579
|
+
const billingPublisher = new BillingPublisher(billingDir, logger);
|
|
7580
|
+
const billingRetrySweeper = new BillingRetrySweeper(
|
|
7581
|
+
billingDir,
|
|
7582
|
+
billingPublisher,
|
|
7583
|
+
logger,
|
|
7584
|
+
DEFAULT_BILLING_CONFIG
|
|
7585
|
+
);
|
|
7586
|
+
setBillingRuntime(billingPublisher, billingRetrySweeper);
|
|
4764
7587
|
const tokenRefreshScheduler = new TokenRefreshScheduler(credentialStore, logger);
|
|
7588
|
+
const accountHealthSweeper = new AccountHealthSweeper(
|
|
7589
|
+
credentialStore,
|
|
7590
|
+
getSharedAccountHealth2(),
|
|
7591
|
+
logger
|
|
7592
|
+
);
|
|
4765
7593
|
return {
|
|
4766
7594
|
logger,
|
|
4767
7595
|
llmConfig,
|
|
@@ -4778,9 +7606,25 @@ function buildDaemon(config, paths) {
|
|
|
4778
7606
|
pricingEngine,
|
|
4779
7607
|
usageRecorder,
|
|
4780
7608
|
adminServer,
|
|
4781
|
-
tokenRefreshScheduler
|
|
7609
|
+
tokenRefreshScheduler,
|
|
7610
|
+
accountHealthSweeper,
|
|
7611
|
+
accountHealthProbeScheduler,
|
|
7612
|
+
webhookDispatcher,
|
|
7613
|
+
auditWriter,
|
|
7614
|
+
auditPruneSweeper,
|
|
7615
|
+
billingPublisher,
|
|
7616
|
+
billingRetrySweeper
|
|
4782
7617
|
};
|
|
4783
7618
|
}
|
|
7619
|
+
function isTokensStoreReadable(tokensPath) {
|
|
7620
|
+
try {
|
|
7621
|
+
if (!existsSync14(tokensPath)) return true;
|
|
7622
|
+
accessSync(tokensPath, fsConstants.R_OK);
|
|
7623
|
+
return true;
|
|
7624
|
+
} catch {
|
|
7625
|
+
return false;
|
|
7626
|
+
}
|
|
7627
|
+
}
|
|
4784
7628
|
|
|
4785
7629
|
// src/commands/launch.ts
|
|
4786
7630
|
var SUPPORTED_LAUNCH_CLIS = [
|
|
@@ -4822,8 +7666,8 @@ function buildCliSpawnPlan(opts) {
|
|
|
4822
7666
|
function resolveInPathDefault(candidate) {
|
|
4823
7667
|
const segments = (process.env["PATH"] ?? "").split(delimiter2).filter(Boolean);
|
|
4824
7668
|
for (const seg of segments) {
|
|
4825
|
-
const full =
|
|
4826
|
-
if (
|
|
7669
|
+
const full = join10(seg, candidate);
|
|
7670
|
+
if (existsSync15(full)) return full;
|
|
4827
7671
|
}
|
|
4828
7672
|
return null;
|
|
4829
7673
|
}
|
|
@@ -4866,6 +7710,10 @@ async function runLaunch(argv, deps) {
|
|
|
4866
7710
|
} catch (err5) {
|
|
4867
7711
|
daemon.apiKeyPool.dispose();
|
|
4868
7712
|
daemon.tokenRefreshScheduler.dispose();
|
|
7713
|
+
daemon.accountHealthSweeper.dispose();
|
|
7714
|
+
daemon.accountHealthProbeScheduler.dispose();
|
|
7715
|
+
daemon.auditPruneSweeper.dispose();
|
|
7716
|
+
daemon.billingRetrySweeper.dispose();
|
|
4869
7717
|
throw err5;
|
|
4870
7718
|
}
|
|
4871
7719
|
let launch;
|
|
@@ -4878,6 +7726,10 @@ async function runLaunch(argv, deps) {
|
|
|
4878
7726
|
await daemon.providerProxy.stop();
|
|
4879
7727
|
daemon.apiKeyPool.dispose();
|
|
4880
7728
|
daemon.tokenRefreshScheduler.dispose();
|
|
7729
|
+
daemon.accountHealthSweeper.dispose();
|
|
7730
|
+
daemon.accountHealthProbeScheduler.dispose();
|
|
7731
|
+
daemon.auditPruneSweeper.dispose();
|
|
7732
|
+
daemon.billingRetrySweeper.dispose();
|
|
4881
7733
|
throw err5;
|
|
4882
7734
|
}
|
|
4883
7735
|
try {
|
|
@@ -4900,6 +7752,10 @@ async function runLaunch(argv, deps) {
|
|
|
4900
7752
|
await daemon.providerProxy.stop();
|
|
4901
7753
|
daemon.apiKeyPool.dispose();
|
|
4902
7754
|
daemon.tokenRefreshScheduler.dispose();
|
|
7755
|
+
daemon.accountHealthSweeper.dispose();
|
|
7756
|
+
daemon.accountHealthProbeScheduler.dispose();
|
|
7757
|
+
daemon.auditPruneSweeper.dispose();
|
|
7758
|
+
daemon.billingRetrySweeper.dispose();
|
|
4903
7759
|
}
|
|
4904
7760
|
}
|
|
4905
7761
|
async function buildLaunchConfig(cli, llmConfig, opts) {
|
|
@@ -4972,6 +7828,7 @@ function spawnCliInherit(plan) {
|
|
|
4972
7828
|
import { spawn as spawn3 } from "child_process";
|
|
4973
7829
|
import { createInterface } from "readline";
|
|
4974
7830
|
import { parseArgs as parseArgs4 } from "util";
|
|
7831
|
+
import { fetchUpstream as fetchUpstream7, setUpstreamProxyResolver as setUpstreamProxyResolver2 } from "@omnicross/core/pipeline/upstreamFetch";
|
|
4975
7832
|
import { claudeOAuth as claudeOAuth3, codexOAuth as codexOAuth3, geminiOAuth as geminiOAuth3 } from "@omnicross/subscriptions";
|
|
4976
7833
|
var PROVIDERS = ["claude", "codex", "gemini"];
|
|
4977
7834
|
async function runLogin(argv, deps) {
|
|
@@ -5003,9 +7860,10 @@ async function runLogin(argv, deps) {
|
|
|
5003
7860
|
};
|
|
5004
7861
|
const box = resolveSecretBox(values["master-key-file"]);
|
|
5005
7862
|
setSecretBox(box);
|
|
7863
|
+
setUpstreamProxyResolver2(createUpstreamProxyResolver());
|
|
5006
7864
|
try {
|
|
5007
7865
|
const tokensPath = defaultTokensPath(values.config);
|
|
5008
|
-
const exchangeFetch = resolved.tokensFetch ?? ((url, init) =>
|
|
7866
|
+
const exchangeFetch = resolved.tokensFetch ?? ((url, init) => fetchUpstream7(url, init, { providerId: provider }));
|
|
5009
7867
|
const store = new JsonSubscriptionCredentialStore(tokensPath, box, exchangeFetch);
|
|
5010
7868
|
const expiresAt = await runProviderLogin(
|
|
5011
7869
|
provider,
|
|
@@ -5018,6 +7876,7 @@ async function runLogin(argv, deps) {
|
|
|
5018
7876
|
console.info(` token: [stored, encrypted] expiresAt: ${expiresAt ?? "n/a"}`);
|
|
5019
7877
|
} finally {
|
|
5020
7878
|
setSecretBox(null);
|
|
7879
|
+
setUpstreamProxyResolver2(null);
|
|
5021
7880
|
}
|
|
5022
7881
|
}
|
|
5023
7882
|
async function runProviderLogin(provider, store, deps, exchangeFetch, label) {
|
|
@@ -5290,7 +8149,7 @@ function providersRmKey(configPath, providerId, keyId) {
|
|
|
5290
8149
|
}
|
|
5291
8150
|
|
|
5292
8151
|
// src/commands/secrets.ts
|
|
5293
|
-
import { existsSync as
|
|
8152
|
+
import { existsSync as existsSync16, readFileSync as readFileSync14, writeFileSync as writeFileSync9 } from "fs";
|
|
5294
8153
|
import { parseArgs as parseArgs6 } from "util";
|
|
5295
8154
|
async function runSecrets(argv) {
|
|
5296
8155
|
const { values, positionals } = parseArgs6({
|
|
@@ -5362,7 +8221,7 @@ function secretsStatus(args) {
|
|
|
5362
8221
|
reportField("admin.token", cfg.admin.token);
|
|
5363
8222
|
}
|
|
5364
8223
|
const tokensPath = defaultTokensPath(args.config);
|
|
5365
|
-
if (
|
|
8224
|
+
if (existsSync16(tokensPath)) {
|
|
5366
8225
|
console.info(`Secret status for ${tokensPath}:`);
|
|
5367
8226
|
reportTokenFields(tokensPath);
|
|
5368
8227
|
}
|
|
@@ -5402,7 +8261,7 @@ function secretsRotate(args) {
|
|
|
5402
8261
|
const tokensPath = defaultTokensPath(args.config);
|
|
5403
8262
|
try {
|
|
5404
8263
|
cfg = loadConfig(args.config);
|
|
5405
|
-
if (
|
|
8264
|
+
if (existsSync16(tokensPath)) tokensPlain = decryptTokensFile(tokensPath, oldBox);
|
|
5406
8265
|
} finally {
|
|
5407
8266
|
setSecretBox(null);
|
|
5408
8267
|
}
|
|
@@ -5431,20 +8290,20 @@ function secretsDecrypt(args) {
|
|
|
5431
8290
|
let tokensPlain = null;
|
|
5432
8291
|
try {
|
|
5433
8292
|
cfg = loadConfig(args.config);
|
|
5434
|
-
if (
|
|
8293
|
+
if (existsSync16(tokensPath)) tokensPlain = decryptTokensFile(tokensPath, box);
|
|
5435
8294
|
} finally {
|
|
5436
8295
|
setSecretBox(null);
|
|
5437
8296
|
}
|
|
5438
8297
|
saveConfig(args.config, cfg);
|
|
5439
8298
|
if (tokensPlain) {
|
|
5440
|
-
|
|
8299
|
+
writeFileSync9(tokensPath, JSON.stringify(tokensPlain, null, 2) + "\n", "utf8");
|
|
5441
8300
|
}
|
|
5442
8301
|
console.info(`Decrypted secrets to plaintext in ${args.config}` + tokensSuffix(args.config));
|
|
5443
8302
|
}
|
|
5444
8303
|
function readRawConfig(path2) {
|
|
5445
8304
|
let parsed;
|
|
5446
8305
|
try {
|
|
5447
|
-
parsed = JSON.parse(
|
|
8306
|
+
parsed = JSON.parse(readFileSync14(path2, "utf8"));
|
|
5448
8307
|
} catch {
|
|
5449
8308
|
throw new Error(`secrets: cannot read or parse '${path2}'`);
|
|
5450
8309
|
}
|
|
@@ -5452,7 +8311,7 @@ function readRawConfig(path2) {
|
|
|
5452
8311
|
}
|
|
5453
8312
|
function readRawJson(path2) {
|
|
5454
8313
|
try {
|
|
5455
|
-
const parsed = JSON.parse(
|
|
8314
|
+
const parsed = JSON.parse(readFileSync14(path2, "utf8"));
|
|
5456
8315
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
5457
8316
|
return parsed;
|
|
5458
8317
|
}
|
|
@@ -5462,7 +8321,7 @@ function readRawJson(path2) {
|
|
|
5462
8321
|
}
|
|
5463
8322
|
function encryptTokensFileInPlace(configPath, box) {
|
|
5464
8323
|
const tokensPath = defaultTokensPath(configPath);
|
|
5465
|
-
if (!
|
|
8324
|
+
if (!existsSync16(tokensPath)) return;
|
|
5466
8325
|
const plain = decryptTokensFile(tokensPath, box);
|
|
5467
8326
|
writeTokensEncrypted(tokensPath, plain, box);
|
|
5468
8327
|
}
|
|
@@ -5475,7 +8334,7 @@ function writeTokensEncrypted(tokensPath, plain, box) {
|
|
|
5475
8334
|
{ updatedAt: "", ...plain },
|
|
5476
8335
|
box
|
|
5477
8336
|
);
|
|
5478
|
-
|
|
8337
|
+
writeFileSync9(tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
|
|
5479
8338
|
}
|
|
5480
8339
|
var TOKEN_FIELDS2 = {
|
|
5481
8340
|
claude: ["accessToken", "refreshToken"],
|
|
@@ -5498,12 +8357,47 @@ function walkTokens(raw, fn) {
|
|
|
5498
8357
|
return next;
|
|
5499
8358
|
}
|
|
5500
8359
|
function tokensSuffix(configPath) {
|
|
5501
|
-
return
|
|
8360
|
+
return existsSync16(defaultTokensPath(configPath)) ? " (+ tokens.json)" : "";
|
|
5502
8361
|
}
|
|
5503
8362
|
|
|
5504
8363
|
// src/commands/start.ts
|
|
5505
8364
|
import { parseArgs as parseArgs7 } from "util";
|
|
5506
|
-
import { loadServerConfig as
|
|
8365
|
+
import { loadServerConfig as loadServerConfig3, OutboundApiConfigError } from "@omnicross/core/outbound-api";
|
|
8366
|
+
import { getSharedAccountHealth as getSharedAccountHealth3 } from "@omnicross/core/pipeline/SubscriptionAccountHealth";
|
|
8367
|
+
|
|
8368
|
+
// src/identity/identityRuntime.ts
|
|
8369
|
+
import { getSharedIdentityStore as getSharedIdentityStore2 } from "@omnicross/core/provider-proxy/identity/SubscriptionIdentityStore";
|
|
8370
|
+
async function applyFingerprintConfig(config, credentialStore) {
|
|
8371
|
+
const store = getSharedIdentityStore2();
|
|
8372
|
+
const enabled = config?.enabled === true;
|
|
8373
|
+
store.configure({ enabled, ua: config?.ua ?? null });
|
|
8374
|
+
if (!enabled) {
|
|
8375
|
+
store.setPersistence(null);
|
|
8376
|
+
return;
|
|
8377
|
+
}
|
|
8378
|
+
await seedIdentities(store, credentialStore);
|
|
8379
|
+
store.setPersistence({
|
|
8380
|
+
persist: (providerId, accountId, identity) => {
|
|
8381
|
+
void credentialStore.setAccountIdentity(providerId, accountId, identity).catch(() => {
|
|
8382
|
+
});
|
|
8383
|
+
}
|
|
8384
|
+
});
|
|
8385
|
+
}
|
|
8386
|
+
async function seedIdentities(store, credentialStore) {
|
|
8387
|
+
let config;
|
|
8388
|
+
try {
|
|
8389
|
+
config = await credentialStore.getFullConfig();
|
|
8390
|
+
} catch {
|
|
8391
|
+
return;
|
|
8392
|
+
}
|
|
8393
|
+
for (const provider of Object.keys(DAEMON_PROVIDER_KEYS)) {
|
|
8394
|
+
for (const account of listAccounts(config, provider)) {
|
|
8395
|
+
if (account.identity) store.seed(provider, account.id, account.identity);
|
|
8396
|
+
}
|
|
8397
|
+
}
|
|
8398
|
+
}
|
|
8399
|
+
|
|
8400
|
+
// src/commands/start.ts
|
|
5507
8401
|
async function runStart(argv) {
|
|
5508
8402
|
const { values } = parseArgs7({
|
|
5509
8403
|
args: argv,
|
|
@@ -5528,19 +8422,45 @@ async function runStart(argv) {
|
|
|
5528
8422
|
const daemon = buildDaemon(config, paths);
|
|
5529
8423
|
await daemon.llmConfig.ready();
|
|
5530
8424
|
await daemon.providerProxy.start();
|
|
5531
|
-
const serverConfig = await
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
endpoints: serverConfig.endpoints,
|
|
5536
|
-
port: serverConfig.port
|
|
8425
|
+
const serverConfig = await loadServerConfig3(daemon.settingsStore);
|
|
8426
|
+
getSharedAccountHealth3().configure({
|
|
8427
|
+
overloadEnabled: serverConfig.accountHealth?.overloadCooldownEnabled,
|
|
8428
|
+
overloadTtlMs: serverConfig.accountHealth?.overloadCooldownMs
|
|
5537
8429
|
});
|
|
8430
|
+
try {
|
|
8431
|
+
await daemon.outboundApiServer.applyConfig({
|
|
8432
|
+
enabled: true,
|
|
8433
|
+
networkBinding: serverConfig.networkBinding,
|
|
8434
|
+
endpoints: serverConfig.endpoints,
|
|
8435
|
+
port: serverConfig.port,
|
|
8436
|
+
userMessageQueue: serverConfig.userMessageQueue,
|
|
8437
|
+
concurrencyQueue: serverConfig.concurrencyQueue,
|
|
8438
|
+
// voucher-redemption #9: carry the persisted flag so `POST /redeem` works on
|
|
8439
|
+
// boot when the operator has enabled the product.
|
|
8440
|
+
voucher: serverConfig.voucher
|
|
8441
|
+
});
|
|
8442
|
+
} catch (err5) {
|
|
8443
|
+
if (err5 instanceof OutboundApiConfigError) {
|
|
8444
|
+
console.warn(`[outbound] not started \u2014 incomplete model configuration: ${err5.message}`);
|
|
8445
|
+
} else {
|
|
8446
|
+
throw err5;
|
|
8447
|
+
}
|
|
8448
|
+
}
|
|
5538
8449
|
let dashboardUrl = null;
|
|
5539
8450
|
if (!values["no-dashboard"]) {
|
|
5540
8451
|
await daemon.adminServer.start();
|
|
5541
8452
|
dashboardUrl = daemon.adminServer.getStatus().url;
|
|
5542
8453
|
}
|
|
5543
8454
|
daemon.tokenRefreshScheduler.start();
|
|
8455
|
+
daemon.accountHealthSweeper.start();
|
|
8456
|
+
if (serverConfig.accountProbe) {
|
|
8457
|
+
daemon.accountHealthProbeScheduler.configure(serverConfig.accountProbe);
|
|
8458
|
+
}
|
|
8459
|
+
daemon.accountHealthProbeScheduler.start();
|
|
8460
|
+
applyWebhookConfig(serverConfig.webhook);
|
|
8461
|
+
applyAuditConfig(serverConfig.audit);
|
|
8462
|
+
applyBillingConfig(serverConfig.billing);
|
|
8463
|
+
await applyFingerprintConfig(serverConfig.fingerprint, daemon.credentialStore);
|
|
5544
8464
|
const status = daemon.outboundApiServer.getStatus();
|
|
5545
8465
|
console.info("omnicross daemon is running.");
|
|
5546
8466
|
if (dashboardUrl) console.info(` dashboard : ${dashboardUrl}`);
|