@omnicross/daemon 0.1.2 → 0.1.3
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 +3206 -328
- package/dist/cli.js +3210 -314
- package/dist/index.cjs +3114 -260
- package/dist/index.d.cts +953 -31
- package/dist/index.d.ts +953 -31
- package/dist/index.js +3083 -213
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
// src/bootstrap.ts
|
|
2
|
+
import { accessSync, constants as fsConstants, existsSync as existsSync14 } from "fs";
|
|
3
|
+
import { DEFAULT_AUDIT_CONFIG } from "@omnicross/contracts/audit-types";
|
|
4
|
+
import { DEFAULT_BILLING_CONFIG } from "@omnicross/contracts/billing-types";
|
|
2
5
|
import { getGeminiCodeAssistProjectResolver } from "@omnicross/core/auth/GeminiCodeAssistProjectResolver";
|
|
3
6
|
import { ApiKeyPoolService } from "@omnicross/core/completion/ApiKeyPoolService";
|
|
4
7
|
import {
|
|
5
8
|
__resetOutboundApiServerForTests,
|
|
9
|
+
DEFAULT_ACCOUNT_PROBE,
|
|
6
10
|
getOutboundApiServer
|
|
7
11
|
} from "@omnicross/core/outbound-api";
|
|
8
12
|
import { setSubscriptionRegistryForOutbound } from "@omnicross/core/outbound-api/subscriptionRegistryPort";
|
|
13
|
+
import { getSharedAccountHealth as getSharedAccountHealth2 } from "@omnicross/core/pipeline/SubscriptionAccountHealth";
|
|
14
|
+
import { fetchUpstream as fetchUpstream6, setUpstreamProxyResolver } from "@omnicross/core/pipeline/upstreamFetch";
|
|
15
|
+
import { __resetSharedIdentityStoreForTests } from "@omnicross/core/provider-proxy/identity/SubscriptionIdentityStore";
|
|
9
16
|
import { setGeminiCodeAssistResolver } from "@omnicross/core/ports/gemini-code-assist-resolver";
|
|
10
17
|
import {
|
|
11
18
|
__resetProviderProxyForTests,
|
|
12
19
|
getProviderProxy
|
|
13
20
|
} from "@omnicross/core/provider-proxy";
|
|
21
|
+
import { KeySpendTracker } from "@omnicross/core/outbound-api";
|
|
14
22
|
import { PricingEngine, UsageRecorder } from "@omnicross/core/usage";
|
|
15
23
|
import {
|
|
16
24
|
setSubscriptionAccountService,
|
|
@@ -113,15 +121,146 @@ function handleCodexOAuthStatus(sessionId, deps) {
|
|
|
113
121
|
// src/admin/AdminServer.ts
|
|
114
122
|
import { timingSafeEqual } from "crypto";
|
|
115
123
|
import http2 from "http";
|
|
124
|
+
import {
|
|
125
|
+
healthHttpStatus
|
|
126
|
+
} from "@omnicross/contracts/health-logging-types";
|
|
127
|
+
|
|
128
|
+
// src/admin/accountProbesApi.ts
|
|
129
|
+
function handleAccountProbes(res, reader) {
|
|
130
|
+
const accounts = reader ? reader.getAllHistory() : [];
|
|
131
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
132
|
+
res.end(JSON.stringify({ accounts }));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/admin/auditQueryApi.ts
|
|
136
|
+
function intParam(value) {
|
|
137
|
+
if (value === null || value.trim() === "") return void 0;
|
|
138
|
+
const n = Number(value);
|
|
139
|
+
return Number.isFinite(n) ? Math.trunc(n) : void 0;
|
|
140
|
+
}
|
|
141
|
+
function handleAuditQuery(req, res, reader) {
|
|
142
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
143
|
+
const query = {};
|
|
144
|
+
const keyId = url.searchParams.get("keyId");
|
|
145
|
+
if (keyId && keyId.trim()) query.keyId = keyId.trim();
|
|
146
|
+
const from = intParam(url.searchParams.get("from"));
|
|
147
|
+
if (from !== void 0) query.from = from;
|
|
148
|
+
const to = intParam(url.searchParams.get("to"));
|
|
149
|
+
if (to !== void 0) query.to = to;
|
|
150
|
+
const limit = intParam(url.searchParams.get("limit"));
|
|
151
|
+
if (limit !== void 0) query.limit = limit;
|
|
152
|
+
const records = reader ? reader(query) : [];
|
|
153
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
154
|
+
res.end(JSON.stringify({ records }));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/admin/billingStatusApi.ts
|
|
158
|
+
function handleBillingStatus(res, reader) {
|
|
159
|
+
const status = reader ? reader() : { total: 0, delivered: 0, pending: 0 };
|
|
160
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
161
|
+
res.end(JSON.stringify({ status }));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/webhook/webhookRuntime.ts
|
|
165
|
+
import { setWebhookSink } from "@omnicross/core/pipeline/webhookEmit";
|
|
166
|
+
var dispatcher = null;
|
|
167
|
+
var health = null;
|
|
168
|
+
var unsubscribers = [];
|
|
169
|
+
var wired = false;
|
|
170
|
+
function setWebhookRuntime(d, h) {
|
|
171
|
+
dispatcher = d;
|
|
172
|
+
health = h;
|
|
173
|
+
}
|
|
174
|
+
function applyWebhookConfig(config) {
|
|
175
|
+
if (!dispatcher) return;
|
|
176
|
+
dispatcher.setConfig(config);
|
|
177
|
+
const shouldWire = config?.enabled === true && (config?.destinations.length ?? 0) > 0;
|
|
178
|
+
if (shouldWire && !wired) {
|
|
179
|
+
const active = dispatcher;
|
|
180
|
+
setWebhookSink((event) => active.emit(event));
|
|
181
|
+
if (health) {
|
|
182
|
+
unsubscribers.push(
|
|
183
|
+
health.onRecovered(
|
|
184
|
+
(e) => active.emit({ kind: "account.recovery", at: e.at, providerId: e.providerId, accountId: e.accountId })
|
|
185
|
+
)
|
|
186
|
+
);
|
|
187
|
+
unsubscribers.push(
|
|
188
|
+
health.onAnomaly(
|
|
189
|
+
(e) => active.emit({
|
|
190
|
+
kind: "account.anomaly",
|
|
191
|
+
at: e.at,
|
|
192
|
+
providerId: e.providerId,
|
|
193
|
+
accountId: e.accountId,
|
|
194
|
+
state: e.state
|
|
195
|
+
})
|
|
196
|
+
)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
wired = true;
|
|
200
|
+
} else if (!shouldWire && wired) {
|
|
201
|
+
teardown();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function deliverWebhookTest(destinationId) {
|
|
205
|
+
if (!dispatcher) return { ok: false, error: "webhook dispatcher not wired" };
|
|
206
|
+
return dispatcher.deliverTest(destinationId);
|
|
207
|
+
}
|
|
208
|
+
function teardown() {
|
|
209
|
+
setWebhookSink(null);
|
|
210
|
+
for (const unsub of unsubscribers) unsub();
|
|
211
|
+
unsubscribers = [];
|
|
212
|
+
wired = false;
|
|
213
|
+
}
|
|
214
|
+
function resetWebhookRuntimeForTests() {
|
|
215
|
+
if (wired) teardown();
|
|
216
|
+
dispatcher = null;
|
|
217
|
+
health = null;
|
|
218
|
+
unsubscribers = [];
|
|
219
|
+
wired = false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// src/admin/webhookTestApi.ts
|
|
223
|
+
function readJsonBody(req) {
|
|
224
|
+
return new Promise((resolve) => {
|
|
225
|
+
const chunks = [];
|
|
226
|
+
req.on("data", (c) => chunks.push(c));
|
|
227
|
+
req.on("end", () => {
|
|
228
|
+
try {
|
|
229
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
230
|
+
const parsed = raw ? JSON.parse(raw) : {};
|
|
231
|
+
resolve(parsed && typeof parsed === "object" ? parsed : {});
|
|
232
|
+
} catch {
|
|
233
|
+
resolve({});
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
req.on("error", () => resolve({}));
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
async function handleWebhookTest(req, res) {
|
|
240
|
+
const body = await readJsonBody(req);
|
|
241
|
+
const destinationId = body["destinationId"];
|
|
242
|
+
if (typeof destinationId !== "string" || !destinationId.trim()) {
|
|
243
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
244
|
+
res.end(JSON.stringify({ error: { type: "bad_request", message: "destinationId is required" } }));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const result = await deliverWebhookTest(destinationId.trim());
|
|
248
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
249
|
+
res.end(JSON.stringify({ result }));
|
|
250
|
+
}
|
|
116
251
|
|
|
117
252
|
// src/admin/adminApi.ts
|
|
118
253
|
import http from "http";
|
|
119
254
|
import {
|
|
120
255
|
createNamedKey,
|
|
121
|
-
|
|
256
|
+
isKindMappedEndpoint,
|
|
257
|
+
loadServerConfig as loadServerConfig2,
|
|
122
258
|
mergeServerConfig,
|
|
123
|
-
|
|
259
|
+
normalizeProxyConfig,
|
|
260
|
+
saveServerConfig,
|
|
261
|
+
validateServerModelConfig
|
|
124
262
|
} from "@omnicross/core/outbound-api";
|
|
263
|
+
import { fetchUpstream } from "@omnicross/core/pipeline/upstreamFetch";
|
|
125
264
|
|
|
126
265
|
// src/config.ts
|
|
127
266
|
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
@@ -317,6 +456,70 @@ var SecretBox = class {
|
|
|
317
456
|
};
|
|
318
457
|
|
|
319
458
|
// src/secrets/secretFields.ts
|
|
459
|
+
function urlHasInlineCredential(url) {
|
|
460
|
+
try {
|
|
461
|
+
const u = new URL(url);
|
|
462
|
+
return u.username.length > 0 || u.password.length > 0;
|
|
463
|
+
} catch {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function transformProxyConfig(cfg, fn) {
|
|
468
|
+
if ("url" in cfg) {
|
|
469
|
+
if (isEnvelope(cfg.url) || urlHasInlineCredential(cfg.url)) {
|
|
470
|
+
return { url: fn(cfg.url) };
|
|
471
|
+
}
|
|
472
|
+
return cfg;
|
|
473
|
+
}
|
|
474
|
+
if (typeof cfg.password === "string" && cfg.password.length > 0) {
|
|
475
|
+
return { ...cfg, password: fn(cfg.password) };
|
|
476
|
+
}
|
|
477
|
+
return cfg;
|
|
478
|
+
}
|
|
479
|
+
function transformOutboundProxy(proxy, fn) {
|
|
480
|
+
const next = {};
|
|
481
|
+
if (proxy.global) next.global = transformProxyConfig(proxy.global, fn);
|
|
482
|
+
if (proxy.byProvider) {
|
|
483
|
+
const byProvider = {};
|
|
484
|
+
for (const [key, value] of Object.entries(proxy.byProvider)) {
|
|
485
|
+
byProvider[key] = transformProxyConfig(value, fn);
|
|
486
|
+
}
|
|
487
|
+
next.byProvider = byProvider;
|
|
488
|
+
}
|
|
489
|
+
return next;
|
|
490
|
+
}
|
|
491
|
+
function encryptProxySegment(proxy, box) {
|
|
492
|
+
return transformOutboundProxy(proxy, (v) => box.encryptMaybe(v));
|
|
493
|
+
}
|
|
494
|
+
function decryptProxySegment(proxy, box) {
|
|
495
|
+
return transformOutboundProxy(proxy, (v) => box.decryptMaybe(v));
|
|
496
|
+
}
|
|
497
|
+
function transformWebhookSegment(webhook, fn) {
|
|
498
|
+
return {
|
|
499
|
+
...webhook,
|
|
500
|
+
destinations: webhook.destinations.map(
|
|
501
|
+
(d) => typeof d.secret === "string" && d.secret.length > 0 ? { ...d, secret: fn(d.secret) } : d
|
|
502
|
+
)
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
function encryptWebhookSegment(webhook, box) {
|
|
506
|
+
return transformWebhookSegment(webhook, (v) => box.encryptMaybe(v));
|
|
507
|
+
}
|
|
508
|
+
function decryptWebhookSegment(webhook, box) {
|
|
509
|
+
return transformWebhookSegment(webhook, (v) => box.decryptMaybe(v));
|
|
510
|
+
}
|
|
511
|
+
function transformBillingSegment(billing, fn) {
|
|
512
|
+
if (typeof billing.secret === "string" && billing.secret.length > 0) {
|
|
513
|
+
return { ...billing, secret: fn(billing.secret) };
|
|
514
|
+
}
|
|
515
|
+
return billing;
|
|
516
|
+
}
|
|
517
|
+
function encryptBillingSegment(billing, box) {
|
|
518
|
+
return transformBillingSegment(billing, (v) => box.encryptMaybe(v));
|
|
519
|
+
}
|
|
520
|
+
function decryptBillingSegment(billing, box) {
|
|
521
|
+
return transformBillingSegment(billing, (v) => box.decryptMaybe(v));
|
|
522
|
+
}
|
|
320
523
|
function transformProvider(provider, fn) {
|
|
321
524
|
const next = { ...provider, apiKey: fn(provider.apiKey) };
|
|
322
525
|
if (provider.apiKeys) {
|
|
@@ -340,6 +543,17 @@ function transformConfigSecrets(cfg, fn) {
|
|
|
340
543
|
if (cfg.admin && typeof cfg.admin.token === "string" && cfg.admin.token.length > 0) {
|
|
341
544
|
next.admin = { ...cfg.admin, token: fn(cfg.admin.token) };
|
|
342
545
|
}
|
|
546
|
+
const proxy = cfg.server?.proxy;
|
|
547
|
+
const webhook = cfg.server?.webhook;
|
|
548
|
+
const billing = cfg.server?.billing;
|
|
549
|
+
if (cfg.server && (proxy?.global || proxy?.byProvider || webhook || billing?.secret)) {
|
|
550
|
+
next.server = { ...cfg.server };
|
|
551
|
+
if (proxy && (proxy.global || proxy.byProvider)) {
|
|
552
|
+
next.server.proxy = transformOutboundProxy(proxy, fn);
|
|
553
|
+
}
|
|
554
|
+
if (webhook) next.server.webhook = transformWebhookSegment(webhook, fn);
|
|
555
|
+
if (billing?.secret) next.server.billing = transformBillingSegment(billing, fn);
|
|
556
|
+
}
|
|
343
557
|
return next;
|
|
344
558
|
}
|
|
345
559
|
function encryptConfigSecrets(cfg, box) {
|
|
@@ -377,7 +591,7 @@ function transformTokens(tokens, fn) {
|
|
|
377
591
|
if (Array.isArray(accounts)) {
|
|
378
592
|
bag[accountsKey] = accounts.map((entry) => {
|
|
379
593
|
if (entry && typeof entry === "object" && "tokens" in entry && entry.tokens && typeof entry.tokens === "object") {
|
|
380
|
-
|
|
594
|
+
const nextEntry = {
|
|
381
595
|
...entry,
|
|
382
596
|
tokens: transformTokenBlock(
|
|
383
597
|
entry.tokens,
|
|
@@ -385,6 +599,11 @@ function transformTokens(tokens, fn) {
|
|
|
385
599
|
fn
|
|
386
600
|
)
|
|
387
601
|
};
|
|
602
|
+
const proxy = entry.proxy;
|
|
603
|
+
if (proxy && typeof proxy === "object") {
|
|
604
|
+
nextEntry.proxy = transformProxyConfig(proxy, fn);
|
|
605
|
+
}
|
|
606
|
+
return nextEntry;
|
|
388
607
|
}
|
|
389
608
|
return entry;
|
|
390
609
|
});
|
|
@@ -419,6 +638,17 @@ function resolveAdminConfig(admin) {
|
|
|
419
638
|
token: typeof admin?.token === "string" && admin.token.length > 0 ? admin.token : void 0
|
|
420
639
|
};
|
|
421
640
|
}
|
|
641
|
+
function validateLogging(raw) {
|
|
642
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
643
|
+
const l = raw;
|
|
644
|
+
const out = {};
|
|
645
|
+
if (l["level"] === "error" || l["level"] === "warn" || l["level"] === "info" || l["level"] === "debug") {
|
|
646
|
+
out.level = l["level"];
|
|
647
|
+
}
|
|
648
|
+
if (l["format"] === "text" || l["format"] === "json") out.format = l["format"];
|
|
649
|
+
if (typeof l["file"] === "string" && l["file"].length > 0) out.file = l["file"];
|
|
650
|
+
return out.level !== void 0 || out.format !== void 0 || out.file !== void 0 ? out : void 0;
|
|
651
|
+
}
|
|
422
652
|
var VALID_FORMATS = ["openai", "anthropic", "gemini"];
|
|
423
653
|
function validateApiKeys(raw) {
|
|
424
654
|
if (!Array.isArray(raw)) return void 0;
|
|
@@ -593,7 +823,8 @@ function validateConfig(raw) {
|
|
|
593
823
|
const providers = providersRaw.map((p, i) => validateProvider(p, i));
|
|
594
824
|
const server = obj["server"];
|
|
595
825
|
const admin = validateAdmin(obj["admin"]);
|
|
596
|
-
|
|
826
|
+
const logging = validateLogging(obj["logging"]);
|
|
827
|
+
return { providers, server, admin, logging };
|
|
597
828
|
}
|
|
598
829
|
var secretBox = null;
|
|
599
830
|
function setSecretBox(box) {
|
|
@@ -693,6 +924,163 @@ function listMappablePresets() {
|
|
|
693
924
|
return { mappable, excluded };
|
|
694
925
|
}
|
|
695
926
|
|
|
927
|
+
// src/proxy/sanitizeProxy.ts
|
|
928
|
+
function sanitizeProxyConfig(cfg) {
|
|
929
|
+
if ("url" in cfg) {
|
|
930
|
+
let endpoint;
|
|
931
|
+
let username;
|
|
932
|
+
let hasPassword = false;
|
|
933
|
+
try {
|
|
934
|
+
const u = new URL(cfg.url);
|
|
935
|
+
endpoint = u.port ? `${u.hostname}:${u.port}` : u.hostname;
|
|
936
|
+
username = u.username ? decodeURIComponent(u.username) : void 0;
|
|
937
|
+
hasPassword = u.password.length > 0;
|
|
938
|
+
} catch {
|
|
939
|
+
}
|
|
940
|
+
return { kind: "url", endpoint, username, hasPassword };
|
|
941
|
+
}
|
|
942
|
+
return {
|
|
943
|
+
kind: cfg.type,
|
|
944
|
+
endpoint: `${cfg.host}:${cfg.port}`,
|
|
945
|
+
username: cfg.username,
|
|
946
|
+
hasPassword: typeof cfg.password === "string" && cfg.password.length > 0
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
function redactProxyConfig(cfg) {
|
|
950
|
+
if ("url" in cfg) {
|
|
951
|
+
try {
|
|
952
|
+
const u = new URL(cfg.url);
|
|
953
|
+
if (u.password) u.password = "";
|
|
954
|
+
return { url: u.toString() };
|
|
955
|
+
} catch {
|
|
956
|
+
return cfg;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
const { password: _password, ...rest } = cfg;
|
|
960
|
+
return rest;
|
|
961
|
+
}
|
|
962
|
+
function redactOutboundProxy(proxy) {
|
|
963
|
+
const out = {};
|
|
964
|
+
if (proxy.global) out.global = redactProxyConfig(proxy.global);
|
|
965
|
+
if (proxy.byProvider) {
|
|
966
|
+
const byProvider = {};
|
|
967
|
+
for (const [key, value] of Object.entries(proxy.byProvider)) {
|
|
968
|
+
byProvider[key] = redactProxyConfig(value);
|
|
969
|
+
}
|
|
970
|
+
out.byProvider = byProvider;
|
|
971
|
+
}
|
|
972
|
+
return out;
|
|
973
|
+
}
|
|
974
|
+
function preserveProxyConfigSecret(incoming, current) {
|
|
975
|
+
if (!current) return incoming;
|
|
976
|
+
if ("url" in incoming) {
|
|
977
|
+
if ("url" in current) {
|
|
978
|
+
try {
|
|
979
|
+
const inU = new URL(incoming.url);
|
|
980
|
+
const curU = new URL(current.url);
|
|
981
|
+
if (!inU.password && curU.password) {
|
|
982
|
+
inU.password = curU.password;
|
|
983
|
+
return { url: inU.toString() };
|
|
984
|
+
}
|
|
985
|
+
} catch {
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return incoming;
|
|
989
|
+
}
|
|
990
|
+
if ("url" in current) return incoming;
|
|
991
|
+
const blank = incoming.password === void 0 || incoming.password === "";
|
|
992
|
+
if (blank && typeof current.password === "string" && current.password.length > 0) {
|
|
993
|
+
return { ...incoming, password: current.password };
|
|
994
|
+
}
|
|
995
|
+
return incoming;
|
|
996
|
+
}
|
|
997
|
+
function preserveOutboundProxySecrets(incoming, current) {
|
|
998
|
+
const out = {};
|
|
999
|
+
if (incoming.global) out.global = preserveProxyConfigSecret(incoming.global, current?.global);
|
|
1000
|
+
if (incoming.byProvider) {
|
|
1001
|
+
const byProvider = {};
|
|
1002
|
+
for (const [key, value] of Object.entries(incoming.byProvider)) {
|
|
1003
|
+
byProvider[key] = preserveProxyConfigSecret(value, current?.byProvider?.[key]);
|
|
1004
|
+
}
|
|
1005
|
+
out.byProvider = byProvider;
|
|
1006
|
+
}
|
|
1007
|
+
return out;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// src/proxy/upstreamProxyResolver.ts
|
|
1011
|
+
import {
|
|
1012
|
+
bumpUpstreamProxyGeneration
|
|
1013
|
+
} from "@omnicross/core/pipeline/upstreamFetch";
|
|
1014
|
+
var serverProxy;
|
|
1015
|
+
function setServerProxyConfig(proxy) {
|
|
1016
|
+
serverProxy = proxy;
|
|
1017
|
+
bumpUpstreamProxyGeneration();
|
|
1018
|
+
}
|
|
1019
|
+
function getServerProxyConfig() {
|
|
1020
|
+
return serverProxy;
|
|
1021
|
+
}
|
|
1022
|
+
var envProxyLoggedFor;
|
|
1023
|
+
function maskProxyUrl(url) {
|
|
1024
|
+
return url.replace(/\/\/[^/@]*@/, "//***@");
|
|
1025
|
+
}
|
|
1026
|
+
function hostFromCtx(ctx) {
|
|
1027
|
+
if (!ctx.url) return void 0;
|
|
1028
|
+
try {
|
|
1029
|
+
return new URL(ctx.url).hostname.toLowerCase();
|
|
1030
|
+
} catch {
|
|
1031
|
+
return void 0;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
function isLoopbackHost(host) {
|
|
1035
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]" || host.startsWith("127.") || host.endsWith(".localhost");
|
|
1036
|
+
}
|
|
1037
|
+
function noProxyMatches(noProxy, host) {
|
|
1038
|
+
if (!noProxy) return false;
|
|
1039
|
+
for (const raw of noProxy.split(",")) {
|
|
1040
|
+
const entry = raw.trim().toLowerCase();
|
|
1041
|
+
if (!entry) continue;
|
|
1042
|
+
if (entry === "*") return true;
|
|
1043
|
+
const bare = entry.startsWith(".") ? entry.slice(1) : entry;
|
|
1044
|
+
if (host === bare || host.endsWith(`.${bare}`)) return true;
|
|
1045
|
+
}
|
|
1046
|
+
return false;
|
|
1047
|
+
}
|
|
1048
|
+
function resolveEnvProxy(ctx, env = process.env) {
|
|
1049
|
+
const raw = env.HTTPS_PROXY ?? env.https_proxy ?? env.ALL_PROXY ?? env.all_proxy ?? env.HTTP_PROXY ?? env.http_proxy;
|
|
1050
|
+
if (!raw || !raw.trim()) return void 0;
|
|
1051
|
+
const host = hostFromCtx(ctx);
|
|
1052
|
+
if (host && (isLoopbackHost(host) || noProxyMatches(env.NO_PROXY ?? env.no_proxy, host))) {
|
|
1053
|
+
return void 0;
|
|
1054
|
+
}
|
|
1055
|
+
const url = raw.trim();
|
|
1056
|
+
if (envProxyLoggedFor !== url) {
|
|
1057
|
+
envProxyLoggedFor = url;
|
|
1058
|
+
console.info(`[upstream-proxy] routing upstream egress through the environment proxy: ${maskProxyUrl(url)}`);
|
|
1059
|
+
}
|
|
1060
|
+
return { url };
|
|
1061
|
+
}
|
|
1062
|
+
function createUpstreamProxyResolver(src = {}) {
|
|
1063
|
+
const readServer = src.getServerProxy ?? getServerProxyConfig;
|
|
1064
|
+
return (ctx) => {
|
|
1065
|
+
const host = hostFromCtx(ctx);
|
|
1066
|
+
if (host) {
|
|
1067
|
+
if (isLoopbackHost(host)) return void 0;
|
|
1068
|
+
const env = src.env ?? process.env;
|
|
1069
|
+
if (noProxyMatches(env.NO_PROXY ?? env.no_proxy, host)) return void 0;
|
|
1070
|
+
}
|
|
1071
|
+
if (src.getAccountProxy && ctx.providerId && ctx.accountId) {
|
|
1072
|
+
const account = src.getAccountProxy(ctx.providerId, ctx.accountId);
|
|
1073
|
+
if (account) return account;
|
|
1074
|
+
}
|
|
1075
|
+
const server = readServer();
|
|
1076
|
+
if (ctx.providerId && server?.byProvider?.[ctx.providerId]) {
|
|
1077
|
+
return server.byProvider[ctx.providerId];
|
|
1078
|
+
}
|
|
1079
|
+
if (server?.global) return server.global;
|
|
1080
|
+
return resolveEnvProxy(ctx, src.env);
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
|
|
696
1084
|
// src/admin/accountsOAuth.ts
|
|
697
1085
|
import { claudeOAuth, geminiOAuth } from "@omnicross/subscriptions";
|
|
698
1086
|
|
|
@@ -815,6 +1203,24 @@ function validateTokenBody(providerId, body) {
|
|
|
815
1203
|
return null;
|
|
816
1204
|
}
|
|
817
1205
|
}
|
|
1206
|
+
function validateSupportedModelsBody(raw) {
|
|
1207
|
+
if (raw === null || raw === void 0) return { ok: true, value: void 0 };
|
|
1208
|
+
if (Array.isArray(raw)) {
|
|
1209
|
+
if (raw.length === 0) return { ok: false };
|
|
1210
|
+
if (!raw.every((x) => typeof x === "string" && x.trim().length > 0)) return { ok: false };
|
|
1211
|
+
return { ok: true, value: raw };
|
|
1212
|
+
}
|
|
1213
|
+
if (typeof raw === "object") {
|
|
1214
|
+
const entries = Object.entries(raw);
|
|
1215
|
+
if (entries.length === 0) return { ok: false };
|
|
1216
|
+
const valid = entries.every(
|
|
1217
|
+
([k, v]) => k.trim().length > 0 && typeof v === "string" && v.trim().length > 0
|
|
1218
|
+
);
|
|
1219
|
+
if (!valid) return { ok: false };
|
|
1220
|
+
return { ok: true, value: Object.fromEntries(entries) };
|
|
1221
|
+
}
|
|
1222
|
+
return { ok: false };
|
|
1223
|
+
}
|
|
818
1224
|
async function statusEntryFor(reader, providerId) {
|
|
819
1225
|
const all = await reader.listAll();
|
|
820
1226
|
return all.find((a) => a.providerId === providerId) ?? null;
|
|
@@ -1096,6 +1502,448 @@ async function handleCliLaunch(cli, body, ctx) {
|
|
|
1096
1502
|
return { status: 200, body: { sessionId: id, providerId: target.providerId, model: target.model } };
|
|
1097
1503
|
}
|
|
1098
1504
|
|
|
1505
|
+
// src/admin/auditConfigBody.ts
|
|
1506
|
+
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1507
|
+
function validateAuditSegment(patch) {
|
|
1508
|
+
const errors = [];
|
|
1509
|
+
const audit = patch.audit;
|
|
1510
|
+
if (audit === void 0) return errors;
|
|
1511
|
+
if (!isPlainObject(audit)) {
|
|
1512
|
+
errors.push("audit must be an object");
|
|
1513
|
+
return errors;
|
|
1514
|
+
}
|
|
1515
|
+
for (const flag of ["enabled", "captureBodies", "trustForwardedFor"]) {
|
|
1516
|
+
if (audit[flag] !== void 0 && typeof audit[flag] !== "boolean") {
|
|
1517
|
+
errors.push(`audit.${flag} must be a boolean`);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
const maxBodyBytes = audit["maxBodyBytes"];
|
|
1521
|
+
if (maxBodyBytes !== void 0 && (typeof maxBodyBytes !== "number" || !Number.isFinite(maxBodyBytes) || maxBodyBytes < 0)) {
|
|
1522
|
+
errors.push("audit.maxBodyBytes must be a non-negative number");
|
|
1523
|
+
}
|
|
1524
|
+
const retentionDays = audit["retentionDays"];
|
|
1525
|
+
if (retentionDays !== void 0 && (typeof retentionDays !== "number" || !Number.isFinite(retentionDays) || retentionDays < 0)) {
|
|
1526
|
+
errors.push("audit.retentionDays must be a non-negative number");
|
|
1527
|
+
}
|
|
1528
|
+
return errors;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// src/admin/billingConfigBody.ts
|
|
1532
|
+
var BILLING_SECRET_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
1533
|
+
var isPlainObject2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1534
|
+
function validateBillingSegment(patch) {
|
|
1535
|
+
const errors = [];
|
|
1536
|
+
const billing = patch.billing;
|
|
1537
|
+
if (billing === void 0) return errors;
|
|
1538
|
+
if (!isPlainObject2(billing)) {
|
|
1539
|
+
errors.push("billing must be an object");
|
|
1540
|
+
return errors;
|
|
1541
|
+
}
|
|
1542
|
+
if (billing["enabled"] !== void 0 && typeof billing["enabled"] !== "boolean") {
|
|
1543
|
+
errors.push("billing.enabled must be a boolean");
|
|
1544
|
+
}
|
|
1545
|
+
if (billing["endpoint"] !== void 0 && typeof billing["endpoint"] !== "string") {
|
|
1546
|
+
errors.push("billing.endpoint must be a string");
|
|
1547
|
+
}
|
|
1548
|
+
if (billing["secret"] !== void 0 && typeof billing["secret"] !== "string") {
|
|
1549
|
+
errors.push("billing.secret must be a string");
|
|
1550
|
+
}
|
|
1551
|
+
const maxRetryAgeMs = billing["maxRetryAgeMs"];
|
|
1552
|
+
if (maxRetryAgeMs !== void 0 && (typeof maxRetryAgeMs !== "number" || !Number.isFinite(maxRetryAgeMs) || maxRetryAgeMs < 0)) {
|
|
1553
|
+
errors.push("billing.maxRetryAgeMs must be a non-negative number");
|
|
1554
|
+
}
|
|
1555
|
+
return errors;
|
|
1556
|
+
}
|
|
1557
|
+
function redactBillingConfig(billing) {
|
|
1558
|
+
if (typeof billing.secret === "string" && billing.secret.length > 0) {
|
|
1559
|
+
return { ...billing, secret: BILLING_SECRET_MASK };
|
|
1560
|
+
}
|
|
1561
|
+
return billing;
|
|
1562
|
+
}
|
|
1563
|
+
function preserveBillingSecret(incoming, current) {
|
|
1564
|
+
const isMaskedOrBlank = incoming.secret === void 0 || incoming.secret === "" || incoming.secret === BILLING_SECRET_MASK;
|
|
1565
|
+
if (isMaskedOrBlank) {
|
|
1566
|
+
if (current?.secret) return { ...incoming, secret: current.secret };
|
|
1567
|
+
const { secret: _secret, ...rest } = incoming;
|
|
1568
|
+
return rest;
|
|
1569
|
+
}
|
|
1570
|
+
return incoming;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// src/admin/dashboard.ts
|
|
1574
|
+
function startOfLocalDayMs(ts) {
|
|
1575
|
+
const d = new Date(ts);
|
|
1576
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
1577
|
+
}
|
|
1578
|
+
function accountProviderId(entry) {
|
|
1579
|
+
if (!entry || typeof entry !== "object") return null;
|
|
1580
|
+
const e = entry;
|
|
1581
|
+
if (typeof e["providerId"] === "string" && e["providerId"]) return e["providerId"];
|
|
1582
|
+
if (typeof e["provider"] === "string" && e["provider"]) return e["provider"];
|
|
1583
|
+
return null;
|
|
1584
|
+
}
|
|
1585
|
+
async function handleDashboard(deps) {
|
|
1586
|
+
const now = Date.now();
|
|
1587
|
+
const today = await deps.usageRecorder.getTotals({ startTs: startOfLocalDayMs(now), endTs: now });
|
|
1588
|
+
const total = await deps.usageRecorder.getTotals({ startTs: 0, endTs: now });
|
|
1589
|
+
const providerList = loadConfig(deps.configPath).providers;
|
|
1590
|
+
const providers = {
|
|
1591
|
+
total: providerList.length,
|
|
1592
|
+
enabled: providerList.filter((p) => p.enabled !== false).length
|
|
1593
|
+
};
|
|
1594
|
+
const keys = await deps.keyDb.outboundApiKeysList();
|
|
1595
|
+
const outboundKeys = {
|
|
1596
|
+
total: keys.length,
|
|
1597
|
+
active: keys.filter((k) => k.enabled && k.revokedAt === null).length
|
|
1598
|
+
};
|
|
1599
|
+
const accountsList = await deps.subscriptionAccounts.listAll();
|
|
1600
|
+
const byProvider = {};
|
|
1601
|
+
for (const entry of accountsList) {
|
|
1602
|
+
const providerId = accountProviderId(entry);
|
|
1603
|
+
if (providerId) byProvider[providerId] = (byProvider[providerId] ?? 0) + 1;
|
|
1604
|
+
}
|
|
1605
|
+
const accounts = { total: accountsList.length, byProvider };
|
|
1606
|
+
const status = deps.outboundApiServer.getStatus();
|
|
1607
|
+
const server = {
|
|
1608
|
+
running: status.running,
|
|
1609
|
+
port: status.port,
|
|
1610
|
+
uptimeMs: Math.round(process.uptime() * 1e3)
|
|
1611
|
+
};
|
|
1612
|
+
const summary = { today, total, providers, outboundKeys, accounts, server, generatedAt: now };
|
|
1613
|
+
return { status: 200, body: summary };
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// src/admin/keyPolicyBody.ts
|
|
1617
|
+
function parseKeyPolicyBody(body) {
|
|
1618
|
+
const policy = {};
|
|
1619
|
+
if ("activationMode" in body) {
|
|
1620
|
+
const m = body["activationMode"];
|
|
1621
|
+
if (m === null) policy.activationMode = null;
|
|
1622
|
+
else if (m === "fixed" || m === "activation") policy.activationMode = m;
|
|
1623
|
+
else return { ok: false, message: "activationMode must be 'fixed', 'activation', or null" };
|
|
1624
|
+
}
|
|
1625
|
+
const numericFields = [
|
|
1626
|
+
{ key: "expiresAt", min: 0 },
|
|
1627
|
+
{ key: "activationDays", min: 1, integer: true },
|
|
1628
|
+
{ key: "dailyCostLimitUsd", min: 0 },
|
|
1629
|
+
{ key: "totalCostLimitUsd", min: 0 },
|
|
1630
|
+
{ key: "weeklyCostLimitUsd", min: 0 },
|
|
1631
|
+
{ key: "rateLimitMaxRequests", min: 0, integer: true },
|
|
1632
|
+
{ key: "rateLimitWindowMs", min: 1 }
|
|
1633
|
+
];
|
|
1634
|
+
for (const { key, min, integer } of numericFields) {
|
|
1635
|
+
if (!(key in body)) continue;
|
|
1636
|
+
const v = body[key];
|
|
1637
|
+
if (v === null) {
|
|
1638
|
+
policy[key] = null;
|
|
1639
|
+
continue;
|
|
1640
|
+
}
|
|
1641
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < min || integer && !Number.isInteger(v)) {
|
|
1642
|
+
return {
|
|
1643
|
+
ok: false,
|
|
1644
|
+
message: `${key} must be ${integer ? "an integer" : "a number"} >= ${min} or null`
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
policy[key] = v;
|
|
1648
|
+
}
|
|
1649
|
+
if ("enableModelRestriction" in body) {
|
|
1650
|
+
const v = body["enableModelRestriction"];
|
|
1651
|
+
if (v === null) policy.enableModelRestriction = null;
|
|
1652
|
+
else if (typeof v === "boolean") policy.enableModelRestriction = v;
|
|
1653
|
+
else return { ok: false, message: "enableModelRestriction must be a boolean or null" };
|
|
1654
|
+
}
|
|
1655
|
+
if ("restrictionMode" in body) {
|
|
1656
|
+
const v = body["restrictionMode"];
|
|
1657
|
+
if (v === null) policy.restrictionMode = null;
|
|
1658
|
+
else if (v === "blacklist" || v === "allowlist") policy.restrictionMode = v;
|
|
1659
|
+
else return { ok: false, message: "restrictionMode must be 'blacklist', 'allowlist', or null" };
|
|
1660
|
+
}
|
|
1661
|
+
if ("restrictedModels" in body) {
|
|
1662
|
+
const v = body["restrictedModels"];
|
|
1663
|
+
if (v === null) {
|
|
1664
|
+
policy.restrictedModels = null;
|
|
1665
|
+
} else if (Array.isArray(v) && v.every((e) => typeof e === "string")) {
|
|
1666
|
+
policy.restrictedModels = v.map((e) => e.trim()).filter((e) => e !== "");
|
|
1667
|
+
} else {
|
|
1668
|
+
return { ok: false, message: "restrictedModels must be an array of strings or null" };
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
return { ok: true, policy };
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// src/admin/voucherAdmin.ts
|
|
1675
|
+
import {
|
|
1676
|
+
generateVoucherCode,
|
|
1677
|
+
hashVoucherCode,
|
|
1678
|
+
loadServerConfig,
|
|
1679
|
+
newVoucherId,
|
|
1680
|
+
toVoucherInfo,
|
|
1681
|
+
voucherCodePrefix
|
|
1682
|
+
} from "@omnicross/core/outbound-api";
|
|
1683
|
+
function writeJson(res, status, body) {
|
|
1684
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1685
|
+
res.end(JSON.stringify(body));
|
|
1686
|
+
}
|
|
1687
|
+
function writeErr(res, status, message) {
|
|
1688
|
+
writeJson(res, status, { error: { type: "voucher_error", message } });
|
|
1689
|
+
}
|
|
1690
|
+
function readJsonBody2(req) {
|
|
1691
|
+
return new Promise((resolve, reject) => {
|
|
1692
|
+
const chunks = [];
|
|
1693
|
+
req.on("data", (c) => chunks.push(c));
|
|
1694
|
+
req.on("end", () => {
|
|
1695
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1696
|
+
if (!raw.trim()) return resolve({});
|
|
1697
|
+
try {
|
|
1698
|
+
const parsed = JSON.parse(raw);
|
|
1699
|
+
resolve(parsed && typeof parsed === "object" ? parsed : {});
|
|
1700
|
+
} catch {
|
|
1701
|
+
reject(new Error("invalid-json"));
|
|
1702
|
+
}
|
|
1703
|
+
});
|
|
1704
|
+
req.on("error", reject);
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
function optPositive(value, integer) {
|
|
1708
|
+
if (value === void 0 || value === null) return { ok: true, value: void 0 };
|
|
1709
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return { ok: false };
|
|
1710
|
+
if (integer && !Number.isInteger(value)) return { ok: false };
|
|
1711
|
+
return { ok: true, value };
|
|
1712
|
+
}
|
|
1713
|
+
function parseVoucherCreateBody(body) {
|
|
1714
|
+
const type = body["type"];
|
|
1715
|
+
if (type !== "credit" && type !== "renewal") {
|
|
1716
|
+
return { ok: false, message: "type must be 'credit' or 'renewal'" };
|
|
1717
|
+
}
|
|
1718
|
+
const maxTotal = optPositive(body["maxTotalCostLimitUsd"], false);
|
|
1719
|
+
if (!maxTotal.ok) return { ok: false, message: "maxTotalCostLimitUsd must be a positive number" };
|
|
1720
|
+
const maxDays = optPositive(body["maxExpiryDays"], true);
|
|
1721
|
+
if (!maxDays.ok) return { ok: false, message: "maxExpiryDays must be a positive integer" };
|
|
1722
|
+
const input = { type };
|
|
1723
|
+
if (maxTotal.value !== void 0) input.maxTotalCostLimitUsd = maxTotal.value;
|
|
1724
|
+
if (maxDays.value !== void 0) input.maxExpiryDays = maxDays.value;
|
|
1725
|
+
if (type === "credit") {
|
|
1726
|
+
const credit = optPositive(body["creditUsd"], false);
|
|
1727
|
+
if (!credit.ok || credit.value === void 0) {
|
|
1728
|
+
return { ok: false, message: "creditUsd must be a positive number for a credit card" };
|
|
1729
|
+
}
|
|
1730
|
+
input.creditUsd = credit.value;
|
|
1731
|
+
} else {
|
|
1732
|
+
const days = optPositive(body["renewalDays"], true);
|
|
1733
|
+
if (!days.ok || days.value === void 0) {
|
|
1734
|
+
return { ok: false, message: "renewalDays must be a positive integer for a renewal card" };
|
|
1735
|
+
}
|
|
1736
|
+
input.renewalDays = days.value;
|
|
1737
|
+
}
|
|
1738
|
+
return { ok: true, input };
|
|
1739
|
+
}
|
|
1740
|
+
async function voucherEnabled(deps) {
|
|
1741
|
+
const config = await loadServerConfig(deps.settingsStore);
|
|
1742
|
+
return config.voucher?.enabled === true;
|
|
1743
|
+
}
|
|
1744
|
+
async function handleVoucher(req, res, method, rest, deps) {
|
|
1745
|
+
const voucherDb = deps.voucherDb;
|
|
1746
|
+
if (!voucherDb) return writeErr(res, 501, "Voucher feature is not available");
|
|
1747
|
+
if (method === "GET" && rest.length === 0) {
|
|
1748
|
+
const rows = await voucherDb.voucherList();
|
|
1749
|
+
return writeJson(res, 200, { vouchers: rows.map(toVoucherInfo) });
|
|
1750
|
+
}
|
|
1751
|
+
if (method === "POST" && rest.length === 0) {
|
|
1752
|
+
if (!await voucherEnabled(deps)) return writeErr(res, 403, "Voucher feature is disabled");
|
|
1753
|
+
let body;
|
|
1754
|
+
try {
|
|
1755
|
+
body = await readJsonBody2(req);
|
|
1756
|
+
} catch {
|
|
1757
|
+
return writeErr(res, 400, "Invalid JSON in request body");
|
|
1758
|
+
}
|
|
1759
|
+
const parsed = parseVoucherCreateBody(body);
|
|
1760
|
+
if (!parsed.ok) return writeErr(res, 400, parsed.message);
|
|
1761
|
+
const code = generateVoucherCode();
|
|
1762
|
+
const created = await voucherDb.voucherCreate({
|
|
1763
|
+
id: newVoucherId(),
|
|
1764
|
+
codeHash: hashVoucherCode(code),
|
|
1765
|
+
codePrefix: voucherCodePrefix(code),
|
|
1766
|
+
...parsed.input
|
|
1767
|
+
});
|
|
1768
|
+
return writeJson(res, 201, {
|
|
1769
|
+
id: created.id,
|
|
1770
|
+
codePrefix: created.codePrefix,
|
|
1771
|
+
type: created.type,
|
|
1772
|
+
createdAt: created.createdAt,
|
|
1773
|
+
// `plaintextOnce` is the ONLY place the full code crosses the wire (D3).
|
|
1774
|
+
plaintextOnce: code
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
const id = rest[0];
|
|
1778
|
+
if (method === "POST" && id && rest[1] === "revoke") {
|
|
1779
|
+
if (!await voucherEnabled(deps)) return writeErr(res, 403, "Voucher feature is disabled");
|
|
1780
|
+
const ok = await voucherDb.voucherRevokeCas(id, Date.now());
|
|
1781
|
+
return writeJson(res, ok ? 200 : 409, { ok });
|
|
1782
|
+
}
|
|
1783
|
+
return writeErr(res, 405, `method ${method} not allowed on voucher`);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// src/admin/webhookConfigBody.ts
|
|
1787
|
+
import {
|
|
1788
|
+
WEBHOOK_DESTINATION_TYPES,
|
|
1789
|
+
WEBHOOK_EVENT_KINDS
|
|
1790
|
+
} from "@omnicross/contracts/webhook-types";
|
|
1791
|
+
var WEBHOOK_SECRET_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
1792
|
+
var isPlainObject3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1793
|
+
function validateWebhookSegment(patch) {
|
|
1794
|
+
const errors = [];
|
|
1795
|
+
const webhook = patch.webhook;
|
|
1796
|
+
if (webhook === void 0) return errors;
|
|
1797
|
+
if (!isPlainObject3(webhook)) {
|
|
1798
|
+
errors.push("webhook must be an object");
|
|
1799
|
+
return errors;
|
|
1800
|
+
}
|
|
1801
|
+
if (typeof webhook["enabled"] !== "boolean") {
|
|
1802
|
+
errors.push("webhook.enabled must be a boolean");
|
|
1803
|
+
}
|
|
1804
|
+
const destinations = webhook["destinations"];
|
|
1805
|
+
if (destinations !== void 0 && !Array.isArray(destinations)) {
|
|
1806
|
+
errors.push("webhook.destinations must be an array");
|
|
1807
|
+
return errors;
|
|
1808
|
+
}
|
|
1809
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
1810
|
+
for (const [i, raw] of (Array.isArray(destinations) ? destinations : []).entries()) {
|
|
1811
|
+
if (!isPlainObject3(raw)) {
|
|
1812
|
+
errors.push(`webhook.destinations[${i}] must be an object`);
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1815
|
+
const id = raw["id"];
|
|
1816
|
+
if (typeof id !== "string" || !id.trim()) {
|
|
1817
|
+
errors.push(`webhook.destinations[${i}].id must be a non-empty string`);
|
|
1818
|
+
} else if (seenIds.has(id.trim())) {
|
|
1819
|
+
errors.push(`webhook.destinations[${i}].id '${id.trim()}' is duplicated`);
|
|
1820
|
+
} else {
|
|
1821
|
+
seenIds.add(id.trim());
|
|
1822
|
+
}
|
|
1823
|
+
if (typeof raw["type"] !== "string" || !WEBHOOK_DESTINATION_TYPES.includes(raw["type"])) {
|
|
1824
|
+
errors.push(`webhook.destinations[${i}].type must be one of ${WEBHOOK_DESTINATION_TYPES.join(", ")}`);
|
|
1825
|
+
}
|
|
1826
|
+
if (typeof raw["url"] !== "string" || !raw["url"].trim()) {
|
|
1827
|
+
errors.push(`webhook.destinations[${i}].url must be a non-empty string`);
|
|
1828
|
+
}
|
|
1829
|
+
if (raw["secret"] !== void 0 && typeof raw["secret"] !== "string") {
|
|
1830
|
+
errors.push(`webhook.destinations[${i}].secret must be a string`);
|
|
1831
|
+
}
|
|
1832
|
+
if (raw["enabled"] !== void 0 && typeof raw["enabled"] !== "boolean") {
|
|
1833
|
+
errors.push(`webhook.destinations[${i}].enabled must be a boolean`);
|
|
1834
|
+
}
|
|
1835
|
+
const events = raw["events"];
|
|
1836
|
+
if (events !== void 0) {
|
|
1837
|
+
if (!Array.isArray(events)) {
|
|
1838
|
+
errors.push(`webhook.destinations[${i}].events must be an array`);
|
|
1839
|
+
} else {
|
|
1840
|
+
for (const e of events) {
|
|
1841
|
+
if (typeof e !== "string" || !WEBHOOK_EVENT_KINDS.includes(e)) {
|
|
1842
|
+
errors.push(`webhook.destinations[${i}].events contains an unknown kind '${String(e)}'`);
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
return errors;
|
|
1849
|
+
}
|
|
1850
|
+
function redactWebhookConfig(webhook) {
|
|
1851
|
+
return {
|
|
1852
|
+
...webhook,
|
|
1853
|
+
destinations: webhook.destinations.map(
|
|
1854
|
+
(d) => typeof d.secret === "string" && d.secret.length > 0 ? { ...d, secret: WEBHOOK_SECRET_MASK } : d
|
|
1855
|
+
)
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
function preserveWebhookSecrets(incoming, current) {
|
|
1859
|
+
const currentById = /* @__PURE__ */ new Map();
|
|
1860
|
+
for (const d of current?.destinations ?? []) currentById.set(d.id, d);
|
|
1861
|
+
return {
|
|
1862
|
+
...incoming,
|
|
1863
|
+
destinations: incoming.destinations.map((d) => {
|
|
1864
|
+
const isMaskedOrBlank = d.secret === void 0 || d.secret === "" || d.secret === WEBHOOK_SECRET_MASK;
|
|
1865
|
+
if (isMaskedOrBlank) {
|
|
1866
|
+
const prev = currentById.get(d.id);
|
|
1867
|
+
if (prev?.secret) return { ...d, secret: prev.secret };
|
|
1868
|
+
const { secret: _secret, ...rest } = d;
|
|
1869
|
+
return rest;
|
|
1870
|
+
}
|
|
1871
|
+
return d;
|
|
1872
|
+
})
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// src/audit/auditRuntime.ts
|
|
1877
|
+
import { setAuditCaptureConfig, setAuditSink } from "@omnicross/core/pipeline/auditSink";
|
|
1878
|
+
var writer = null;
|
|
1879
|
+
var sweeper = null;
|
|
1880
|
+
function setAuditRuntime(w, s) {
|
|
1881
|
+
writer = w;
|
|
1882
|
+
sweeper = s;
|
|
1883
|
+
}
|
|
1884
|
+
function applyAuditConfig(config) {
|
|
1885
|
+
const enabled = config?.enabled === true && writer !== null;
|
|
1886
|
+
if (enabled && config) {
|
|
1887
|
+
setAuditCaptureConfig(config);
|
|
1888
|
+
const activeWriter = writer;
|
|
1889
|
+
setAuditSink((record) => activeWriter.record(record));
|
|
1890
|
+
if (sweeper) {
|
|
1891
|
+
sweeper.configure(config);
|
|
1892
|
+
sweeper.start();
|
|
1893
|
+
}
|
|
1894
|
+
} else {
|
|
1895
|
+
setAuditCaptureConfig(null);
|
|
1896
|
+
setAuditSink(null);
|
|
1897
|
+
if (sweeper) {
|
|
1898
|
+
if (config) sweeper.configure(config);
|
|
1899
|
+
sweeper.dispose();
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
function resetAuditRuntimeForTests() {
|
|
1904
|
+
setAuditCaptureConfig(null);
|
|
1905
|
+
setAuditSink(null);
|
|
1906
|
+
if (sweeper) sweeper.dispose();
|
|
1907
|
+
writer = null;
|
|
1908
|
+
sweeper = null;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// src/billing/billingRuntime.ts
|
|
1912
|
+
import { setBillingCaptureConfig, setBillingSink } from "@omnicross/core/pipeline/billingEmit";
|
|
1913
|
+
var publisher = null;
|
|
1914
|
+
var sweeper2 = null;
|
|
1915
|
+
function setBillingRuntime(p, s) {
|
|
1916
|
+
publisher = p;
|
|
1917
|
+
sweeper2 = s;
|
|
1918
|
+
}
|
|
1919
|
+
function applyBillingConfig(config) {
|
|
1920
|
+
const enabled = config?.enabled === true && publisher !== null;
|
|
1921
|
+
if (enabled && config) {
|
|
1922
|
+
const activePublisher = publisher;
|
|
1923
|
+
activePublisher.setConfig(config);
|
|
1924
|
+
setBillingCaptureConfig(config);
|
|
1925
|
+
setBillingSink((event) => activePublisher.record(event));
|
|
1926
|
+
if (sweeper2) {
|
|
1927
|
+
sweeper2.configure(config);
|
|
1928
|
+
sweeper2.start();
|
|
1929
|
+
}
|
|
1930
|
+
} else {
|
|
1931
|
+
setBillingCaptureConfig(null);
|
|
1932
|
+
setBillingSink(null);
|
|
1933
|
+
if (sweeper2) {
|
|
1934
|
+
if (config) sweeper2.configure(config);
|
|
1935
|
+
sweeper2.dispose();
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
function resetBillingRuntimeForTests() {
|
|
1940
|
+
setBillingCaptureConfig(null);
|
|
1941
|
+
setBillingSink(null);
|
|
1942
|
+
if (sweeper2) sweeper2.dispose();
|
|
1943
|
+
publisher = null;
|
|
1944
|
+
sweeper2 = null;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1099
1947
|
// src/ports/account-multi.ts
|
|
1100
1948
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
1101
1949
|
var PROVIDER_KEYS = {
|
|
@@ -1207,6 +2055,9 @@ function getAccountById(config, p, id) {
|
|
|
1207
2055
|
const account = getAccounts(config, p).find((a) => a.id === id);
|
|
1208
2056
|
return account ? { id: account.id, tokens: account.tokens } : void 0;
|
|
1209
2057
|
}
|
|
2058
|
+
function getAccountProxy(config, p, id) {
|
|
2059
|
+
return getAccounts(config, p).find((a) => a.id === id)?.proxy;
|
|
2060
|
+
}
|
|
1210
2061
|
function getActiveAccount(config, p) {
|
|
1211
2062
|
const active = getAccounts(config, p).find((a) => a.id === getActiveId(config, p));
|
|
1212
2063
|
return active ? { id: active.id, tokens: active.tokens } : void 0;
|
|
@@ -1247,7 +2098,17 @@ function sanitizeAccounts(config, p) {
|
|
|
1247
2098
|
isSetupToken: t.isSetupToken,
|
|
1248
2099
|
hasAccessToken: !!(t.accessToken || t.apiKey),
|
|
1249
2100
|
isActive: a.id === activeId,
|
|
1250
|
-
|
|
2101
|
+
// Scheduling metadata (subscription-account-scheduling): editable priority
|
|
2102
|
+
// (default 50 shown when unset) + display-only lastUsedAt. Secret-free.
|
|
2103
|
+
priority: a.priority,
|
|
2104
|
+
lastUsedAt: a.lastUsedAt,
|
|
2105
|
+
syncWarning: t.syncWarning,
|
|
2106
|
+
// Per-account proxy (upstream-proxy): masked view — password → hasPassword,
|
|
2107
|
+
// userinfo stripped. The plaintext password is NEVER projected.
|
|
2108
|
+
proxy: a.proxy ? sanitizeProxyConfig(a.proxy) : void 0,
|
|
2109
|
+
// Per-account model support / remap (subscription-account-model-map): model
|
|
2110
|
+
// ids are not token material → carried through verbatim for the editor.
|
|
2111
|
+
supportedModels: a.supportedModels
|
|
1251
2112
|
};
|
|
1252
2113
|
});
|
|
1253
2114
|
}
|
|
@@ -1261,24 +2122,95 @@ function renameAccount(config, p, id, label) {
|
|
|
1261
2122
|
);
|
|
1262
2123
|
return { ok: true };
|
|
1263
2124
|
}
|
|
1264
|
-
function
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
2125
|
+
function setAccountPriority(config, p, id, priority) {
|
|
2126
|
+
const accounts = getAccounts(config, p);
|
|
2127
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2128
|
+
setAccounts(
|
|
2129
|
+
config,
|
|
2130
|
+
p,
|
|
2131
|
+
accounts.map((a) => a.id === id ? { ...a, priority } : a)
|
|
2132
|
+
);
|
|
2133
|
+
return { ok: true };
|
|
1268
2134
|
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
2135
|
+
function setAccountProxy(config, p, id, proxy) {
|
|
2136
|
+
const accounts = getAccounts(config, p);
|
|
2137
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2138
|
+
setAccounts(
|
|
2139
|
+
config,
|
|
2140
|
+
p,
|
|
2141
|
+
accounts.map((a) => {
|
|
2142
|
+
if (a.id !== id) return a;
|
|
2143
|
+
if (!proxy) {
|
|
2144
|
+
const { proxy: _drop, ...rest } = a;
|
|
2145
|
+
return rest;
|
|
2146
|
+
}
|
|
2147
|
+
return { ...a, proxy };
|
|
2148
|
+
})
|
|
2149
|
+
);
|
|
2150
|
+
return { ok: true };
|
|
2151
|
+
}
|
|
2152
|
+
function setAccountSupportedModels(config, p, id, supportedModels) {
|
|
2153
|
+
const accounts = getAccounts(config, p);
|
|
2154
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2155
|
+
setAccounts(
|
|
2156
|
+
config,
|
|
2157
|
+
p,
|
|
2158
|
+
accounts.map((a) => {
|
|
2159
|
+
if (a.id !== id) return a;
|
|
2160
|
+
if (supportedModels === void 0) {
|
|
2161
|
+
const { supportedModels: _drop, ...rest } = a;
|
|
2162
|
+
return rest;
|
|
2163
|
+
}
|
|
2164
|
+
return { ...a, supportedModels };
|
|
2165
|
+
})
|
|
2166
|
+
);
|
|
2167
|
+
return { ok: true };
|
|
2168
|
+
}
|
|
2169
|
+
function setAccountLastUsed(config, p, id, iso) {
|
|
2170
|
+
const accounts = getAccounts(config, p);
|
|
2171
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2172
|
+
setAccounts(
|
|
2173
|
+
config,
|
|
2174
|
+
p,
|
|
2175
|
+
accounts.map((a) => a.id === id ? { ...a, lastUsedAt: iso } : a)
|
|
2176
|
+
);
|
|
2177
|
+
return { ok: true };
|
|
2178
|
+
}
|
|
2179
|
+
function setAccountIdentity(config, p, id, identity) {
|
|
2180
|
+
const accounts = getAccounts(config, p);
|
|
2181
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2182
|
+
setAccounts(
|
|
2183
|
+
config,
|
|
2184
|
+
p,
|
|
2185
|
+
accounts.map((a) => {
|
|
2186
|
+
if (a.id !== id) return a;
|
|
2187
|
+
if (identity === void 0) {
|
|
2188
|
+
const { identity: _drop, ...rest } = a;
|
|
2189
|
+
return rest;
|
|
2190
|
+
}
|
|
2191
|
+
return { ...a, identity };
|
|
2192
|
+
})
|
|
2193
|
+
);
|
|
2194
|
+
return { ok: true };
|
|
2195
|
+
}
|
|
2196
|
+
function clearProvider(config, p) {
|
|
2197
|
+
setBlock(config, p, void 0);
|
|
2198
|
+
setAccounts(config, p, void 0);
|
|
2199
|
+
setActiveId(config, p, void 0);
|
|
2200
|
+
}
|
|
2201
|
+
var DAEMON_PROVIDER_KEYS = PROVIDER_KEYS;
|
|
2202
|
+
|
|
2203
|
+
// src/migration/packCodec.ts
|
|
2204
|
+
import { createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2, randomBytes as randomBytes3, scryptSync } from "crypto";
|
|
2205
|
+
var PACK_MAGIC = "OMCXPACK";
|
|
2206
|
+
var PACK_VERSION = 1;
|
|
2207
|
+
var KDF_ALGORITHM = "scrypt";
|
|
2208
|
+
var PACK_PREFIX = `${PACK_MAGIC}${PACK_VERSION}.`;
|
|
2209
|
+
var KEY_BYTES3 = 32;
|
|
2210
|
+
var IV_BYTES2 = 12;
|
|
2211
|
+
var TAG_BYTES2 = 16;
|
|
2212
|
+
var SCRYPT_N = 1 << 15;
|
|
2213
|
+
var SCRYPT_R = 8;
|
|
1282
2214
|
var SCRYPT_P = 1;
|
|
1283
2215
|
var SCRYPT_SALT_BYTES = 16;
|
|
1284
2216
|
var SCRYPT_MAXMEM = 128 * SCRYPT_R * SCRYPT_N * 2;
|
|
@@ -1526,6 +2458,12 @@ function parseRange(query) {
|
|
|
1526
2458
|
return { startTs, endTs };
|
|
1527
2459
|
}
|
|
1528
2460
|
var isRange = (v) => v.startTs !== void 0 && !("status" in v);
|
|
2461
|
+
var BUCKET_SPAN_MS = {
|
|
2462
|
+
hour: 36e5,
|
|
2463
|
+
day: 864e5,
|
|
2464
|
+
month: 28 * 864e5
|
|
2465
|
+
};
|
|
2466
|
+
var MAX_TIMESERIES_BUCKETS = 2e3;
|
|
1529
2467
|
async function handleUsageGet(view, query, deps) {
|
|
1530
2468
|
const range = parseRange(query);
|
|
1531
2469
|
if (!isRange(range)) return range;
|
|
@@ -1534,6 +2472,24 @@ async function handleUsageGet(view, query, deps) {
|
|
|
1534
2472
|
return { status: 200, body: await deps.usageRecorder.getTotals(range) };
|
|
1535
2473
|
case "by-model":
|
|
1536
2474
|
return { status: 200, body: await deps.usageRecorder.getByModel(range) };
|
|
2475
|
+
case "timeseries": {
|
|
2476
|
+
const bucket = query.get("bucket");
|
|
2477
|
+
if (bucket !== "hour" && bucket !== "day" && bucket !== "month") {
|
|
2478
|
+
return err4(400, "bucket must be one of 'hour', 'day', 'month'");
|
|
2479
|
+
}
|
|
2480
|
+
const now = Date.now();
|
|
2481
|
+
const clamped = { startTs: range.startTs, endTs: Math.min(range.endTs, now) };
|
|
2482
|
+
if (clamped.startTs < clamped.endTs) {
|
|
2483
|
+
const projected = Math.ceil((clamped.endTs - clamped.startTs) / BUCKET_SPAN_MS[bucket]) + 1;
|
|
2484
|
+
if (projected > MAX_TIMESERIES_BUCKETS) {
|
|
2485
|
+
return err4(
|
|
2486
|
+
400,
|
|
2487
|
+
`requested range projects ~${projected} '${bucket}' buckets (max ${MAX_TIMESERIES_BUCKETS}); narrow the range or use a coarser bucket`
|
|
2488
|
+
);
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
return { status: 200, body: await deps.usageRecorder.getTimeSeries(clamped, bucket) };
|
|
2492
|
+
}
|
|
1537
2493
|
case "by-api-key": {
|
|
1538
2494
|
const rows = await deps.usageRecorder.getByApiKey(range);
|
|
1539
2495
|
const labels = poolKeyLabels(loadConfig(deps.configPath));
|
|
@@ -1679,7 +2635,7 @@ function readBody(req) {
|
|
|
1679
2635
|
req.on("error", reject);
|
|
1680
2636
|
});
|
|
1681
2637
|
}
|
|
1682
|
-
async function
|
|
2638
|
+
async function readJsonBody3(req) {
|
|
1683
2639
|
const raw = await readBody(req);
|
|
1684
2640
|
if (!raw.trim()) return {};
|
|
1685
2641
|
try {
|
|
@@ -1689,12 +2645,12 @@ async function readJsonBody(req) {
|
|
|
1689
2645
|
return {};
|
|
1690
2646
|
}
|
|
1691
2647
|
}
|
|
1692
|
-
function
|
|
2648
|
+
function writeJson2(res, status, body) {
|
|
1693
2649
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1694
2650
|
res.end(JSON.stringify(body));
|
|
1695
2651
|
}
|
|
1696
2652
|
function writeJsonError(res, status, message) {
|
|
1697
|
-
|
|
2653
|
+
writeJson2(res, status, { error: { type: "admin_api_error", message } });
|
|
1698
2654
|
}
|
|
1699
2655
|
function maskProviderApiKey(apiKey) {
|
|
1700
2656
|
if (!apiKey) return "";
|
|
@@ -1710,7 +2666,23 @@ function toKeyInfo(row) {
|
|
|
1710
2666
|
enabled: row.enabled,
|
|
1711
2667
|
createdAt: row.createdAt,
|
|
1712
2668
|
lastUsedAt: row.lastUsedAt,
|
|
1713
|
-
revoked: row.revokedAt !== null
|
|
2669
|
+
revoked: row.revokedAt !== null,
|
|
2670
|
+
maxConcurrency: row.maxConcurrency,
|
|
2671
|
+
// Key-policy envelope (outbound-key-policy) — all secret-free scalar fields;
|
|
2672
|
+
// the UI reads them to render + pre-fill the policy editor.
|
|
2673
|
+
expiresAt: row.expiresAt,
|
|
2674
|
+
activationMode: row.activationMode,
|
|
2675
|
+
activationDays: row.activationDays,
|
|
2676
|
+
activatedAt: row.activatedAt,
|
|
2677
|
+
dailyCostLimitUsd: row.dailyCostLimitUsd,
|
|
2678
|
+
totalCostLimitUsd: row.totalCostLimitUsd,
|
|
2679
|
+
weeklyCostLimitUsd: row.weeklyCostLimitUsd,
|
|
2680
|
+
rateLimitMaxRequests: row.rateLimitMaxRequests,
|
|
2681
|
+
rateLimitWindowMs: row.rateLimitWindowMs,
|
|
2682
|
+
// Per-key model restriction (#6) — secret-free scalars the UI pre-fills.
|
|
2683
|
+
enableModelRestriction: row.enableModelRestriction,
|
|
2684
|
+
restrictionMode: row.restrictionMode,
|
|
2685
|
+
restrictedModels: row.restrictedModels
|
|
1714
2686
|
};
|
|
1715
2687
|
}
|
|
1716
2688
|
function toProviderView(row) {
|
|
@@ -1772,6 +2744,8 @@ async function handleAdminApi(req, res, path2, deps) {
|
|
|
1772
2744
|
return handlePresets(res, method);
|
|
1773
2745
|
case "keys":
|
|
1774
2746
|
return await handleKeys(req, res, method, rest, deps);
|
|
2747
|
+
case "voucher":
|
|
2748
|
+
return await handleVoucher(req, res, method, rest, deps);
|
|
1775
2749
|
case "server":
|
|
1776
2750
|
return await handleServer(req, res, method, deps);
|
|
1777
2751
|
case "accounts":
|
|
@@ -1788,6 +2762,8 @@ async function handleAdminApi(req, res, path2, deps) {
|
|
|
1788
2762
|
return await handleMigrationImport(req, res, method, deps);
|
|
1789
2763
|
case "usage":
|
|
1790
2764
|
return await handleUsage(req, res, method, rest, deps);
|
|
2765
|
+
case "dashboard":
|
|
2766
|
+
return await handleDashboardRoute(res, method, deps);
|
|
1791
2767
|
case "pricing":
|
|
1792
2768
|
return await handlePricing(req, res, method, rest, deps);
|
|
1793
2769
|
default:
|
|
@@ -1803,17 +2779,22 @@ function requestQuery(req) {
|
|
|
1803
2779
|
return new URLSearchParams(qIdx >= 0 ? raw.slice(qIdx + 1) : "");
|
|
1804
2780
|
}
|
|
1805
2781
|
function writeResult(res, result) {
|
|
1806
|
-
|
|
2782
|
+
writeJson2(res, result.status, result.body);
|
|
1807
2783
|
}
|
|
1808
2784
|
async function handleUsage(req, res, method, rest, deps) {
|
|
1809
2785
|
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on usage`);
|
|
1810
2786
|
return writeResult(res, await handleUsageGet(rest[0], requestQuery(req), deps));
|
|
1811
2787
|
}
|
|
2788
|
+
async function handleDashboardRoute(res, method, deps) {
|
|
2789
|
+
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on dashboard`);
|
|
2790
|
+
const result = await handleDashboard(deps);
|
|
2791
|
+
return writeJson2(res, result.status, result.body);
|
|
2792
|
+
}
|
|
1812
2793
|
async function handlePricing(req, res, method, rest, deps) {
|
|
1813
2794
|
if (rest.length === 0) {
|
|
1814
2795
|
if (method === "GET") return writeResult(res, await handlePricingList(deps));
|
|
1815
2796
|
if (method === "PUT") {
|
|
1816
|
-
return writeResult(res, await handlePricingUpsert(await
|
|
2797
|
+
return writeResult(res, await handlePricingUpsert(await readJsonBody3(req), deps));
|
|
1817
2798
|
}
|
|
1818
2799
|
if (method === "DELETE") {
|
|
1819
2800
|
return writeResult(res, await handlePricingDelete(requestQuery(req), deps));
|
|
@@ -1824,7 +2805,7 @@ async function handlePricing(req, res, method, rest, deps) {
|
|
|
1824
2805
|
return writeResult(res, await handlePricingFetchLatest(deps));
|
|
1825
2806
|
}
|
|
1826
2807
|
if (method === "POST" && rest.length === 1 && rest[0] === "resolve-conflicts") {
|
|
1827
|
-
return writeResult(res, await handlePricingResolveConflicts(await
|
|
2808
|
+
return writeResult(res, await handlePricingResolveConflicts(await readJsonBody3(req), deps));
|
|
1828
2809
|
}
|
|
1829
2810
|
return writeJsonError(res, 404, `unknown pricing route '${rest.join("/")}'`);
|
|
1830
2811
|
}
|
|
@@ -1838,15 +2819,15 @@ function migrationDeps(deps) {
|
|
|
1838
2819
|
}
|
|
1839
2820
|
async function handleMigrationExport(req, res, method, deps) {
|
|
1840
2821
|
if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on export`);
|
|
1841
|
-
const body = await
|
|
2822
|
+
const body = await readJsonBody3(req);
|
|
1842
2823
|
const result = await handleExport(body, migrationDeps(deps));
|
|
1843
|
-
return
|
|
2824
|
+
return writeJson2(res, result.status, result.body);
|
|
1844
2825
|
}
|
|
1845
2826
|
async function handleMigrationImport(req, res, method, deps) {
|
|
1846
2827
|
if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on import`);
|
|
1847
|
-
const body = await
|
|
2828
|
+
const body = await readJsonBody3(req);
|
|
1848
2829
|
const result = await handleImport(body, migrationDeps(deps));
|
|
1849
|
-
return
|
|
2830
|
+
return writeJson2(res, result.status, result.body);
|
|
1850
2831
|
}
|
|
1851
2832
|
async function handleProviders(req, res, method, rest, deps) {
|
|
1852
2833
|
const cfg = loadConfig(deps.configPath);
|
|
@@ -1877,13 +2858,13 @@ async function handleProviders(req, res, method, rest, deps) {
|
|
|
1877
2858
|
if (method === "GET" && rest.length === 2 && rest[1] === "reveal-key") {
|
|
1878
2859
|
const row = cfg.providers.find((p) => p.id === rest[0]);
|
|
1879
2860
|
if (!row) return writeJsonError(res, 404, `provider '${rest[0]}' not found`);
|
|
1880
|
-
return
|
|
2861
|
+
return writeJson2(res, 200, { apiKey: row.apiKey ?? "" });
|
|
1881
2862
|
}
|
|
1882
2863
|
if (method === "GET") {
|
|
1883
|
-
return
|
|
2864
|
+
return writeJson2(res, 200, { providers: cfg.providers.map(toProviderView) });
|
|
1884
2865
|
}
|
|
1885
2866
|
if (method === "POST") {
|
|
1886
|
-
const body = await
|
|
2867
|
+
const body = await readJsonBody3(req);
|
|
1887
2868
|
const provider = parseProviderInput(body, void 0);
|
|
1888
2869
|
if (!provider) return writeJsonError(res, 400, "invalid provider (id, apiFormat, baseUrl required)");
|
|
1889
2870
|
if (cfg.providers.some((p) => p.id === provider.id)) {
|
|
@@ -1891,25 +2872,25 @@ async function handleProviders(req, res, method, rest, deps) {
|
|
|
1891
2872
|
}
|
|
1892
2873
|
cfg.providers.push(provider);
|
|
1893
2874
|
persistProviders(cfg, deps);
|
|
1894
|
-
return
|
|
2875
|
+
return writeJson2(res, 201, { provider: toProviderView(provider) });
|
|
1895
2876
|
}
|
|
1896
2877
|
const id = rest[0];
|
|
1897
2878
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
1898
2879
|
const idx = cfg.providers.findIndex((p) => p.id === id);
|
|
1899
2880
|
if (idx < 0) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
1900
2881
|
if (method === "PUT") {
|
|
1901
|
-
const body = await
|
|
2882
|
+
const body = await readJsonBody3(req);
|
|
1902
2883
|
const existing = cfg.providers[idx];
|
|
1903
2884
|
const updated = parseProviderInput(body, existing);
|
|
1904
2885
|
if (!updated) return writeJsonError(res, 400, "invalid provider (apiFormat, baseUrl required)");
|
|
1905
2886
|
cfg.providers[idx] = updated;
|
|
1906
2887
|
persistProviders(cfg, deps);
|
|
1907
|
-
return
|
|
2888
|
+
return writeJson2(res, 200, { provider: toProviderView(updated) });
|
|
1908
2889
|
}
|
|
1909
2890
|
if (method === "DELETE") {
|
|
1910
2891
|
cfg.providers.splice(idx, 1);
|
|
1911
2892
|
persistProviders(cfg, deps);
|
|
1912
|
-
return
|
|
2893
|
+
return writeJson2(res, 200, { ok: true });
|
|
1913
2894
|
}
|
|
1914
2895
|
return writeJsonError(res, 405, `method ${method} not allowed on providers`);
|
|
1915
2896
|
}
|
|
@@ -1918,7 +2899,7 @@ function persistProviders(cfg, deps) {
|
|
|
1918
2899
|
deps.llmConfig.reload(cfg);
|
|
1919
2900
|
}
|
|
1920
2901
|
async function handleProviderReorder(req, res, cfg, deps) {
|
|
1921
|
-
const body = await
|
|
2902
|
+
const body = await readJsonBody3(req);
|
|
1922
2903
|
const rawOrder = body["order"];
|
|
1923
2904
|
if (!Array.isArray(rawOrder)) {
|
|
1924
2905
|
return writeJsonError(res, 400, "reorder requires { order: string[] }");
|
|
@@ -1942,14 +2923,14 @@ async function handleProviderReorder(req, res, cfg, deps) {
|
|
|
1942
2923
|
}
|
|
1943
2924
|
cfg.providers = reordered;
|
|
1944
2925
|
persistProviders(cfg, deps);
|
|
1945
|
-
return
|
|
2926
|
+
return writeJson2(res, 200, { ok: true, providers: cfg.providers.map(toProviderView) });
|
|
1946
2927
|
}
|
|
1947
2928
|
async function handleDiscoverModels(res, id, cfg) {
|
|
1948
2929
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
1949
2930
|
const row = cfg.providers.find((p) => p.id === id);
|
|
1950
2931
|
if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
1951
2932
|
if (row.apiFormat !== "openai") {
|
|
1952
|
-
return
|
|
2933
|
+
return writeJson2(res, 200, { models: [], unsupportedFormat: true });
|
|
1953
2934
|
}
|
|
1954
2935
|
const resolvedKey = resolveEnvKey(row.apiKey);
|
|
1955
2936
|
const base = row.baseUrl.replace(/\/+$/, "");
|
|
@@ -1957,7 +2938,7 @@ async function handleDiscoverModels(res, id, cfg) {
|
|
|
1957
2938
|
try {
|
|
1958
2939
|
const headers = { Accept: "application/json" };
|
|
1959
2940
|
if (resolvedKey) headers["Authorization"] = `Bearer ${resolvedKey}`;
|
|
1960
|
-
const response = await
|
|
2941
|
+
const response = await fetchUpstream(url, { method: "GET", headers }, { providerId: "byo" });
|
|
1961
2942
|
if (!response.ok) {
|
|
1962
2943
|
const text = await response.text().catch(() => "");
|
|
1963
2944
|
let message = text.slice(0, 300);
|
|
@@ -1966,32 +2947,32 @@ async function handleDiscoverModels(res, id, cfg) {
|
|
|
1966
2947
|
message = parsed?.error?.message || parsed?.message || message;
|
|
1967
2948
|
} catch {
|
|
1968
2949
|
}
|
|
1969
|
-
return
|
|
2950
|
+
return writeJson2(res, 200, {
|
|
1970
2951
|
models: [],
|
|
1971
2952
|
error: `discovery failed (${response.status})${message ? `: ${message}` : ""}`
|
|
1972
2953
|
});
|
|
1973
2954
|
}
|
|
1974
2955
|
const data = await response.json();
|
|
1975
2956
|
const models = Array.isArray(data?.data) ? data.data.map((m) => typeof m?.id === "string" ? m.id : "").filter((m) => m.length > 0) : [];
|
|
1976
|
-
return
|
|
2957
|
+
return writeJson2(res, 200, { models });
|
|
1977
2958
|
} catch (err5) {
|
|
1978
2959
|
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
1979
|
-
return
|
|
2960
|
+
return writeJson2(res, 200, { models: [], error: `discovery failed: ${message}` });
|
|
1980
2961
|
}
|
|
1981
2962
|
}
|
|
1982
2963
|
async function handleTestModel(req, res, id, cfg) {
|
|
1983
2964
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
1984
2965
|
const row = cfg.providers.find((p) => p.id === id);
|
|
1985
2966
|
if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
1986
|
-
const body = await
|
|
2967
|
+
const body = await readJsonBody3(req);
|
|
1987
2968
|
const model = typeof body["model"] === "string" ? body["model"].trim() : "";
|
|
1988
2969
|
if (!model) return writeJsonError(res, 400, "test requires a { model } string");
|
|
1989
2970
|
if (row.apiFormat === "gemini") {
|
|
1990
|
-
return
|
|
2971
|
+
return writeJson2(res, 200, { ok: false, unsupportedFormat: true });
|
|
1991
2972
|
}
|
|
1992
2973
|
const resolvedKey = resolveEnvKey(row.apiKey);
|
|
1993
2974
|
if (!resolvedKey) {
|
|
1994
|
-
return
|
|
2975
|
+
return writeJson2(res, 200, { ok: false, message: "no API key configured for this provider" });
|
|
1995
2976
|
}
|
|
1996
2977
|
const url = row.baseUrl.replace(/\/+$/, "");
|
|
1997
2978
|
const prompt = "Reply with the single word: OK.";
|
|
@@ -2012,11 +2993,11 @@ async function handleTestModel(req, res, id, cfg) {
|
|
|
2012
2993
|
}
|
|
2013
2994
|
const startedAt = Date.now();
|
|
2014
2995
|
try {
|
|
2015
|
-
const response = await
|
|
2016
|
-
|
|
2017
|
-
headers,
|
|
2018
|
-
|
|
2019
|
-
|
|
2996
|
+
const response = await fetchUpstream(
|
|
2997
|
+
url,
|
|
2998
|
+
{ method: "POST", headers, body: JSON.stringify(payload) },
|
|
2999
|
+
{ providerId: "byo" }
|
|
3000
|
+
);
|
|
2020
3001
|
const latencyMs = Date.now() - startedAt;
|
|
2021
3002
|
const text = await response.text().catch(() => "");
|
|
2022
3003
|
if (!response.ok) {
|
|
@@ -2026,9 +3007,9 @@ async function handleTestModel(req, res, id, cfg) {
|
|
|
2026
3007
|
message = parsed?.error?.message || parsed?.message || message;
|
|
2027
3008
|
} catch {
|
|
2028
3009
|
}
|
|
2029
|
-
return
|
|
3010
|
+
return writeJson2(res, 200, { ok: false, status: response.status, latencyMs, message });
|
|
2030
3011
|
}
|
|
2031
|
-
return
|
|
3012
|
+
return writeJson2(res, 200, {
|
|
2032
3013
|
ok: true,
|
|
2033
3014
|
status: response.status,
|
|
2034
3015
|
latencyMs,
|
|
@@ -2036,7 +3017,7 @@ async function handleTestModel(req, res, id, cfg) {
|
|
|
2036
3017
|
});
|
|
2037
3018
|
} catch (err5) {
|
|
2038
3019
|
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
2039
|
-
return
|
|
3020
|
+
return writeJson2(res, 200, { ok: false, latencyMs: Date.now() - startedAt, message });
|
|
2040
3021
|
}
|
|
2041
3022
|
}
|
|
2042
3023
|
function extractSampleText(text, apiFormat) {
|
|
@@ -2058,9 +3039,9 @@ function toPoolKeyView(row, cooldown, deps) {
|
|
|
2058
3039
|
return entries.map((e) => {
|
|
2059
3040
|
const auto = deps.autoDisableStore.get(e.id);
|
|
2060
3041
|
const cd = cooldown[e.id];
|
|
2061
|
-
const
|
|
2062
|
-
if (cd)
|
|
2063
|
-
if (auto)
|
|
3042
|
+
const health2 = {};
|
|
3043
|
+
if (cd) health2.cooldown = cd;
|
|
3044
|
+
if (auto) health2.autoDisabled = { status: auto.status, at: auto.at, reason: auto.reason };
|
|
2064
3045
|
return {
|
|
2065
3046
|
id: e.id,
|
|
2066
3047
|
label: e.label && e.label.length > 0 ? e.label : e.id,
|
|
@@ -2068,7 +3049,7 @@ function toPoolKeyView(row, cooldown, deps) {
|
|
|
2068
3049
|
enabled: e.enabled !== false && !deps.autoDisableStore.isDisabled(e.id),
|
|
2069
3050
|
weight: typeof e.weight === "number" && Number.isFinite(e.weight) ? e.weight : 1,
|
|
2070
3051
|
apiKeyMasked: maskProviderApiKey(e.apiKey),
|
|
2071
|
-
...Object.keys(
|
|
3052
|
+
...Object.keys(health2).length > 0 ? { health: health2 } : {}
|
|
2072
3053
|
};
|
|
2073
3054
|
});
|
|
2074
3055
|
}
|
|
@@ -2077,7 +3058,7 @@ async function handleProviderKeys(res, id, cfg, deps) {
|
|
|
2077
3058
|
const row = cfg.providers.find((p) => p.id === id);
|
|
2078
3059
|
if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
2079
3060
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2080
|
-
return
|
|
3061
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2081
3062
|
}
|
|
2082
3063
|
function parsePoolKeyInput(body, existing) {
|
|
2083
3064
|
const out = {};
|
|
@@ -2096,7 +3077,7 @@ async function handleAddProviderKey(req, res, id, cfg, deps) {
|
|
|
2096
3077
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
2097
3078
|
const idx = cfg.providers.findIndex((p) => p.id === id);
|
|
2098
3079
|
if (idx < 0) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
2099
|
-
const body = await
|
|
3080
|
+
const body = await readJsonBody3(req);
|
|
2100
3081
|
const parsed = parsePoolKeyInput(body);
|
|
2101
3082
|
if (!parsed.apiKey) return writeJsonError(res, 400, "add requires a non-empty apiKey");
|
|
2102
3083
|
const keyId = `key-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -2108,7 +3089,7 @@ async function handleAddProviderKey(req, res, id, cfg, deps) {
|
|
|
2108
3089
|
row.apiKeys = [...row.apiKeys ?? [], entry];
|
|
2109
3090
|
persistProviders(cfg, deps);
|
|
2110
3091
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2111
|
-
return
|
|
3092
|
+
return writeJson2(res, 201, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2112
3093
|
}
|
|
2113
3094
|
async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
|
|
2114
3095
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
@@ -2118,7 +3099,7 @@ async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
|
|
|
2118
3099
|
const row = cfg.providers[idx];
|
|
2119
3100
|
const keyIdx = (row.apiKeys ?? []).findIndex((k) => k.id === keyId);
|
|
2120
3101
|
if (keyIdx < 0) return writeJsonError(res, 404, `pool key '${keyId}' not found`);
|
|
2121
|
-
const body = await
|
|
3102
|
+
const body = await readJsonBody3(req);
|
|
2122
3103
|
const existing = row.apiKeys[keyIdx];
|
|
2123
3104
|
const parsed = parsePoolKeyInput(body, existing);
|
|
2124
3105
|
const entry = { id: keyId, apiKey: parsed.apiKey || existing.apiKey };
|
|
@@ -2128,7 +3109,7 @@ async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
|
|
|
2128
3109
|
row.apiKeys[keyIdx] = entry;
|
|
2129
3110
|
persistProviders(cfg, deps);
|
|
2130
3111
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2131
|
-
return
|
|
3112
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2132
3113
|
}
|
|
2133
3114
|
async function handleDeleteProviderKey(res, id, keyId, cfg, deps) {
|
|
2134
3115
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
@@ -2142,7 +3123,7 @@ async function handleDeleteProviderKey(res, id, keyId, cfg, deps) {
|
|
|
2142
3123
|
if (row.apiKeys.length === 0) row.apiKeys = void 0;
|
|
2143
3124
|
persistProviders(cfg, deps);
|
|
2144
3125
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2145
|
-
return
|
|
3126
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2146
3127
|
}
|
|
2147
3128
|
async function handleToggleProviderKey(req, res, id, keyId, cfg, deps) {
|
|
2148
3129
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
@@ -2152,11 +3133,11 @@ async function handleToggleProviderKey(req, res, id, keyId, cfg, deps) {
|
|
|
2152
3133
|
const row = cfg.providers[idx];
|
|
2153
3134
|
const keyIdx = (row.apiKeys ?? []).findIndex((k) => k.id === keyId);
|
|
2154
3135
|
if (keyIdx < 0) return writeJsonError(res, 404, `pool key '${keyId}' not found`);
|
|
2155
|
-
const body = await
|
|
3136
|
+
const body = await readJsonBody3(req);
|
|
2156
3137
|
row.apiKeys[keyIdx].enabled = Boolean(body["enabled"]);
|
|
2157
3138
|
persistProviders(cfg, deps);
|
|
2158
3139
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2159
|
-
return
|
|
3140
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2160
3141
|
}
|
|
2161
3142
|
function parseApiKeysInput(raw, existing) {
|
|
2162
3143
|
if (!Array.isArray(raw)) return existing;
|
|
@@ -2327,18 +3308,31 @@ function handlePresets(res, method) {
|
|
|
2327
3308
|
baseUrl: p.baseUrl,
|
|
2328
3309
|
models: p.models
|
|
2329
3310
|
}));
|
|
2330
|
-
return
|
|
3311
|
+
return writeJson2(res, 200, { presets, excluded });
|
|
2331
3312
|
}
|
|
2332
3313
|
async function handleKeys(req, res, method, rest, deps) {
|
|
2333
3314
|
if (method === "GET" && rest.length === 0) {
|
|
2334
3315
|
const rows = await deps.keyDb.outboundApiKeysList();
|
|
2335
|
-
|
|
3316
|
+
const reader = deps.keySpendReader;
|
|
3317
|
+
if (!reader) return writeJson2(res, 200, { keys: rows.map(toKeyInfo) });
|
|
3318
|
+
const now = Date.now();
|
|
3319
|
+
const keys = await Promise.all(
|
|
3320
|
+
rows.map(async (row) => {
|
|
3321
|
+
const info = toKeyInfo(row);
|
|
3322
|
+
if (row.revokedAt === null) {
|
|
3323
|
+
const s = await reader.getSpend(row.id, now);
|
|
3324
|
+
info.spend = { dailyUsd: s.dailyUsd, weeklyUsd: s.weeklyUsd, totalUsd: s.totalUsd };
|
|
3325
|
+
}
|
|
3326
|
+
return info;
|
|
3327
|
+
})
|
|
3328
|
+
);
|
|
3329
|
+
return writeJson2(res, 200, { keys });
|
|
2336
3330
|
}
|
|
2337
3331
|
if (method === "POST" && rest.length === 0) {
|
|
2338
|
-
const body = await
|
|
3332
|
+
const body = await readJsonBody3(req);
|
|
2339
3333
|
const name = typeof body["name"] === "string" && body["name"].trim() ? body["name"].trim() : "key";
|
|
2340
3334
|
const created = await createNamedKey(deps.keyDb, name);
|
|
2341
|
-
return
|
|
3335
|
+
return writeJson2(res, 201, {
|
|
2342
3336
|
id: created.id,
|
|
2343
3337
|
name: created.name,
|
|
2344
3338
|
keyPrefix: created.keyPrefix,
|
|
@@ -2350,46 +3344,181 @@ async function handleKeys(req, res, method, rest, deps) {
|
|
|
2350
3344
|
const action = rest[1];
|
|
2351
3345
|
if (method === "POST" && id && action === "revoke") {
|
|
2352
3346
|
const ok = await deps.keyDb.outboundApiKeysRevoke(id);
|
|
2353
|
-
return
|
|
3347
|
+
return writeJson2(res, ok ? 200 : 404, { ok });
|
|
2354
3348
|
}
|
|
2355
3349
|
if (method === "POST" && id && action === "enabled") {
|
|
2356
|
-
const body = await
|
|
3350
|
+
const body = await readJsonBody3(req);
|
|
2357
3351
|
const enabled = body["enabled"] === true;
|
|
2358
3352
|
const ok = await deps.keyDb.outboundApiKeysSetEnabled(id, enabled);
|
|
2359
|
-
return
|
|
3353
|
+
return writeJson2(res, ok ? 200 : 404, { ok, enabled });
|
|
3354
|
+
}
|
|
3355
|
+
if (method === "POST" && id && action === "max-concurrency") {
|
|
3356
|
+
const body = await readJsonBody3(req);
|
|
3357
|
+
const raw = body["maxConcurrency"];
|
|
3358
|
+
let value;
|
|
3359
|
+
if (raw === null) {
|
|
3360
|
+
value = null;
|
|
3361
|
+
} else if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 1e3) {
|
|
3362
|
+
value = raw;
|
|
3363
|
+
} else {
|
|
3364
|
+
return writeJsonError(
|
|
3365
|
+
res,
|
|
3366
|
+
400,
|
|
3367
|
+
"maxConcurrency must be an integer 1..1000 or null"
|
|
3368
|
+
);
|
|
3369
|
+
}
|
|
3370
|
+
const ok = await deps.keyDb.outboundApiKeysSetMaxConcurrency(id, value);
|
|
3371
|
+
return writeJson2(res, ok ? 200 : 404, { ok, maxConcurrency: value });
|
|
3372
|
+
}
|
|
3373
|
+
if (method === "POST" && id && action === "policy") {
|
|
3374
|
+
const body = await readJsonBody3(req);
|
|
3375
|
+
const parsed = parseKeyPolicyBody(body);
|
|
3376
|
+
if (!parsed.ok) return writeJsonError(res, 400, parsed.message);
|
|
3377
|
+
const ok = await deps.keyDb.outboundApiKeysSetPolicy(id, parsed.policy);
|
|
3378
|
+
return writeJson2(res, ok ? 200 : 404, { ok });
|
|
2360
3379
|
}
|
|
2361
3380
|
return writeJsonError(res, 405, `method ${method} not allowed on keys`);
|
|
2362
3381
|
}
|
|
3382
|
+
function validateQueueSegments(patch) {
|
|
3383
|
+
const errors = [];
|
|
3384
|
+
const checkNum = (label, value, min, max) => {
|
|
3385
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
|
|
3386
|
+
errors.push(`${label} must be a number ${min}..${max}`);
|
|
3387
|
+
}
|
|
3388
|
+
};
|
|
3389
|
+
const isPlainObject4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3390
|
+
const umq = patch.userMessageQueue;
|
|
3391
|
+
if (umq !== void 0) {
|
|
3392
|
+
if (!isPlainObject4(umq)) {
|
|
3393
|
+
errors.push("userMessageQueue must be an object");
|
|
3394
|
+
} else {
|
|
3395
|
+
if (typeof umq.enabled !== "boolean") {
|
|
3396
|
+
errors.push("userMessageQueue.enabled must be a boolean");
|
|
3397
|
+
}
|
|
3398
|
+
checkNum("userMessageQueue.delayMs", umq.delayMs, 0, 1e4);
|
|
3399
|
+
checkNum("userMessageQueue.waitTimeoutMs", umq.waitTimeoutMs, 1e3, 3e5);
|
|
3400
|
+
}
|
|
3401
|
+
}
|
|
3402
|
+
const cq = patch.concurrencyQueue;
|
|
3403
|
+
if (cq !== void 0) {
|
|
3404
|
+
if (!isPlainObject4(cq)) {
|
|
3405
|
+
errors.push("concurrencyQueue must be an object");
|
|
3406
|
+
} else {
|
|
3407
|
+
checkNum("concurrencyQueue.maxQueueSizeFactor", cq.maxQueueSizeFactor, 1, 10);
|
|
3408
|
+
checkNum("concurrencyQueue.minQueueSize", cq.minQueueSize, 1, 100);
|
|
3409
|
+
checkNum("concurrencyQueue.waitTimeoutMs", cq.waitTimeoutMs, 1e3, 3e5);
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
const ah = patch.accountHealth;
|
|
3413
|
+
if (ah !== void 0) {
|
|
3414
|
+
if (!isPlainObject4(ah)) {
|
|
3415
|
+
errors.push("accountHealth must be an object");
|
|
3416
|
+
} else {
|
|
3417
|
+
if (typeof ah.overloadCooldownEnabled !== "boolean") {
|
|
3418
|
+
errors.push("accountHealth.overloadCooldownEnabled must be a boolean");
|
|
3419
|
+
}
|
|
3420
|
+
checkNum("accountHealth.overloadCooldownMs", ah.overloadCooldownMs, 6e4, 36e5);
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
return errors;
|
|
3424
|
+
}
|
|
2363
3425
|
async function handleServer(req, res, method, deps) {
|
|
2364
3426
|
if (method === "GET") {
|
|
2365
|
-
const config = await
|
|
2366
|
-
|
|
3427
|
+
const config = await loadServerConfig2(deps.settingsStore);
|
|
3428
|
+
let server = config;
|
|
3429
|
+
if (config.proxy) server = { ...server, proxy: redactOutboundProxy(config.proxy) };
|
|
3430
|
+
if (config.webhook) server = { ...server, webhook: redactWebhookConfig(config.webhook) };
|
|
3431
|
+
if (config.billing) server = { ...server, billing: redactBillingConfig(config.billing) };
|
|
3432
|
+
return writeJson2(res, 200, { server });
|
|
2367
3433
|
}
|
|
2368
3434
|
if (method === "PUT") {
|
|
2369
|
-
const patch = await
|
|
2370
|
-
const
|
|
2371
|
-
|
|
3435
|
+
const patch = await readJsonBody3(req);
|
|
3436
|
+
const queueErrors = validateQueueSegments(patch);
|
|
3437
|
+
if (queueErrors.length > 0) {
|
|
3438
|
+
return writeJsonError(res, 400, `invalid queue config: ${queueErrors.join("; ")}`);
|
|
3439
|
+
}
|
|
3440
|
+
const webhookErrors = validateWebhookSegment(patch);
|
|
3441
|
+
if (webhookErrors.length > 0) {
|
|
3442
|
+
return writeJsonError(res, 400, `invalid webhook config: ${webhookErrors.join("; ")}`);
|
|
3443
|
+
}
|
|
3444
|
+
const auditErrors = validateAuditSegment(patch);
|
|
3445
|
+
if (auditErrors.length > 0) {
|
|
3446
|
+
return writeJsonError(res, 400, `invalid audit config: ${auditErrors.join("; ")}`);
|
|
3447
|
+
}
|
|
3448
|
+
const billingErrors = validateBillingSegment(patch);
|
|
3449
|
+
if (billingErrors.length > 0) {
|
|
3450
|
+
return writeJsonError(res, 400, `invalid billing config: ${billingErrors.join("; ")}`);
|
|
3451
|
+
}
|
|
3452
|
+
const current = await loadServerConfig2(deps.settingsStore);
|
|
3453
|
+
let effectivePatch = patch;
|
|
3454
|
+
if (patch.proxy) {
|
|
3455
|
+
effectivePatch = { ...effectivePatch, proxy: preserveOutboundProxySecrets(patch.proxy, current.proxy) };
|
|
3456
|
+
}
|
|
3457
|
+
if (patch.webhook) {
|
|
3458
|
+
effectivePatch = { ...effectivePatch, webhook: preserveWebhookSecrets(patch.webhook, current.webhook) };
|
|
3459
|
+
}
|
|
3460
|
+
if (patch.billing) {
|
|
3461
|
+
effectivePatch = { ...effectivePatch, billing: preserveBillingSecret(patch.billing, current.billing) };
|
|
3462
|
+
}
|
|
3463
|
+
const merged = mergeServerConfig(current, effectivePatch);
|
|
2372
3464
|
await saveServerConfig(deps.settingsStore, merged);
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
3465
|
+
setServerProxyConfig(merged.proxy);
|
|
3466
|
+
applyWebhookConfig(merged.webhook);
|
|
3467
|
+
applyAuditConfig(merged.audit);
|
|
3468
|
+
applyBillingConfig(merged.billing);
|
|
3469
|
+
if (merged.enabled) {
|
|
3470
|
+
const missing = validateServerModelConfig(merged);
|
|
3471
|
+
if (missing.length > 0) {
|
|
3472
|
+
if (deps.outboundApiServer.getStatus().running) {
|
|
3473
|
+
await deps.outboundApiServer.stop();
|
|
3474
|
+
}
|
|
3475
|
+
return writeJson2(res, 200, {
|
|
3476
|
+
server: merged,
|
|
3477
|
+
error: { code: "incomplete-model-config", missing }
|
|
3478
|
+
});
|
|
3479
|
+
}
|
|
3480
|
+
}
|
|
3481
|
+
try {
|
|
3482
|
+
await deps.outboundApiServer.applyConfig({
|
|
3483
|
+
enabled: merged.enabled,
|
|
3484
|
+
networkBinding: merged.networkBinding,
|
|
3485
|
+
endpoints: merged.endpoints,
|
|
3486
|
+
port: merged.port,
|
|
3487
|
+
userMessageQueue: merged.userMessageQueue,
|
|
3488
|
+
concurrencyQueue: merged.concurrencyQueue,
|
|
3489
|
+
// voucher-redemption #9: hot-apply the voucher flag so enabling the product
|
|
3490
|
+
// takes effect without a restart.
|
|
3491
|
+
voucher: merged.voucher
|
|
3492
|
+
});
|
|
3493
|
+
} catch (err5) {
|
|
3494
|
+
const missing = incompleteConfigMissing(err5);
|
|
3495
|
+
if (missing) {
|
|
3496
|
+
return writeJson2(res, 200, {
|
|
3497
|
+
server: merged,
|
|
3498
|
+
error: { code: "incomplete-model-config", missing }
|
|
3499
|
+
});
|
|
3500
|
+
}
|
|
3501
|
+
throw err5;
|
|
3502
|
+
}
|
|
3503
|
+
return writeJson2(res, 200, { server: merged });
|
|
2380
3504
|
}
|
|
2381
3505
|
return writeJsonError(res, 405, `method ${method} not allowed on server`);
|
|
2382
3506
|
}
|
|
3507
|
+
function incompleteConfigMissing(err5) {
|
|
3508
|
+
if (typeof err5 !== "object" || err5 === null) return null;
|
|
3509
|
+
const missing = err5.missing;
|
|
3510
|
+
return Array.isArray(missing) ? missing : null;
|
|
3511
|
+
}
|
|
2383
3512
|
async function handleAccounts(req, res, method, rest, deps) {
|
|
2384
3513
|
if (method === "GET" && rest.length === 0) {
|
|
2385
3514
|
const accounts = await deps.subscriptionAccounts.listAll();
|
|
2386
3515
|
const providerAccounts = await deps.subscriptionTokenWriter.listSanitizedAccounts();
|
|
2387
3516
|
const externalCli = await deps.subscriptionTokenWriter.listExternalCliAvailability();
|
|
2388
|
-
return
|
|
3517
|
+
return writeJson2(res, 200, { accounts, providerAccounts, externalCli });
|
|
2389
3518
|
}
|
|
2390
3519
|
if (method === "GET" && rest[0] === "codex" && rest[1] === "oauth" && rest[3] === "status") {
|
|
2391
3520
|
const result = handleCodexOAuthStatus(rest[2], deps);
|
|
2392
|
-
return
|
|
3521
|
+
return writeJson2(res, result.status, result.body);
|
|
2393
3522
|
}
|
|
2394
3523
|
if (method === "PUT" || method === "POST" || method === "DELETE") {
|
|
2395
3524
|
const providerId = asSubscriptionProviderId(rest[0]);
|
|
@@ -2398,15 +3527,15 @@ async function handleAccounts(req, res, method, rest, deps) {
|
|
|
2398
3527
|
}
|
|
2399
3528
|
if (method === "POST" && rest[1] === "oauth" && rest[2] === "start") {
|
|
2400
3529
|
const result = providerId === "codex" ? handleCodexOAuthStart(deps) : handleOAuthStart(providerId, deps);
|
|
2401
|
-
return
|
|
3530
|
+
return writeJson2(res, result.status, result.body);
|
|
2402
3531
|
}
|
|
2403
3532
|
if (method === "POST" && rest[1] === "oauth" && rest[2] === "complete") {
|
|
2404
|
-
const body2 = await
|
|
3533
|
+
const body2 = await readJsonBody3(req);
|
|
2405
3534
|
const result = await handleOAuthComplete(providerId, body2, deps);
|
|
2406
|
-
return
|
|
3535
|
+
return writeJson2(res, result.status, result.body);
|
|
2407
3536
|
}
|
|
2408
3537
|
if (method === "POST" && rest[1] === "accounts") {
|
|
2409
|
-
const body2 = await
|
|
3538
|
+
const body2 = await readJsonBody3(req);
|
|
2410
3539
|
const block = validateTokenBody(providerId, body2);
|
|
2411
3540
|
if (!block) {
|
|
2412
3541
|
return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
|
|
@@ -2414,79 +3543,113 @@ async function handleAccounts(req, res, method, rest, deps) {
|
|
|
2414
3543
|
const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
|
|
2415
3544
|
await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block, label);
|
|
2416
3545
|
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2417
|
-
return
|
|
3546
|
+
return writeJson2(res, 200, status2 ? { account: status2 } : { ok: true });
|
|
2418
3547
|
}
|
|
2419
3548
|
if (method === "POST" && rest[1] === "import-external") {
|
|
2420
3549
|
if (providerId !== "claude" && providerId !== "codex") {
|
|
2421
3550
|
return writeJsonError(res, 400, `provider '${providerId}' has no external CLI store`);
|
|
2422
3551
|
}
|
|
2423
|
-
const body2 = await
|
|
3552
|
+
const body2 = await readJsonBody3(req);
|
|
2424
3553
|
const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
|
|
2425
3554
|
const result = await deps.subscriptionTokenWriter.importExternalCliAccount(providerId, label);
|
|
2426
3555
|
if (!result.ok) {
|
|
2427
3556
|
return writeJsonError(res, 409, `no usable external ${providerId} CLI credential found`);
|
|
2428
3557
|
}
|
|
2429
3558
|
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2430
|
-
return
|
|
3559
|
+
return writeJson2(res, 200, { ok: true, account: status2 ?? void 0 });
|
|
2431
3560
|
}
|
|
2432
3561
|
if (method === "POST" && rest[1] === "refresh") {
|
|
2433
3562
|
if (providerId === "opencodego") {
|
|
2434
3563
|
return writeJsonError(res, 400, "opencodego credentials are not refreshable");
|
|
2435
3564
|
}
|
|
2436
|
-
const
|
|
2437
|
-
const ok = providerId === "claude" ? await
|
|
3565
|
+
const writer2 = deps.subscriptionTokenWriter;
|
|
3566
|
+
const ok = providerId === "claude" ? await writer2.refreshClaudeToken() : providerId === "codex" ? await writer2.refreshCodexToken() : await writer2.refreshGeminiToken();
|
|
2438
3567
|
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2439
|
-
return
|
|
3568
|
+
return writeJson2(res, 200, { ok, account: status2 ?? void 0 });
|
|
2440
3569
|
}
|
|
2441
3570
|
if (method === "POST" && rest[2] === "label") {
|
|
2442
3571
|
const accountId = rest[1];
|
|
2443
|
-
const body2 = await
|
|
3572
|
+
const body2 = await readJsonBody3(req);
|
|
2444
3573
|
const label = typeof body2["label"] === "string" ? body2["label"].trim() : "";
|
|
2445
3574
|
const result = await deps.subscriptionTokenWriter.renameAccount(providerId, accountId, label);
|
|
2446
3575
|
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
2447
|
-
return
|
|
3576
|
+
return writeJson2(res, 200, { ok: true });
|
|
3577
|
+
}
|
|
3578
|
+
if (method === "POST" && rest[2] === "priority") {
|
|
3579
|
+
const accountId = rest[1];
|
|
3580
|
+
const body2 = await readJsonBody3(req);
|
|
3581
|
+
const raw = body2["priority"];
|
|
3582
|
+
const priority = typeof raw === "number" ? raw : Number(raw);
|
|
3583
|
+
if (!Number.isFinite(priority)) {
|
|
3584
|
+
return writeJsonError(res, 400, "priority must be a finite number");
|
|
3585
|
+
}
|
|
3586
|
+
const result = await deps.subscriptionTokenWriter.setAccountPriority(providerId, accountId, priority);
|
|
3587
|
+
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
3588
|
+
return writeJson2(res, 200, { ok: true });
|
|
3589
|
+
}
|
|
3590
|
+
if (method === "POST" && rest[2] === "proxy") {
|
|
3591
|
+
const accountId = rest[1];
|
|
3592
|
+
const body2 = await readJsonBody3(req);
|
|
3593
|
+
const rawProxy = body2["proxy"];
|
|
3594
|
+
let proxy;
|
|
3595
|
+
if (rawProxy !== null && rawProxy !== void 0) {
|
|
3596
|
+
proxy = normalizeProxyConfig(rawProxy);
|
|
3597
|
+
if (!proxy) return writeJsonError(res, 400, "invalid proxy config");
|
|
3598
|
+
}
|
|
3599
|
+
const result = await deps.subscriptionTokenWriter.setAccountProxy(providerId, accountId, proxy);
|
|
3600
|
+
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
3601
|
+
return writeJson2(res, 200, { ok: true });
|
|
3602
|
+
}
|
|
3603
|
+
if (method === "POST" && rest[2] === "supported-models") {
|
|
3604
|
+
const accountId = rest[1];
|
|
3605
|
+
const body2 = await readJsonBody3(req);
|
|
3606
|
+
const parsed = validateSupportedModelsBody(body2["supportedModels"]);
|
|
3607
|
+
if (!parsed.ok) return writeJsonError(res, 400, "invalid supportedModels");
|
|
3608
|
+
const result = await deps.subscriptionTokenWriter.setAccountSupportedModels(providerId, accountId, parsed.value);
|
|
3609
|
+
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
3610
|
+
return writeJson2(res, 200, { ok: true });
|
|
2448
3611
|
}
|
|
2449
3612
|
if (method === "PUT" && rest[1] === "active") {
|
|
2450
|
-
const body2 = await
|
|
3613
|
+
const body2 = await readJsonBody3(req);
|
|
2451
3614
|
const id = typeof body2["id"] === "string" ? body2["id"] : "";
|
|
2452
3615
|
if (!id) return writeJsonError(res, 400, "active switch requires { id }");
|
|
2453
3616
|
const result = await deps.subscriptionTokenWriter.setActiveAccount(providerId, id);
|
|
2454
3617
|
if (!result.ok) return writeJsonError(res, 404, `account '${id}' not found`);
|
|
2455
|
-
return
|
|
3618
|
+
return writeJson2(res, 200, { ok: true });
|
|
2456
3619
|
}
|
|
2457
3620
|
if (method === "DELETE" && rest.length >= 2) {
|
|
2458
3621
|
const accountId = rest[1];
|
|
2459
3622
|
const result = await deps.subscriptionTokenWriter.removeAccount(providerId, accountId);
|
|
2460
3623
|
if (!result.removed) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
2461
|
-
return
|
|
3624
|
+
return writeJson2(res, 200, { ok: true });
|
|
2462
3625
|
}
|
|
2463
3626
|
if (method === "DELETE") {
|
|
2464
3627
|
await deps.subscriptionTokenWriter.clearProvider(providerId);
|
|
2465
|
-
return
|
|
3628
|
+
return writeJson2(res, 200, { ok: true });
|
|
2466
3629
|
}
|
|
2467
|
-
const body = await
|
|
3630
|
+
const body = await readJsonBody3(req);
|
|
2468
3631
|
const config = validateTokenBody(providerId, body);
|
|
2469
3632
|
if (!config) {
|
|
2470
3633
|
return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
|
|
2471
3634
|
}
|
|
2472
3635
|
await deps.subscriptionTokenWriter.writeProviderTokens(providerId, config);
|
|
2473
3636
|
const status = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2474
|
-
return
|
|
3637
|
+
return writeJson2(res, 200, status ? { account: status } : { ok: true });
|
|
2475
3638
|
}
|
|
2476
3639
|
return writeJsonError(res, 405, `method ${method} not allowed on accounts`);
|
|
2477
3640
|
}
|
|
2478
3641
|
async function handleCli(req, res, method, rest, deps) {
|
|
2479
3642
|
if (method === "GET" && rest.length === 0) {
|
|
2480
3643
|
const result = handleCliList(process.platform, deps.cliPathProbe);
|
|
2481
|
-
return
|
|
3644
|
+
return writeJson2(res, result.status, result.body);
|
|
2482
3645
|
}
|
|
2483
3646
|
if (method === "GET" && rest[0] === "sessions") {
|
|
2484
3647
|
const result = handleCliSessions();
|
|
2485
|
-
return
|
|
3648
|
+
return writeJson2(res, result.status, result.body);
|
|
2486
3649
|
}
|
|
2487
3650
|
if (method === "DELETE" && rest[0] === "sessions" && rest[1]) {
|
|
2488
3651
|
const result = handleCliStop(rest[1]);
|
|
2489
|
-
return
|
|
3652
|
+
return writeJson2(res, result.status, result.body);
|
|
2490
3653
|
}
|
|
2491
3654
|
if (method === "POST" && rest[1] === "install") {
|
|
2492
3655
|
const cli = rest[0];
|
|
@@ -2494,14 +3657,14 @@ async function handleCli(req, res, method, rest, deps) {
|
|
|
2494
3657
|
return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
|
|
2495
3658
|
}
|
|
2496
3659
|
const result = await handleCliInstall(cli, deps.cliCommandRunner);
|
|
2497
|
-
return
|
|
3660
|
+
return writeJson2(res, result.status, result.body);
|
|
2498
3661
|
}
|
|
2499
3662
|
if (method === "POST" && rest[1] === "launch") {
|
|
2500
3663
|
const cli = rest[0];
|
|
2501
3664
|
if (!isLaunchCliId(cli)) {
|
|
2502
3665
|
return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
|
|
2503
3666
|
}
|
|
2504
|
-
const body = await
|
|
3667
|
+
const body = await readJsonBody3(req);
|
|
2505
3668
|
const providers = loadConfig(deps.configPath).providers ?? [];
|
|
2506
3669
|
const result = await handleCliLaunch(cli, body, {
|
|
2507
3670
|
llmConfig: deps.llmConfig,
|
|
@@ -2509,20 +3672,28 @@ async function handleCli(req, res, method, rest, deps) {
|
|
|
2509
3672
|
opener: deps.cliTerminalOpener,
|
|
2510
3673
|
probe: deps.cliPathProbe
|
|
2511
3674
|
});
|
|
2512
|
-
return
|
|
3675
|
+
return writeJson2(res, result.status, result.body);
|
|
2513
3676
|
}
|
|
2514
3677
|
return writeJsonError(res, 405, `method ${method} not allowed on cli`);
|
|
2515
3678
|
}
|
|
2516
3679
|
async function handleStatus(res, method, deps) {
|
|
2517
3680
|
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on status`);
|
|
2518
3681
|
const status = deps.outboundApiServer.getStatus();
|
|
2519
|
-
const serverConfig = await
|
|
2520
|
-
const endpoints = serverConfig.endpoints.map((e) =>
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
3682
|
+
const serverConfig = await loadServerConfig2(deps.settingsStore);
|
|
3683
|
+
const endpoints = serverConfig.endpoints.map((e) => {
|
|
3684
|
+
if (isKindMappedEndpoint(e.endpoint)) {
|
|
3685
|
+
return { endpoint: e.endpoint, kinds: e.modelMap ?? {}, useSubscription: e.useSubscription };
|
|
3686
|
+
}
|
|
3687
|
+
if (e.endpoint === "chat") {
|
|
3688
|
+
return { endpoint: e.endpoint, models: e.models ?? [], useSubscription: e.useSubscription };
|
|
3689
|
+
}
|
|
3690
|
+
return { endpoint: e.endpoint, model: e.defaultModel ?? "", useSubscription: e.useSubscription };
|
|
3691
|
+
});
|
|
3692
|
+
if (status.running) {
|
|
3693
|
+
const queueStatus = deps.outboundApiServer.getQueueStatus();
|
|
3694
|
+
return writeJson2(res, 200, { ...status, endpoints, queueStatus });
|
|
3695
|
+
}
|
|
3696
|
+
return writeJson2(res, 200, { ...status, endpoints });
|
|
2526
3697
|
}
|
|
2527
3698
|
function resolvePlaygroundPath(endpoint, body) {
|
|
2528
3699
|
switch (endpoint) {
|
|
@@ -2542,7 +3713,7 @@ function resolvePlaygroundPath(endpoint, body) {
|
|
|
2542
3713
|
}
|
|
2543
3714
|
async function handlePlayground(req, res, method, deps) {
|
|
2544
3715
|
if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on playground`);
|
|
2545
|
-
const body = await
|
|
3716
|
+
const body = await readJsonBody3(req);
|
|
2546
3717
|
const endpoint = typeof body["endpoint"] === "string" ? body["endpoint"] : "";
|
|
2547
3718
|
const key = typeof body["key"] === "string" ? body["key"] : "";
|
|
2548
3719
|
const payload = body["body"];
|
|
@@ -2687,10 +3858,12 @@ async function handleUiStatic(req, res, urlPath, uiDist) {
|
|
|
2687
3858
|
return true;
|
|
2688
3859
|
}
|
|
2689
3860
|
|
|
3861
|
+
// src/admin/version.ts
|
|
3862
|
+
var DAEMON_VERSION = true ? "0.1.3" : "0.0.0-dev";
|
|
3863
|
+
|
|
2690
3864
|
// src/admin/AdminServer.ts
|
|
2691
3865
|
var LOOPBACK_ADDR = "127.0.0.1";
|
|
2692
3866
|
var LAN_ADDR = "0.0.0.0";
|
|
2693
|
-
var DAEMON_VERSION = true ? "0.1.2" : "0.0.0-dev";
|
|
2694
3867
|
var AdminServer = class {
|
|
2695
3868
|
constructor(deps) {
|
|
2696
3869
|
this.deps = deps;
|
|
@@ -2711,7 +3884,7 @@ var AdminServer = class {
|
|
|
2711
3884
|
const cfg = this.deps.getAdminConfig();
|
|
2712
3885
|
if (!cfg.enabled) return 0;
|
|
2713
3886
|
if (cfg.networkBinding && !cfg.token) {
|
|
2714
|
-
|
|
3887
|
+
this.deps.logger.error(
|
|
2715
3888
|
"[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)."
|
|
2716
3889
|
);
|
|
2717
3890
|
return 0;
|
|
@@ -2720,7 +3893,7 @@ var AdminServer = class {
|
|
|
2720
3893
|
const actualPort = await this.listen(bindAddr, cfg.port);
|
|
2721
3894
|
this.boundAddr = bindAddr;
|
|
2722
3895
|
this.boundPort = actualPort;
|
|
2723
|
-
|
|
3896
|
+
this.deps.logger.info(`[AdminServer] Dashboard listening on ${bindAddr}:${actualPort}`);
|
|
2724
3897
|
return actualPort;
|
|
2725
3898
|
}
|
|
2726
3899
|
/** Bind once; on EADDRINUSE retry with an ephemeral port (port 0). */
|
|
@@ -2742,7 +3915,7 @@ var AdminServer = class {
|
|
|
2742
3915
|
const addr = server.address();
|
|
2743
3916
|
if (addr && typeof addr === "object") {
|
|
2744
3917
|
server.removeListener("error", onError);
|
|
2745
|
-
server.on("error", (e) =>
|
|
3918
|
+
server.on("error", (e) => this.deps.logger.error("[AdminServer] server error", e));
|
|
2746
3919
|
this.server = server;
|
|
2747
3920
|
resolve(addr.port);
|
|
2748
3921
|
} else {
|
|
@@ -2755,7 +3928,7 @@ var AdminServer = class {
|
|
|
2755
3928
|
onRequest(req, res) {
|
|
2756
3929
|
void this.dispatch(req, res).catch((err5) => {
|
|
2757
3930
|
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
2758
|
-
|
|
3931
|
+
this.deps.logger.error("[AdminServer] unhandled error:", message);
|
|
2759
3932
|
if (!res.headersSent) {
|
|
2760
3933
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2761
3934
|
res.end(JSON.stringify({ error: { type: "admin_error", message } }));
|
|
@@ -2766,18 +3939,42 @@ var AdminServer = class {
|
|
|
2766
3939
|
const cfg = this.deps.getAdminConfig();
|
|
2767
3940
|
res.setHeader("x-omnicross-daemon", DAEMON_VERSION);
|
|
2768
3941
|
res.setHeader("x-omnicross-pid", String(process.pid));
|
|
3942
|
+
const url = req.url ?? "/";
|
|
3943
|
+
const path2 = url.split("?")[0];
|
|
3944
|
+
const healthPath = path2.replace(/\/+$/, "") || "/";
|
|
3945
|
+
if ((req.method === "GET" || req.method === "HEAD") && (healthPath === "/health" || healthPath === "/healthz")) {
|
|
3946
|
+
const report = this.deps.getHealthReport();
|
|
3947
|
+
const code = healthHttpStatus(report.status);
|
|
3948
|
+
res.writeHead(code, { "Content-Type": "application/json" });
|
|
3949
|
+
res.end(req.method === "HEAD" ? void 0 : JSON.stringify(report));
|
|
3950
|
+
return;
|
|
3951
|
+
}
|
|
2769
3952
|
if (cfg.token && !this.isAuthorized(req, cfg.token)) {
|
|
2770
3953
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
2771
3954
|
res.end(JSON.stringify({ error: { type: "unauthorized", message: "admin token required" } }));
|
|
2772
3955
|
return;
|
|
2773
3956
|
}
|
|
2774
|
-
const url = req.url ?? "/";
|
|
2775
|
-
const path2 = url.split("?")[0];
|
|
2776
3957
|
if ((req.method === "GET" || req.method === "HEAD") && (path2 === "/" || path2 === "/admin")) {
|
|
2777
3958
|
res.writeHead(302, { Location: "/ui/" });
|
|
2778
3959
|
res.end();
|
|
2779
3960
|
return;
|
|
2780
3961
|
}
|
|
3962
|
+
if (path2 === "/admin/api/account-probes" && (req.method === "GET" || req.method === "HEAD")) {
|
|
3963
|
+
handleAccountProbes(res, this.deps.probeHistoryReader);
|
|
3964
|
+
return;
|
|
3965
|
+
}
|
|
3966
|
+
if (path2 === "/admin/api/audit" && (req.method === "GET" || req.method === "HEAD")) {
|
|
3967
|
+
handleAuditQuery(req, res, this.deps.auditReader);
|
|
3968
|
+
return;
|
|
3969
|
+
}
|
|
3970
|
+
if (path2 === "/admin/api/billing-status" && (req.method === "GET" || req.method === "HEAD")) {
|
|
3971
|
+
handleBillingStatus(res, this.deps.billingStatusReader);
|
|
3972
|
+
return;
|
|
3973
|
+
}
|
|
3974
|
+
if (path2 === "/admin/api/webhook-test" && req.method === "POST") {
|
|
3975
|
+
await handleWebhookTest(req, res);
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
2781
3978
|
if (path2.startsWith("/admin/api/")) {
|
|
2782
3979
|
await handleAdminApi(req, res, path2, this.deps);
|
|
2783
3980
|
return;
|
|
@@ -2821,6 +4018,51 @@ function constantTimeEquals(a, b) {
|
|
|
2821
4018
|
return timingSafeEqual(bufA, bufB);
|
|
2822
4019
|
}
|
|
2823
4020
|
|
|
4021
|
+
// src/admin/health.ts
|
|
4022
|
+
var CRITICAL_CHECKS = ["config", "credentialStore"];
|
|
4023
|
+
var READINESS_CHECKS = ["outboundServer"];
|
|
4024
|
+
function safeBool(fn) {
|
|
4025
|
+
try {
|
|
4026
|
+
return fn() === true;
|
|
4027
|
+
} catch {
|
|
4028
|
+
return false;
|
|
4029
|
+
}
|
|
4030
|
+
}
|
|
4031
|
+
function toMb(bytes) {
|
|
4032
|
+
return Math.round(bytes / (1024 * 1024) * 10) / 10;
|
|
4033
|
+
}
|
|
4034
|
+
function buildHealthReport(deps) {
|
|
4035
|
+
const checks = {
|
|
4036
|
+
config: safeBool(deps.configPresent),
|
|
4037
|
+
credentialStore: safeBool(deps.credentialStoreReadable),
|
|
4038
|
+
outboundServer: safeBool(deps.outboundServerRunning),
|
|
4039
|
+
adminServer: safeBool(deps.adminServerRunning)
|
|
4040
|
+
};
|
|
4041
|
+
if (deps.subscriptionAccountsHealthy) {
|
|
4042
|
+
let probeHealthy;
|
|
4043
|
+
try {
|
|
4044
|
+
probeHealthy = deps.subscriptionAccountsHealthy();
|
|
4045
|
+
} catch {
|
|
4046
|
+
probeHealthy = false;
|
|
4047
|
+
}
|
|
4048
|
+
if (probeHealthy !== void 0) checks.subscriptionAccountsHealthy = probeHealthy;
|
|
4049
|
+
}
|
|
4050
|
+
const criticalOk = CRITICAL_CHECKS.every((k) => checks[k]);
|
|
4051
|
+
const readinessOk = READINESS_CHECKS.every((k) => checks[k]);
|
|
4052
|
+
const status = !criticalOk ? "error" : readinessOk ? "ok" : "degraded";
|
|
4053
|
+
const mem = (deps.memoryUsage ?? process.memoryUsage)();
|
|
4054
|
+
const uptime = (deps.uptimeSeconds ?? process.uptime)();
|
|
4055
|
+
const nowMs = (deps.now ?? Date.now)();
|
|
4056
|
+
return {
|
|
4057
|
+
status,
|
|
4058
|
+
version: deps.version,
|
|
4059
|
+
uptimeSeconds: Math.floor(uptime),
|
|
4060
|
+
timestamp: new Date(nowMs).toISOString(),
|
|
4061
|
+
memory: { rssMb: toMb(mem.rss), heapUsedMb: toMb(mem.heapUsed) },
|
|
4062
|
+
checks
|
|
4063
|
+
};
|
|
4064
|
+
}
|
|
4065
|
+
|
|
2824
4066
|
// src/admin/oauthSessions.ts
|
|
2825
4067
|
import crypto2 from "crypto";
|
|
2826
4068
|
var DEFAULT_OAUTH_SESSION_TTL_MS = 10 * 60 * 1e3;
|
|
@@ -2994,12 +4236,21 @@ function createPoolKeysLoader(getProviderRow, autoDisabled) {
|
|
|
2994
4236
|
|
|
2995
4237
|
// src/commands/paths.ts
|
|
2996
4238
|
import { dirname as dirname2, join as join3 } from "path";
|
|
4239
|
+
function defaultVouchersPath(configPath) {
|
|
4240
|
+
return join3(dirname2(configPath), "vouchers.json");
|
|
4241
|
+
}
|
|
2997
4242
|
function defaultPricingPath(configPath) {
|
|
2998
4243
|
return join3(dirname2(configPath), "pricing.json");
|
|
2999
4244
|
}
|
|
3000
4245
|
function defaultUsageEventsPath(configPath) {
|
|
3001
4246
|
return join3(dirname2(configPath), "usage-events.jsonl");
|
|
3002
4247
|
}
|
|
4248
|
+
function defaultAuditDir(configPath) {
|
|
4249
|
+
return join3(dirname2(configPath), "audit");
|
|
4250
|
+
}
|
|
4251
|
+
function defaultBillingDir(configPath) {
|
|
4252
|
+
return join3(dirname2(configPath), "billing");
|
|
4253
|
+
}
|
|
3003
4254
|
|
|
3004
4255
|
// src/ports/ConfigFileProviderConfigSource.ts
|
|
3005
4256
|
import {
|
|
@@ -3161,46 +4412,199 @@ function toLLMProvider(row) {
|
|
|
3161
4412
|
};
|
|
3162
4413
|
}
|
|
3163
4414
|
|
|
3164
|
-
// src/ports/
|
|
3165
|
-
|
|
4415
|
+
// src/ports/ConfigurableLogger.ts
|
|
4416
|
+
import { createWriteStream } from "fs";
|
|
4417
|
+
var LEVEL_ORDER = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
4418
|
+
var RESERVED_JSON_KEYS = /* @__PURE__ */ new Set(["ts", "level", "msg", "error"]);
|
|
4419
|
+
var ConfigurableLogger = class {
|
|
4420
|
+
threshold;
|
|
4421
|
+
format;
|
|
4422
|
+
filePath;
|
|
4423
|
+
fileStream = null;
|
|
4424
|
+
fileDisabled = false;
|
|
4425
|
+
constructor(cfg) {
|
|
4426
|
+
this.threshold = LEVEL_ORDER[cfg?.level ?? "debug"];
|
|
4427
|
+
this.format = cfg?.format ?? "text";
|
|
4428
|
+
this.filePath = cfg?.file && cfg.file.length > 0 ? cfg.file : void 0;
|
|
4429
|
+
}
|
|
3166
4430
|
info(message, meta) {
|
|
3167
|
-
|
|
3168
|
-
else console.info(message, meta);
|
|
4431
|
+
this.emit("info", message, void 0, meta);
|
|
3169
4432
|
}
|
|
3170
4433
|
warn(message, meta) {
|
|
3171
|
-
|
|
3172
|
-
else console.warn(message, meta);
|
|
4434
|
+
this.emit("warn", message, void 0, meta);
|
|
3173
4435
|
}
|
|
3174
4436
|
error(message, error, meta) {
|
|
3175
|
-
|
|
3176
|
-
else if (meta === void 0) console.error(message, error);
|
|
3177
|
-
else console.error(message, error, meta);
|
|
4437
|
+
this.emit("error", message, error, meta);
|
|
3178
4438
|
}
|
|
3179
4439
|
debug(message, meta) {
|
|
3180
|
-
|
|
3181
|
-
|
|
4440
|
+
this.emit("debug", message, void 0, meta);
|
|
4441
|
+
}
|
|
4442
|
+
/**
|
|
4443
|
+
* Flush + close the file sink (tests / graceful shutdown). Resolves once the
|
|
4444
|
+
* append stream has finished flushing to disk. No-op when no file sink is open.
|
|
4445
|
+
*/
|
|
4446
|
+
close() {
|
|
4447
|
+
const stream = this.fileStream;
|
|
4448
|
+
this.fileStream = null;
|
|
4449
|
+
if (!stream) return Promise.resolve();
|
|
4450
|
+
return new Promise((resolve) => stream.end(() => resolve()));
|
|
4451
|
+
}
|
|
4452
|
+
emit(level, message, error, meta) {
|
|
4453
|
+
if (LEVEL_ORDER[level] > this.threshold) return;
|
|
4454
|
+
this.writeConsole(level, message, error, meta);
|
|
4455
|
+
if (this.filePath) this.writeFile(level, message, error, meta);
|
|
4456
|
+
}
|
|
4457
|
+
/**
|
|
4458
|
+
* Console sink. In `text` format this reproduces the legacy `ConsoleLogger`
|
|
4459
|
+
* EXACTLY (same method + arg shape) so the unconfigured default is a byte-for-
|
|
4460
|
+
* byte drop-in; in `json` format it prints the structured line.
|
|
4461
|
+
*/
|
|
4462
|
+
writeConsole(level, message, error, meta) {
|
|
4463
|
+
if (this.format === "json") {
|
|
4464
|
+
this.consoleFn(level)(this.jsonLine(level, message, error, meta));
|
|
4465
|
+
return;
|
|
4466
|
+
}
|
|
4467
|
+
if (level === "error") {
|
|
4468
|
+
if (error === void 0 && meta === void 0) console.error(message);
|
|
4469
|
+
else if (meta === void 0) console.error(message, error);
|
|
4470
|
+
else console.error(message, error, meta);
|
|
4471
|
+
return;
|
|
4472
|
+
}
|
|
4473
|
+
const fn = this.consoleFn(level);
|
|
4474
|
+
if (meta === void 0) fn(message);
|
|
4475
|
+
else fn(message, meta);
|
|
4476
|
+
}
|
|
4477
|
+
/** Append one line to the file sink; a failure disables the sink (swallowed). */
|
|
4478
|
+
writeFile(level, message, error, meta) {
|
|
4479
|
+
const stream = this.getFileStream();
|
|
4480
|
+
if (!stream) return;
|
|
4481
|
+
try {
|
|
4482
|
+
const line = this.format === "json" ? this.jsonLine(level, message, error, meta) : this.textLine(level, message, error, meta);
|
|
4483
|
+
stream.write(line + "\n");
|
|
4484
|
+
} catch {
|
|
4485
|
+
}
|
|
4486
|
+
}
|
|
4487
|
+
/** Lazily open the append-only file stream; disable the sink on any error. */
|
|
4488
|
+
getFileStream() {
|
|
4489
|
+
if (this.fileDisabled || !this.filePath) return null;
|
|
4490
|
+
if (this.fileStream) return this.fileStream;
|
|
4491
|
+
try {
|
|
4492
|
+
const stream = createWriteStream(this.filePath, { flags: "a" });
|
|
4493
|
+
stream.on("error", () => {
|
|
4494
|
+
this.fileDisabled = true;
|
|
4495
|
+
this.fileStream = null;
|
|
4496
|
+
});
|
|
4497
|
+
this.fileStream = stream;
|
|
4498
|
+
return stream;
|
|
4499
|
+
} catch {
|
|
4500
|
+
this.fileDisabled = true;
|
|
4501
|
+
return null;
|
|
4502
|
+
}
|
|
4503
|
+
}
|
|
4504
|
+
consoleFn(level) {
|
|
4505
|
+
switch (level) {
|
|
4506
|
+
case "error":
|
|
4507
|
+
return console.error;
|
|
4508
|
+
case "warn":
|
|
4509
|
+
return console.warn;
|
|
4510
|
+
case "info":
|
|
4511
|
+
return console.info;
|
|
4512
|
+
case "debug":
|
|
4513
|
+
return console.debug;
|
|
4514
|
+
}
|
|
4515
|
+
}
|
|
4516
|
+
/** `{ ts, level, msg, ...meta }` (+ `error` when present) as a single line. */
|
|
4517
|
+
jsonLine(level, message, error, meta) {
|
|
4518
|
+
const obj = {
|
|
4519
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4520
|
+
level,
|
|
4521
|
+
msg: message
|
|
4522
|
+
};
|
|
4523
|
+
if (error !== void 0) obj["error"] = reduceError(error);
|
|
4524
|
+
if (meta !== void 0) {
|
|
4525
|
+
if (meta instanceof Error) obj["meta"] = reduceError(meta);
|
|
4526
|
+
else if (meta && typeof meta === "object") {
|
|
4527
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
4528
|
+
if (!RESERVED_JSON_KEYS.has(k)) obj[k] = v;
|
|
4529
|
+
}
|
|
4530
|
+
} else obj["meta"] = meta;
|
|
4531
|
+
}
|
|
4532
|
+
try {
|
|
4533
|
+
return JSON.stringify(obj);
|
|
4534
|
+
} catch {
|
|
4535
|
+
return JSON.stringify({ ts: obj["ts"], level, msg: message });
|
|
4536
|
+
}
|
|
4537
|
+
}
|
|
4538
|
+
/** Human-readable file line: `ISO [level] message {metaJson}`. */
|
|
4539
|
+
textLine(level, message, error, meta) {
|
|
4540
|
+
const parts = [`${(/* @__PURE__ */ new Date()).toISOString()} [${level}] ${message}`];
|
|
4541
|
+
if (error !== void 0) parts.push(safeStringify(reduceError(error)));
|
|
4542
|
+
if (meta !== void 0) parts.push(safeStringify(meta instanceof Error ? reduceError(meta) : meta));
|
|
4543
|
+
return parts.join(" ");
|
|
3182
4544
|
}
|
|
3183
4545
|
};
|
|
4546
|
+
function reduceError(error) {
|
|
4547
|
+
if (error instanceof Error) {
|
|
4548
|
+
return error.stack ? { message: error.message, stack: error.stack } : { message: error.message };
|
|
4549
|
+
}
|
|
4550
|
+
return { value: String(error) };
|
|
4551
|
+
}
|
|
4552
|
+
function safeStringify(value) {
|
|
4553
|
+
try {
|
|
4554
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
4555
|
+
} catch {
|
|
4556
|
+
return "[unserializable]";
|
|
4557
|
+
}
|
|
4558
|
+
}
|
|
3184
4559
|
|
|
3185
4560
|
// src/ports/JsonApiServerSettingsStore.ts
|
|
3186
4561
|
import { readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
3187
4562
|
import { OUTBOUND_API_SERVER_CONFIG_KEY } from "@omnicross/core/outbound-api";
|
|
3188
4563
|
var JsonApiServerSettingsStore = class {
|
|
3189
|
-
|
|
4564
|
+
/**
|
|
4565
|
+
* @param configPath the daemon config.json whose `server` field is backed.
|
|
4566
|
+
* @param box OPTIONAL at-rest `SecretBox` (upstream-proxy). When set, the
|
|
4567
|
+
* `server.proxy.*` passwords are encrypted-on-`set` /
|
|
4568
|
+
* decrypted-on-`get` (the settings-store path is otherwise not
|
|
4569
|
+
* secret-aware — every OTHER server field is non-secret). Null
|
|
4570
|
+
* ⇒ passthrough (legacy/pure tests unchanged).
|
|
4571
|
+
*/
|
|
4572
|
+
constructor(configPath, box = null) {
|
|
3190
4573
|
this.configPath = configPath;
|
|
4574
|
+
this.box = box;
|
|
3191
4575
|
}
|
|
3192
4576
|
configPath;
|
|
4577
|
+
box;
|
|
3193
4578
|
async get(key) {
|
|
3194
4579
|
if (key !== OUTBOUND_API_SERVER_CONFIG_KEY) return void 0;
|
|
3195
4580
|
const file = this.readFile();
|
|
3196
|
-
|
|
4581
|
+
if (file.server === void 0) return void 0;
|
|
4582
|
+
return this.decryptSecrets(file.server);
|
|
3197
4583
|
}
|
|
3198
4584
|
async set(key, value) {
|
|
3199
4585
|
if (key !== OUTBOUND_API_SERVER_CONFIG_KEY) return;
|
|
3200
4586
|
const file = this.readFile();
|
|
3201
|
-
file.server = value;
|
|
4587
|
+
file.server = this.encryptSecrets(value);
|
|
3202
4588
|
writeFileSync3(this.configPath, JSON.stringify(file, null, 2) + "\n", "utf8");
|
|
3203
4589
|
}
|
|
4590
|
+
/** Encrypt the proxy passwords + webhook + billing secrets before persisting (no-op without a box). */
|
|
4591
|
+
encryptSecrets(config) {
|
|
4592
|
+
if (!this.box) return config;
|
|
4593
|
+
let out = config;
|
|
4594
|
+
if (out?.proxy) out = { ...out, proxy: encryptProxySegment(out.proxy, this.box) };
|
|
4595
|
+
if (out?.webhook) out = { ...out, webhook: encryptWebhookSegment(out.webhook, this.box) };
|
|
4596
|
+
if (out?.billing) out = { ...out, billing: encryptBillingSegment(out.billing, this.box) };
|
|
4597
|
+
return out;
|
|
4598
|
+
}
|
|
4599
|
+
/** Decrypt the proxy passwords + webhook + billing secrets on read (no-op without a box). */
|
|
4600
|
+
decryptSecrets(config) {
|
|
4601
|
+
if (!this.box) return config;
|
|
4602
|
+
let out = config;
|
|
4603
|
+
if (out?.proxy) out = { ...out, proxy: decryptProxySegment(out.proxy, this.box) };
|
|
4604
|
+
if (out?.webhook) out = { ...out, webhook: decryptWebhookSegment(out.webhook, this.box) };
|
|
4605
|
+
if (out?.billing) out = { ...out, billing: decryptBillingSegment(out.billing, this.box) };
|
|
4606
|
+
return out;
|
|
4607
|
+
}
|
|
3204
4608
|
/** Read the config.json, tolerating a missing/corrupt file (→ empty shape). */
|
|
3205
4609
|
readFile() {
|
|
3206
4610
|
try {
|
|
@@ -3319,6 +4723,57 @@ var JsonlUsageEventStore = class {
|
|
|
3319
4723
|
}
|
|
3320
4724
|
return Array.from(groups.values());
|
|
3321
4725
|
}
|
|
4726
|
+
/**
|
|
4727
|
+
* ONE pass over a single key's events (`ts < endTs`) summing its `costUsd` into
|
|
4728
|
+
* `totalUsd` / `dailyUsd` (`ts >= dayStartTs`) / `weeklyUsd` (`ts >= weekStartTs`).
|
|
4729
|
+
* Used to lazily seed the outbound key-policy spend tracker (once per key). A
|
|
4730
|
+
* key with no attributed events yields all zeros.
|
|
4731
|
+
*/
|
|
4732
|
+
async getSpendByKey(query) {
|
|
4733
|
+
let totalUsd = 0;
|
|
4734
|
+
let dailyUsd = 0;
|
|
4735
|
+
let weeklyUsd = 0;
|
|
4736
|
+
for (const row of this.readRows({ startTs: 0, endTs: query.endTs })) {
|
|
4737
|
+
if (row.apiKeyId !== query.apiKeyId) continue;
|
|
4738
|
+
totalUsd += row.costUsd;
|
|
4739
|
+
if (row.ts >= query.dayStartTs) dailyUsd += row.costUsd;
|
|
4740
|
+
if (row.ts >= query.weekStartTs) weeklyUsd += row.costUsd;
|
|
4741
|
+
}
|
|
4742
|
+
return { totalUsd, dailyUsd, weeklyUsd };
|
|
4743
|
+
}
|
|
4744
|
+
/**
|
|
4745
|
+
* Time-series aggregation over LOCAL-time bucket boundaries. Every bucket in
|
|
4746
|
+
* `[floor(startTs), endTs)` is present (empty ones zero-filled), ascending by
|
|
4747
|
+
* `bucketStartTs`; an empty range (`startTs >= endTs`) returns `[]`. Reuses
|
|
4748
|
+
* `readRows` so malformed lines are skipped and only in-range rows contribute.
|
|
4749
|
+
*/
|
|
4750
|
+
async getTimeSeries(range, bucket) {
|
|
4751
|
+
if (range.startTs >= range.endTs) return [];
|
|
4752
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
4753
|
+
for (let b = floorToBucket(range.startTs, bucket); b < range.endTs; b = nextBoundary(b, bucket)) {
|
|
4754
|
+
buckets.set(b, {
|
|
4755
|
+
bucketStartTs: b,
|
|
4756
|
+
label: bucketLabel(b, bucket),
|
|
4757
|
+
requests: 0,
|
|
4758
|
+
inputTokens: 0,
|
|
4759
|
+
outputTokens: 0,
|
|
4760
|
+
cacheReadTokens: 0,
|
|
4761
|
+
cacheCreationTokens: 0,
|
|
4762
|
+
costUsd: 0
|
|
4763
|
+
});
|
|
4764
|
+
}
|
|
4765
|
+
for (const row of this.readRows(range)) {
|
|
4766
|
+
const g = buckets.get(floorToBucket(row.ts, bucket));
|
|
4767
|
+
if (!g) continue;
|
|
4768
|
+
g.requests += 1;
|
|
4769
|
+
g.inputTokens += row.inputTokens;
|
|
4770
|
+
g.outputTokens += row.outputTokens;
|
|
4771
|
+
g.cacheReadTokens += row.cacheReadTokens;
|
|
4772
|
+
g.cacheCreationTokens += row.cacheCreationTokens;
|
|
4773
|
+
g.costUsd += row.costUsd;
|
|
4774
|
+
}
|
|
4775
|
+
return Array.from(buckets.values());
|
|
4776
|
+
}
|
|
3322
4777
|
async getMessagesForSession(sessionId) {
|
|
3323
4778
|
return this.readAllRows().filter((r) => r.sessionId === sessionId).sort((a, b) => a.ts - b.ts).map((r) => ({
|
|
3324
4779
|
id: r.id,
|
|
@@ -3387,6 +4842,43 @@ var JsonlUsageEventStore = class {
|
|
|
3387
4842
|
return rows;
|
|
3388
4843
|
}
|
|
3389
4844
|
};
|
|
4845
|
+
function floorToBucket(ts, bucket) {
|
|
4846
|
+
const d = new Date(ts);
|
|
4847
|
+
switch (bucket) {
|
|
4848
|
+
case "hour":
|
|
4849
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()).getTime();
|
|
4850
|
+
case "day":
|
|
4851
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
4852
|
+
case "month":
|
|
4853
|
+
return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
|
|
4854
|
+
}
|
|
4855
|
+
}
|
|
4856
|
+
function nextBoundary(ts, bucket) {
|
|
4857
|
+
const d = new Date(ts);
|
|
4858
|
+
switch (bucket) {
|
|
4859
|
+
case "hour":
|
|
4860
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours() + 1).getTime();
|
|
4861
|
+
case "day":
|
|
4862
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime();
|
|
4863
|
+
case "month":
|
|
4864
|
+
return new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
|
|
4865
|
+
}
|
|
4866
|
+
}
|
|
4867
|
+
var pad2 = (n) => String(n).padStart(2, "0");
|
|
4868
|
+
function bucketLabel(bucketStartTs, bucket) {
|
|
4869
|
+
const d = new Date(bucketStartTs);
|
|
4870
|
+
const y = d.getFullYear();
|
|
4871
|
+
const mo = pad2(d.getMonth() + 1);
|
|
4872
|
+
const day = pad2(d.getDate());
|
|
4873
|
+
switch (bucket) {
|
|
4874
|
+
case "hour":
|
|
4875
|
+
return `${mo}-${day} ${pad2(d.getHours())}:00`;
|
|
4876
|
+
case "day":
|
|
4877
|
+
return `${y}-${mo}-${day}`;
|
|
4878
|
+
case "month":
|
|
4879
|
+
return `${y}-${mo}`;
|
|
4880
|
+
}
|
|
4881
|
+
}
|
|
3390
4882
|
var NUMERIC_FIELDS = [
|
|
3391
4883
|
"ts",
|
|
3392
4884
|
"inputTokens",
|
|
@@ -3470,6 +4962,45 @@ var JsonOutboundKeyDb = class {
|
|
|
3470
4962
|
return true;
|
|
3471
4963
|
});
|
|
3472
4964
|
}
|
|
4965
|
+
async outboundApiKeysSetMaxConcurrency(id, maxConcurrency) {
|
|
4966
|
+
return this.mutateRow(id, (row) => {
|
|
4967
|
+
if (row.revokedAt !== null) return false;
|
|
4968
|
+
if (maxConcurrency === null) delete row.maxConcurrency;
|
|
4969
|
+
else row.maxConcurrency = maxConcurrency;
|
|
4970
|
+
return true;
|
|
4971
|
+
});
|
|
4972
|
+
}
|
|
4973
|
+
async outboundApiKeysSetPolicy(id, policy) {
|
|
4974
|
+
return this.mutateRow(id, (row) => {
|
|
4975
|
+
if (row.revokedAt !== null) return false;
|
|
4976
|
+
applyPolicyField(row, "expiresAt", policy.expiresAt);
|
|
4977
|
+
applyPolicyField(row, "activationDays", policy.activationDays);
|
|
4978
|
+
applyPolicyField(row, "dailyCostLimitUsd", policy.dailyCostLimitUsd);
|
|
4979
|
+
applyPolicyField(row, "totalCostLimitUsd", policy.totalCostLimitUsd);
|
|
4980
|
+
applyPolicyField(row, "weeklyCostLimitUsd", policy.weeklyCostLimitUsd);
|
|
4981
|
+
applyPolicyField(row, "rateLimitMaxRequests", policy.rateLimitMaxRequests);
|
|
4982
|
+
applyPolicyField(row, "rateLimitWindowMs", policy.rateLimitWindowMs);
|
|
4983
|
+
if (policy.activationMode === null) delete row.activationMode;
|
|
4984
|
+
else if (policy.activationMode !== void 0) row.activationMode = policy.activationMode;
|
|
4985
|
+
if (policy.enableModelRestriction === null) delete row.enableModelRestriction;
|
|
4986
|
+
else if (policy.enableModelRestriction !== void 0) {
|
|
4987
|
+
row.enableModelRestriction = policy.enableModelRestriction;
|
|
4988
|
+
}
|
|
4989
|
+
if (policy.restrictionMode === null) delete row.restrictionMode;
|
|
4990
|
+
else if (policy.restrictionMode !== void 0) row.restrictionMode = policy.restrictionMode;
|
|
4991
|
+
if (policy.restrictedModels === null) delete row.restrictedModels;
|
|
4992
|
+
else if (policy.restrictedModels !== void 0) row.restrictedModels = policy.restrictedModels;
|
|
4993
|
+
return true;
|
|
4994
|
+
});
|
|
4995
|
+
}
|
|
4996
|
+
async outboundApiKeysMarkActivated(id, activatedAt) {
|
|
4997
|
+
return this.mutateRow(id, (row) => {
|
|
4998
|
+
if (row.revokedAt !== null) return false;
|
|
4999
|
+
if (row.activatedAt != null) return false;
|
|
5000
|
+
row.activatedAt = activatedAt;
|
|
5001
|
+
return true;
|
|
5002
|
+
});
|
|
5003
|
+
}
|
|
3473
5004
|
/** Apply `fn` to the row with `id`, persisting when it returns true. */
|
|
3474
5005
|
mutateRow(id, fn) {
|
|
3475
5006
|
const rows = this.readRows();
|
|
@@ -3493,6 +5024,11 @@ var JsonOutboundKeyDb = class {
|
|
|
3493
5024
|
writeFileSync4(this.keysPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
|
|
3494
5025
|
}
|
|
3495
5026
|
};
|
|
5027
|
+
function applyPolicyField(row, field, value) {
|
|
5028
|
+
if (value === void 0) return;
|
|
5029
|
+
if (value === null) delete row[field];
|
|
5030
|
+
else row[field] = value;
|
|
5031
|
+
}
|
|
3496
5032
|
|
|
3497
5033
|
// src/ports/JsonPricingStore.ts
|
|
3498
5034
|
import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
@@ -3619,12 +5155,107 @@ var JsonPricingStore = class {
|
|
|
3619
5155
|
}
|
|
3620
5156
|
};
|
|
3621
5157
|
|
|
3622
|
-
// src/ports/
|
|
3623
|
-
import { existsSync as
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
5158
|
+
// src/ports/JsonVoucherDb.ts
|
|
5159
|
+
import { existsSync as existsSync7, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
|
|
5160
|
+
var JsonVoucherDb = class {
|
|
5161
|
+
constructor(vouchersPath) {
|
|
5162
|
+
this.vouchersPath = vouchersPath;
|
|
5163
|
+
}
|
|
5164
|
+
vouchersPath;
|
|
5165
|
+
async voucherCreate(input) {
|
|
5166
|
+
const rows = this.readRows();
|
|
5167
|
+
const row = {
|
|
5168
|
+
id: input.id,
|
|
5169
|
+
codeHash: input.codeHash,
|
|
5170
|
+
codePrefix: input.codePrefix,
|
|
5171
|
+
type: input.type,
|
|
5172
|
+
status: "unredeemed",
|
|
5173
|
+
createdAt: input.createdAt ?? Date.now()
|
|
5174
|
+
};
|
|
5175
|
+
if (input.creditUsd != null) row.creditUsd = input.creditUsd;
|
|
5176
|
+
if (input.renewalDays != null) row.renewalDays = input.renewalDays;
|
|
5177
|
+
if (input.maxTotalCostLimitUsd != null) row.maxTotalCostLimitUsd = input.maxTotalCostLimitUsd;
|
|
5178
|
+
if (input.maxExpiryDays != null) row.maxExpiryDays = input.maxExpiryDays;
|
|
5179
|
+
rows.push(row);
|
|
5180
|
+
this.writeRows(rows);
|
|
5181
|
+
return row;
|
|
5182
|
+
}
|
|
5183
|
+
async voucherGetByHash(codeHash) {
|
|
5184
|
+
const rows = this.readRows();
|
|
5185
|
+
return rows.find((r) => r.codeHash === codeHash) ?? null;
|
|
5186
|
+
}
|
|
5187
|
+
async voucherRedeemCas(id, keyId, granted, now) {
|
|
5188
|
+
const rows = this.readRows();
|
|
5189
|
+
const row = rows.find((r) => r.id === id);
|
|
5190
|
+
if (!row || row.status !== "unredeemed") return false;
|
|
5191
|
+
row.status = "redeemed";
|
|
5192
|
+
row.redeemedAt = now;
|
|
5193
|
+
row.redeemedByKeyId = keyId;
|
|
5194
|
+
row.grantApplied = false;
|
|
5195
|
+
if (granted.totalCostLimitUsd != null) row.grantedTotalCostLimitUsd = granted.totalCostLimitUsd;
|
|
5196
|
+
if (granted.expiresAt != null) row.grantedExpiresAt = granted.expiresAt;
|
|
5197
|
+
this.writeRows(rows);
|
|
5198
|
+
return true;
|
|
5199
|
+
}
|
|
5200
|
+
async voucherMarkGrantApplied(id) {
|
|
5201
|
+
const rows = this.readRows();
|
|
5202
|
+
const row = rows.find((r) => r.id === id);
|
|
5203
|
+
if (!row || row.status !== "redeemed") return false;
|
|
5204
|
+
if (row.grantApplied === true) return true;
|
|
5205
|
+
row.grantApplied = true;
|
|
5206
|
+
this.writeRows(rows);
|
|
5207
|
+
return true;
|
|
5208
|
+
}
|
|
5209
|
+
async voucherRevertRedeem(id, keyId) {
|
|
5210
|
+
const rows = this.readRows();
|
|
5211
|
+
const row = rows.find((r) => r.id === id);
|
|
5212
|
+
if (!row || row.status !== "redeemed" || row.grantApplied === true) return false;
|
|
5213
|
+
if (row.redeemedByKeyId !== keyId) return false;
|
|
5214
|
+
row.status = "unredeemed";
|
|
5215
|
+
delete row.redeemedAt;
|
|
5216
|
+
delete row.redeemedByKeyId;
|
|
5217
|
+
delete row.grantApplied;
|
|
5218
|
+
delete row.grantedTotalCostLimitUsd;
|
|
5219
|
+
delete row.grantedExpiresAt;
|
|
5220
|
+
this.writeRows(rows);
|
|
5221
|
+
return true;
|
|
5222
|
+
}
|
|
5223
|
+
async voucherRevokeCas(id, now) {
|
|
5224
|
+
const rows = this.readRows();
|
|
5225
|
+
const row = rows.find((r) => r.id === id);
|
|
5226
|
+
if (!row || row.status !== "unredeemed") return false;
|
|
5227
|
+
row.status = "revoked";
|
|
5228
|
+
row.revokedAt = now;
|
|
5229
|
+
this.writeRows(rows);
|
|
5230
|
+
return true;
|
|
5231
|
+
}
|
|
5232
|
+
async voucherList() {
|
|
5233
|
+
return this.readRows();
|
|
5234
|
+
}
|
|
5235
|
+
/** Read the voucher rows, tolerating a missing/corrupt file (→ empty list). */
|
|
5236
|
+
readRows() {
|
|
5237
|
+
if (!existsSync7(this.vouchersPath)) return [];
|
|
5238
|
+
try {
|
|
5239
|
+
const parsed = JSON.parse(readFileSync7(this.vouchersPath, "utf8"));
|
|
5240
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
5241
|
+
} catch {
|
|
5242
|
+
return [];
|
|
5243
|
+
}
|
|
5244
|
+
}
|
|
5245
|
+
writeRows(rows) {
|
|
5246
|
+
writeFileSync6(this.vouchersPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
|
|
5247
|
+
}
|
|
5248
|
+
};
|
|
5249
|
+
|
|
5250
|
+
// src/ports/JsonSubscriptionCredentialStore.ts
|
|
5251
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync3, readFileSync as readFileSync10, writeFileSync as writeFileSync8 } from "fs";
|
|
5252
|
+
import { dirname as dirname4 } from "path";
|
|
5253
|
+
import { getSharedAccountHealth } from "@omnicross/core/pipeline/SubscriptionAccountHealth";
|
|
5254
|
+
import { fetchUpstream as fetchUpstream2 } from "@omnicross/core/pipeline/upstreamFetch";
|
|
5255
|
+
import { getSharedIdentityStore } from "@omnicross/core/provider-proxy/identity/SubscriptionIdentityStore";
|
|
5256
|
+
import {
|
|
5257
|
+
claudeOAuth as claudeOAuth2,
|
|
5258
|
+
codexOAuth as codexOAuth2,
|
|
3628
5259
|
geminiOAuth as geminiOAuth2
|
|
3629
5260
|
} from "@omnicross/subscriptions";
|
|
3630
5261
|
|
|
@@ -3702,7 +5333,7 @@ function findDuplicateCredentialIds(accounts) {
|
|
|
3702
5333
|
}
|
|
3703
5334
|
|
|
3704
5335
|
// src/ports/external-cli-credentials.ts
|
|
3705
|
-
import { existsSync as
|
|
5336
|
+
import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
|
|
3706
5337
|
import { homedir as homedir2 } from "os";
|
|
3707
5338
|
import { join as join4 } from "path";
|
|
3708
5339
|
function externalStorePath(provider, home = homedir2()) {
|
|
@@ -3755,10 +5386,10 @@ function parseCodexTokensEnvelope(raw) {
|
|
|
3755
5386
|
}
|
|
3756
5387
|
function readExternalCliCredentials(provider, home = homedir2()) {
|
|
3757
5388
|
const path2 = externalStorePath(provider, home);
|
|
3758
|
-
if (!
|
|
5389
|
+
if (!existsSync8(path2)) return null;
|
|
3759
5390
|
let raw;
|
|
3760
5391
|
try {
|
|
3761
|
-
const parsed = JSON.parse(
|
|
5392
|
+
const parsed = JSON.parse(readFileSync8(path2, "utf8"));
|
|
3762
5393
|
raw = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
|
|
3763
5394
|
} catch {
|
|
3764
5395
|
return null;
|
|
@@ -3767,7 +5398,7 @@ function readExternalCliCredentials(provider, home = homedir2()) {
|
|
|
3767
5398
|
}
|
|
3768
5399
|
|
|
3769
5400
|
// src/ports/external-cli-store.ts
|
|
3770
|
-
import { copyFileSync, existsSync as
|
|
5401
|
+
import { copyFileSync, existsSync as existsSync9, mkdirSync as mkdirSync2, readFileSync as readFileSync9, renameSync, writeFileSync as writeFileSync7 } from "fs";
|
|
3771
5402
|
import { homedir as homedir3 } from "os";
|
|
3772
5403
|
import { dirname as dirname3 } from "path";
|
|
3773
5404
|
function markerPath(provider, home) {
|
|
@@ -3795,9 +5426,9 @@ function buildCodexTokensEnvelope(tokens) {
|
|
|
3795
5426
|
return envelope;
|
|
3796
5427
|
}
|
|
3797
5428
|
function readExistingObject(path2) {
|
|
3798
|
-
if (!
|
|
5429
|
+
if (!existsSync9(path2)) return {};
|
|
3799
5430
|
try {
|
|
3800
|
-
const parsed = JSON.parse(
|
|
5431
|
+
const parsed = JSON.parse(readFileSync9(path2, "utf8"));
|
|
3801
5432
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
3802
5433
|
} catch {
|
|
3803
5434
|
return {};
|
|
@@ -3806,16 +5437,16 @@ function readExistingObject(path2) {
|
|
|
3806
5437
|
function writeAtomic(path2, content) {
|
|
3807
5438
|
mkdirSync2(dirname3(path2), { recursive: true });
|
|
3808
5439
|
const temp = `${path2}.omnicross-tmp`;
|
|
3809
|
-
|
|
5440
|
+
writeFileSync7(temp, content, "utf8");
|
|
3810
5441
|
renameSync(temp, path2);
|
|
3811
5442
|
}
|
|
3812
5443
|
function createExternalCliStore(home = homedir3()) {
|
|
3813
5444
|
return {
|
|
3814
5445
|
readMarkerAccountId(provider) {
|
|
3815
5446
|
const path2 = markerPath(provider, home);
|
|
3816
|
-
if (!
|
|
5447
|
+
if (!existsSync9(path2)) return void 0;
|
|
3817
5448
|
try {
|
|
3818
|
-
const parsed = JSON.parse(
|
|
5449
|
+
const parsed = JSON.parse(readFileSync9(path2, "utf8"));
|
|
3819
5450
|
return typeof parsed.accountId === "string" && parsed.accountId ? parsed.accountId : void 0;
|
|
3820
5451
|
} catch {
|
|
3821
5452
|
return void 0;
|
|
@@ -3833,7 +5464,7 @@ function createExternalCliStore(home = homedir3()) {
|
|
|
3833
5464
|
const envelope = provider === "claude" ? buildClaudeOAuthEnvelope(tokens) : buildCodexTokensEnvelope(tokens);
|
|
3834
5465
|
if (!envelope) return false;
|
|
3835
5466
|
const storePath = externalStorePath(provider, home);
|
|
3836
|
-
if (
|
|
5467
|
+
if (existsSync9(storePath) && !existsSync9(backupPath(provider, home))) {
|
|
3837
5468
|
copyFileSync(storePath, backupPath(provider, home));
|
|
3838
5469
|
}
|
|
3839
5470
|
const existing = readExistingObject(storePath);
|
|
@@ -3845,16 +5476,21 @@ function createExternalCliStore(home = homedir3()) {
|
|
|
3845
5476
|
}
|
|
3846
5477
|
|
|
3847
5478
|
// src/ports/JsonSubscriptionCredentialStore.ts
|
|
5479
|
+
var ACCOUNT_REFRESH_LEAD_MS = 5 * 6e4;
|
|
3848
5480
|
var JsonSubscriptionCredentialStore = class {
|
|
3849
5481
|
/**
|
|
3850
5482
|
* @param tokensPath on-disk `tokens.json` location.
|
|
3851
5483
|
* @param box at-rest `SecretBox` (encrypt-on-write / decrypt-on-read).
|
|
3852
|
-
* @param fetchImpl injectable HTTP port for the OAuth refresh
|
|
3853
|
-
* (oauth design D4).
|
|
3854
|
-
*
|
|
3855
|
-
*
|
|
5484
|
+
* @param fetchImpl OPTIONAL injectable HTTP port for the OAuth refresh
|
|
5485
|
+
* round-trips (oauth design D4). A TEST-injected transport is
|
|
5486
|
+
* used verbatim. When ABSENT (production), each refresh uses a
|
|
5487
|
+
* proxy-aware {@link fetchUpstream} that threads the
|
|
5488
|
+
* `{ providerId, accountId }` ctx (upstream-proxy M1) so a
|
|
5489
|
+
* per-account/per-provider proxy is honored on refresh exactly
|
|
5490
|
+
* as on relay — refresh egresses from the SAME proxy IP as the
|
|
5491
|
+
* account's traffic. NOT used by any read/write path.
|
|
3856
5492
|
*/
|
|
3857
|
-
constructor(tokensPath, box, fetchImpl =
|
|
5493
|
+
constructor(tokensPath, box, fetchImpl = void 0, externalCliReader = readExternalCliCredentials, externalCliStore = createExternalCliStore()) {
|
|
3858
5494
|
this.tokensPath = tokensPath;
|
|
3859
5495
|
this.box = box;
|
|
3860
5496
|
this.fetchImpl = fetchImpl;
|
|
@@ -3866,6 +5502,15 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3866
5502
|
fetchImpl;
|
|
3867
5503
|
externalCliReader;
|
|
3868
5504
|
externalCliStore;
|
|
5505
|
+
/**
|
|
5506
|
+
* The proxy-aware `FetchLike` for one refresh round-trip (upstream-proxy M1). A
|
|
5507
|
+
* TEST-injected `fetchImpl` is returned verbatim; otherwise the refresh routes
|
|
5508
|
+
* through {@link fetchUpstream} with the account's `{ providerId, accountId }`
|
|
5509
|
+
* ctx so the per-account/provider proxy applies. `@internal` — also a test seam.
|
|
5510
|
+
*/
|
|
5511
|
+
buildRefreshFetch(providerId, accountId) {
|
|
5512
|
+
return this.fetchImpl ?? ((url, init) => fetchUpstream2(url, init, { providerId, accountId }));
|
|
5513
|
+
}
|
|
3869
5514
|
/**
|
|
3870
5515
|
* In-flight refresh coalescing (external-cli-sync). OAuth refresh tokens are
|
|
3871
5516
|
* SINGLE-USE: two concurrent refreshes of one account each spend the same
|
|
@@ -3897,6 +5542,19 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3897
5542
|
async getValidOpenCodeGoApiKey() {
|
|
3898
5543
|
return this.readConfig().opencodego?.apiKey ?? null;
|
|
3899
5544
|
}
|
|
5545
|
+
/**
|
|
5546
|
+
* DAEMON-ONLY per-account proxy lookup by id (upstream-proxy). Returns the
|
|
5547
|
+
* DECRYPTED `ProxyConfig` for the account (`readConfig` decrypts on read), or
|
|
5548
|
+
* `undefined` for an unknown provider/account or no per-account proxy. Feeds the
|
|
5549
|
+
* winning per-account layer of the upstream-proxy resolver. Synchronous like the
|
|
5550
|
+
* other hot reads. Never returns token material.
|
|
5551
|
+
*/
|
|
5552
|
+
getAccountProxy(providerId, accountId) {
|
|
5553
|
+
if (providerId !== "claude" && providerId !== "codex" && providerId !== "gemini" && providerId !== "opencodego") {
|
|
5554
|
+
return void 0;
|
|
5555
|
+
}
|
|
5556
|
+
return getAccountProxy(this.readConfig(), providerId, accountId);
|
|
5557
|
+
}
|
|
3900
5558
|
/**
|
|
3901
5559
|
* DAEMON-ONLY sanitized accounts list (design D8, NOT on the port). Projects
|
|
3902
5560
|
* each provider's accounts to the secret-free `SubscriptionAccountSanitized`
|
|
@@ -3905,10 +5563,25 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3905
5563
|
*/
|
|
3906
5564
|
async listSanitizedAccounts() {
|
|
3907
5565
|
const config = this.readConfig();
|
|
5566
|
+
const health2 = getSharedAccountHealth();
|
|
5567
|
+
const identityStore = getSharedIdentityStore();
|
|
5568
|
+
const fingerprintOn = identityStore.isEnabled();
|
|
5569
|
+
const now = Date.now();
|
|
3908
5570
|
const out = {};
|
|
3909
5571
|
for (const provider of ["claude", "codex", "gemini", "opencodego"]) {
|
|
3910
5572
|
const sanitized = sanitizeAccounts(config, provider);
|
|
3911
|
-
if (sanitized.length
|
|
5573
|
+
if (sanitized.length === 0) continue;
|
|
5574
|
+
for (const account of sanitized) {
|
|
5575
|
+
const status = health2.getStatus(provider, account.id, now);
|
|
5576
|
+
account.health = status.state;
|
|
5577
|
+
account.cooldownUntil = status.cooldownUntil !== void 0 ? new Date(status.cooldownUntil).toISOString() : void 0;
|
|
5578
|
+
if (fingerprintOn && provider === "claude") {
|
|
5579
|
+
account.identityCaptured = identityStore.hasIdentity(provider, account.id);
|
|
5580
|
+
const capturedAt = identityStore.capturedAt(provider, account.id);
|
|
5581
|
+
account.identityCapturedAt = capturedAt !== void 0 ? new Date(capturedAt).toISOString() : void 0;
|
|
5582
|
+
}
|
|
5583
|
+
}
|
|
5584
|
+
out[provider] = this.attachSyncWarnings(config, provider, sanitized);
|
|
3912
5585
|
}
|
|
3913
5586
|
return out;
|
|
3914
5587
|
}
|
|
@@ -3959,8 +5632,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3959
5632
|
if (!active || !claude?.refreshToken) return false;
|
|
3960
5633
|
const capturedId = active.id;
|
|
3961
5634
|
this.materializeMigration(config);
|
|
5635
|
+
const refreshFetch = this.buildRefreshFetch("claude", capturedId);
|
|
3962
5636
|
try {
|
|
3963
|
-
const result = await claudeOAuth2.refreshAccessToken(claude.refreshToken,
|
|
5637
|
+
const result = await claudeOAuth2.refreshAccessToken(claude.refreshToken, refreshFetch);
|
|
3964
5638
|
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
3965
5639
|
const next = {
|
|
3966
5640
|
...claude,
|
|
@@ -3977,7 +5651,7 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3977
5651
|
return true;
|
|
3978
5652
|
} catch (error) {
|
|
3979
5653
|
if (await this.tryExternalImport("claude", capturedId, claude, async (rt) => {
|
|
3980
|
-
const r = await claudeOAuth2.refreshAccessToken(rt,
|
|
5654
|
+
const r = await claudeOAuth2.refreshAccessToken(rt, refreshFetch);
|
|
3981
5655
|
return {
|
|
3982
5656
|
accessToken: r.accessToken,
|
|
3983
5657
|
refreshToken: r.refreshToken,
|
|
@@ -4004,8 +5678,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4004
5678
|
if (!active || !codex?.refreshToken) return false;
|
|
4005
5679
|
const capturedId = active.id;
|
|
4006
5680
|
this.materializeMigration(config);
|
|
5681
|
+
const refreshFetch = this.buildRefreshFetch("codex", capturedId);
|
|
4007
5682
|
try {
|
|
4008
|
-
const result = await codexOAuth2.refreshAccessToken(codex.refreshToken,
|
|
5683
|
+
const result = await codexOAuth2.refreshAccessToken(codex.refreshToken, refreshFetch);
|
|
4009
5684
|
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
4010
5685
|
const next = {
|
|
4011
5686
|
...codex,
|
|
@@ -4023,7 +5698,7 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4023
5698
|
return true;
|
|
4024
5699
|
} catch (error) {
|
|
4025
5700
|
if (await this.tryExternalImport("codex", capturedId, codex, async (rt) => {
|
|
4026
|
-
const r = await codexOAuth2.refreshAccessToken(rt,
|
|
5701
|
+
const r = await codexOAuth2.refreshAccessToken(rt, refreshFetch);
|
|
4027
5702
|
return {
|
|
4028
5703
|
accessToken: r.accessToken,
|
|
4029
5704
|
refreshToken: r.refreshToken,
|
|
@@ -4053,8 +5728,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4053
5728
|
if (!active || !gemini?.refreshToken) return false;
|
|
4054
5729
|
const capturedId = active.id;
|
|
4055
5730
|
this.materializeMigration(config);
|
|
5731
|
+
const refreshFetch = this.buildRefreshFetch("gemini", capturedId);
|
|
4056
5732
|
try {
|
|
4057
|
-
const result = await geminiOAuth2.refreshAccessToken(gemini.refreshToken,
|
|
5733
|
+
const result = await geminiOAuth2.refreshAccessToken(gemini.refreshToken, refreshFetch);
|
|
4058
5734
|
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
4059
5735
|
const next = {
|
|
4060
5736
|
...gemini,
|
|
@@ -4088,7 +5764,7 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4088
5764
|
if (!account || !captured?.refreshToken) return false;
|
|
4089
5765
|
this.materializeMigration(config);
|
|
4090
5766
|
try {
|
|
4091
|
-
const refreshed = await this.refreshUpstream(provider, captured.refreshToken);
|
|
5767
|
+
const refreshed = await this.refreshUpstream(provider, captured.refreshToken, id);
|
|
4092
5768
|
const next = {
|
|
4093
5769
|
...captured,
|
|
4094
5770
|
accessToken: refreshed.accessToken,
|
|
@@ -4110,10 +5786,114 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4110
5786
|
}
|
|
4111
5787
|
});
|
|
4112
5788
|
}
|
|
5789
|
+
// ── By-id account-pool surface (subscription-account-scheduling, design D6) ──
|
|
5790
|
+
/**
|
|
5791
|
+
* Resolve a SPECIFIC account's access token by id (design D6). Mirrors each
|
|
5792
|
+
* provider's ACTIVE-getter policy, keyed by id: claude returns the stored token
|
|
5793
|
+
* (refresh is 401-driven, like `getValidClaudeAccessToken`); codex/gemini refresh
|
|
5794
|
+
* a near-expiry token via `refreshAccountById` (like `resolveAccessToken`);
|
|
5795
|
+
* opencodego returns the account's static key. `null` when unknown/expired/
|
|
5796
|
+
* tokenless.
|
|
5797
|
+
*/
|
|
5798
|
+
async getAccessTokenForAccount(providerId, accountId) {
|
|
5799
|
+
const account = getAccountById(this.readConfig(), providerId, accountId);
|
|
5800
|
+
if (!account) return null;
|
|
5801
|
+
if (providerId === "opencodego") {
|
|
5802
|
+
return account.tokens.apiKey ?? null;
|
|
5803
|
+
}
|
|
5804
|
+
const oauth = account.tokens;
|
|
5805
|
+
if (!oauth.accessToken) return null;
|
|
5806
|
+
if (providerId === "codex" || providerId === "gemini") {
|
|
5807
|
+
const expiresAtMs = oauth.expiresAt ? Date.parse(oauth.expiresAt) : 0;
|
|
5808
|
+
const expiringSoon = expiresAtMs > 0 && Date.now() >= expiresAtMs - ACCOUNT_REFRESH_LEAD_MS;
|
|
5809
|
+
if (expiringSoon && oauth.refreshToken) {
|
|
5810
|
+
const ok = await this.refreshAccountById(providerId, accountId);
|
|
5811
|
+
if (!ok) return null;
|
|
5812
|
+
const fresh = getAccountById(this.readConfig(), providerId, accountId);
|
|
5813
|
+
return fresh?.tokens?.accessToken ?? null;
|
|
5814
|
+
}
|
|
5815
|
+
}
|
|
5816
|
+
if (oauth.status === "expired") return null;
|
|
5817
|
+
return oauth.accessToken;
|
|
5818
|
+
}
|
|
5819
|
+
/**
|
|
5820
|
+
* Refresh a SPECIFIC account's OAuth token by id (design D6/D7). Delegates to
|
|
5821
|
+
* `refreshAccountById` (coalesced per `provider:id`); opencodego is a static key
|
|
5822
|
+
* → `false` (no refresh affordance).
|
|
5823
|
+
*/
|
|
5824
|
+
async refreshAccountToken(providerId, accountId) {
|
|
5825
|
+
if (providerId === "opencodego") return false;
|
|
5826
|
+
return this.refreshAccountById(providerId, accountId);
|
|
5827
|
+
}
|
|
5828
|
+
/**
|
|
5829
|
+
* Best-effort record of a selection time onto the account's `lastUsedAt` by id
|
|
5830
|
+
* (design D4). Entry-metadata only (the token mirror is untouched); a no-op for
|
|
5831
|
+
* an unknown id. The selector throttles the call frequency, so this stays cheap.
|
|
5832
|
+
*/
|
|
5833
|
+
async touchAccountLastUsed(providerId, accountId, iso) {
|
|
5834
|
+
const config = this.readConfig();
|
|
5835
|
+
const result = setAccountLastUsed(config, providerId, accountId, iso);
|
|
5836
|
+
if (!result.ok) return;
|
|
5837
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5838
|
+
}
|
|
5839
|
+
/**
|
|
5840
|
+
* Best-effort write-through of a per-account client `identity`
|
|
5841
|
+
* (subscription-client-fingerprint #7, P2). Entry-metadata only (NON-secret
|
|
5842
|
+
* whitelisted fingerprint headers; the token mirror is untouched); a no-op for
|
|
5843
|
+
* an unknown id. Called by the identity store's persistence port on a first-seen
|
|
5844
|
+
* freeze / TTL refresh, so it stays infrequent. Never throws to the caller — the
|
|
5845
|
+
* store's port wrapper swallows a rejection so the relay hot path is unaffected.
|
|
5846
|
+
*/
|
|
5847
|
+
async setAccountIdentity(providerId, accountId, identity) {
|
|
5848
|
+
const config = this.readConfig();
|
|
5849
|
+
const result = setAccountIdentity(config, providerId, accountId, identity);
|
|
5850
|
+
if (!result.ok) return;
|
|
5851
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5852
|
+
}
|
|
5853
|
+
/**
|
|
5854
|
+
* DAEMON-ONLY set-priority (subscription-account-scheduling, admin write, NOT on
|
|
5855
|
+
* the port). Set one account's scheduling `priority` by id. Secret-free
|
|
5856
|
+
* (entry-metadata only; the mirror invariant is untouched). Rejects an unknown id.
|
|
5857
|
+
*/
|
|
5858
|
+
async setAccountPriority(providerId, accountId, priority) {
|
|
5859
|
+
const config = this.readConfig();
|
|
5860
|
+
const result = setAccountPriority(config, providerId, accountId, priority);
|
|
5861
|
+
if (!result.ok) return result;
|
|
5862
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5863
|
+
return result;
|
|
5864
|
+
}
|
|
5865
|
+
/**
|
|
5866
|
+
* DAEMON-ONLY set/clear per-account proxy (upstream-proxy, admin write, NOT on
|
|
5867
|
+
* the port). Passing `undefined` clears the override. Write-only password: when
|
|
5868
|
+
* the incoming structured proxy omits the password but the account already had
|
|
5869
|
+
* one, the current (decrypted) password is preserved — editing host/port never
|
|
5870
|
+
* wipes the secret. Persist re-encrypts `proxy.password` via the tokens SecretBox.
|
|
5871
|
+
*/
|
|
5872
|
+
async setAccountProxy(providerId, accountId, proxy) {
|
|
5873
|
+
const config = this.readConfig();
|
|
5874
|
+
const merged = proxy ? preserveProxyConfigSecret(proxy, getAccountProxy(config, providerId, accountId)) : void 0;
|
|
5875
|
+
const result = setAccountProxy(config, providerId, accountId, merged);
|
|
5876
|
+
if (!result.ok) return result;
|
|
5877
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5878
|
+
return result;
|
|
5879
|
+
}
|
|
5880
|
+
/**
|
|
5881
|
+
* DAEMON-ONLY set/clear per-account `supportedModels` (subscription-account-
|
|
5882
|
+
* model-map, admin write, NOT on the port). Passing `undefined` clears it.
|
|
5883
|
+
* Secret-free (model ids only; the mirror invariant is untouched). Rejects an
|
|
5884
|
+
* unknown id.
|
|
5885
|
+
*/
|
|
5886
|
+
async setAccountSupportedModels(providerId, accountId, supportedModels) {
|
|
5887
|
+
const config = this.readConfig();
|
|
5888
|
+
const result = setAccountSupportedModels(config, providerId, accountId, supportedModels);
|
|
5889
|
+
if (!result.ok) return result;
|
|
5890
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5891
|
+
return result;
|
|
5892
|
+
}
|
|
4113
5893
|
/** Dispatch one OAuth refresh round-trip to the provider's shared flow. */
|
|
4114
|
-
async refreshUpstream(provider, refreshToken) {
|
|
5894
|
+
async refreshUpstream(provider, refreshToken, accountId) {
|
|
4115
5895
|
const flow = provider === "claude" ? claudeOAuth2 : provider === "codex" ? codexOAuth2 : geminiOAuth2;
|
|
4116
|
-
const r = await flow.refreshAccessToken(refreshToken, this.
|
|
5896
|
+
const r = await flow.refreshAccessToken(refreshToken, this.buildRefreshFetch(provider, accountId));
|
|
4117
5897
|
return {
|
|
4118
5898
|
accessToken: r.accessToken,
|
|
4119
5899
|
refreshToken: r.refreshToken,
|
|
@@ -4328,7 +6108,7 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4328
6108
|
persist(config) {
|
|
4329
6109
|
mkdirSync3(dirname4(this.tokensPath), { recursive: true });
|
|
4330
6110
|
const encrypted = encryptTokens(config, this.box);
|
|
4331
|
-
|
|
6111
|
+
writeFileSync8(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
|
|
4332
6112
|
}
|
|
4333
6113
|
/**
|
|
4334
6114
|
* Read + parse `tokens.json`, tolerating a missing/corrupt file, then DECRYPT
|
|
@@ -4344,10 +6124,10 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4344
6124
|
* `config.ts loadConfig`, which decrypts outside its parse try.
|
|
4345
6125
|
*/
|
|
4346
6126
|
readConfig() {
|
|
4347
|
-
if (!
|
|
6127
|
+
if (!existsSync10(this.tokensPath)) return { updatedAt: "" };
|
|
4348
6128
|
let parsed;
|
|
4349
6129
|
try {
|
|
4350
|
-
const raw = JSON.parse(
|
|
6130
|
+
const raw = JSON.parse(readFileSync10(this.tokensPath, "utf8"));
|
|
4351
6131
|
parsed = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
|
|
4352
6132
|
} catch {
|
|
4353
6133
|
parsed = null;
|
|
@@ -4358,18 +6138,254 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4358
6138
|
}
|
|
4359
6139
|
};
|
|
4360
6140
|
|
|
4361
|
-
// src/
|
|
6141
|
+
// src/AccountHealthProbeScheduler.ts
|
|
6142
|
+
import { fetchUpstream as fetchUpstream3 } from "@omnicross/core/pipeline/upstreamFetch";
|
|
6143
|
+
|
|
6144
|
+
// src/probe/ProbeStrategy.ts
|
|
6145
|
+
var PROVIDER_PROBE_PLANS = {
|
|
6146
|
+
claude: {
|
|
6147
|
+
kind: "upstream",
|
|
6148
|
+
// VERIFIED free authed list endpoint (no tokens billed). The anthropic OAuth
|
|
6149
|
+
// bearer is accepted here exactly as on the relay path.
|
|
6150
|
+
url: "https://api.anthropic.com/v1/models",
|
|
6151
|
+
buildInit: (token) => ({
|
|
6152
|
+
method: "GET",
|
|
6153
|
+
headers: {
|
|
6154
|
+
Authorization: `Bearer ${token}`,
|
|
6155
|
+
"anthropic-version": "2023-06-01"
|
|
6156
|
+
}
|
|
6157
|
+
})
|
|
6158
|
+
},
|
|
6159
|
+
// UNVERIFIED cheap authed GET — Phase 1 local-only (LEAD OQ1: do not guess a
|
|
6160
|
+
// billable/wrong endpoint). Upgrade to `{ kind:'upstream' }` once verified.
|
|
6161
|
+
codex: { kind: "local" },
|
|
6162
|
+
gemini: { kind: "local" },
|
|
6163
|
+
opencodego: { kind: "local" }
|
|
6164
|
+
};
|
|
6165
|
+
function probePlanFor(providerId) {
|
|
6166
|
+
return PROVIDER_PROBE_PLANS[providerId] ?? { kind: "local" };
|
|
6167
|
+
}
|
|
6168
|
+
|
|
6169
|
+
// src/AccountHealthProbeScheduler.ts
|
|
6170
|
+
var KEY_SEP = "\0";
|
|
6171
|
+
var MAX_BODY_SNIFF = 2048;
|
|
6172
|
+
var PROBE_PROVIDERS = [
|
|
6173
|
+
"claude",
|
|
6174
|
+
"codex",
|
|
6175
|
+
"gemini",
|
|
6176
|
+
"opencodego"
|
|
6177
|
+
];
|
|
6178
|
+
var AccountHealthProbeScheduler = class {
|
|
6179
|
+
constructor(store, health2, logger, config, opts = {}) {
|
|
6180
|
+
this.store = store;
|
|
6181
|
+
this.health = health2;
|
|
6182
|
+
this.logger = logger;
|
|
6183
|
+
this.config = config;
|
|
6184
|
+
this.now = opts.now ?? Date.now;
|
|
6185
|
+
this.fetchImpl = opts.fetchImpl ?? fetchUpstream3;
|
|
6186
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
6187
|
+
this.planFor = opts.planFor ?? probePlanFor;
|
|
6188
|
+
}
|
|
6189
|
+
store;
|
|
6190
|
+
health;
|
|
6191
|
+
logger;
|
|
6192
|
+
config;
|
|
6193
|
+
timer = null;
|
|
6194
|
+
sweeping = false;
|
|
6195
|
+
history = /* @__PURE__ */ new Map();
|
|
6196
|
+
now;
|
|
6197
|
+
fetchImpl;
|
|
6198
|
+
sleep;
|
|
6199
|
+
planFor;
|
|
6200
|
+
/** Whether probing is enabled by the current config. */
|
|
6201
|
+
get enabled() {
|
|
6202
|
+
return this.config.enabled;
|
|
6203
|
+
}
|
|
6204
|
+
/**
|
|
6205
|
+
* Re-apply config to the live instance (the async `start.ts` loads the persisted
|
|
6206
|
+
* `accountProbe` segment after `buildDaemon`). Call BEFORE `start()`.
|
|
6207
|
+
*/
|
|
6208
|
+
configure(config) {
|
|
6209
|
+
this.config = config;
|
|
6210
|
+
}
|
|
6211
|
+
/** Arm the probe interval. No-op when disabled (zero regression). Idempotent. */
|
|
6212
|
+
start() {
|
|
6213
|
+
if (this.timer || !this.config.enabled) return;
|
|
6214
|
+
this.timer = setInterval(() => void this.sweep(), this.config.intervalMs);
|
|
6215
|
+
this.timer.unref?.();
|
|
6216
|
+
}
|
|
6217
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6218
|
+
dispose() {
|
|
6219
|
+
if (this.timer) {
|
|
6220
|
+
clearInterval(this.timer);
|
|
6221
|
+
this.timer = null;
|
|
6222
|
+
}
|
|
6223
|
+
}
|
|
6224
|
+
/**
|
|
6225
|
+
* One sweep: probe every ELIGIBLE account SEQUENTIALLY with a `staggerMs` gap.
|
|
6226
|
+
* Disabled ⇒ no-op. `onlyMultiAccount` skips single-account providers. Exposed
|
|
6227
|
+
* for tests; never throws.
|
|
6228
|
+
*/
|
|
6229
|
+
async sweep() {
|
|
6230
|
+
if (!this.config.enabled || this.sweeping) return;
|
|
6231
|
+
this.sweeping = true;
|
|
6232
|
+
try {
|
|
6233
|
+
const config = await this.store.getFullConfig();
|
|
6234
|
+
let probed = 0;
|
|
6235
|
+
let marked = 0;
|
|
6236
|
+
for (const providerId of PROBE_PROVIDERS) {
|
|
6237
|
+
const accounts = listAccounts(config, providerId);
|
|
6238
|
+
if (this.config.onlyMultiAccount && accounts.length < 2) continue;
|
|
6239
|
+
for (const account of accounts) {
|
|
6240
|
+
if (probed > 0 && this.config.staggerMs > 0) await this.sleep(this.config.staggerMs);
|
|
6241
|
+
const outcome = await this.probeAccount(providerId, account.id);
|
|
6242
|
+
probed += 1;
|
|
6243
|
+
if (outcome.marked) marked += 1;
|
|
6244
|
+
}
|
|
6245
|
+
}
|
|
6246
|
+
this.logger.debug("account-probe sweep complete", { probed, marked });
|
|
6247
|
+
} catch (error) {
|
|
6248
|
+
this.logger.warn("account-probe sweep failed", {
|
|
6249
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6250
|
+
});
|
|
6251
|
+
} finally {
|
|
6252
|
+
this.sweeping = false;
|
|
6253
|
+
}
|
|
6254
|
+
}
|
|
6255
|
+
/**
|
|
6256
|
+
* Probe ONE account (design D1). Local tier first (dead token → synthesized 401,
|
|
6257
|
+
* no upstream); else the upstream tier when a verified endpoint exists. Records
|
|
6258
|
+
* the rolling history entry either way; returns whether the tracker was MARKED.
|
|
6259
|
+
*/
|
|
6260
|
+
async probeAccount(providerId, accountId) {
|
|
6261
|
+
const now = this.now();
|
|
6262
|
+
let token = null;
|
|
6263
|
+
let readThrew = false;
|
|
6264
|
+
try {
|
|
6265
|
+
token = await this.store.getAccessTokenForAccount(providerId, accountId);
|
|
6266
|
+
} catch {
|
|
6267
|
+
readThrew = true;
|
|
6268
|
+
}
|
|
6269
|
+
if (readThrew) {
|
|
6270
|
+
this.record(providerId, accountId, { ts: now, ok: false, status: null, tier: "local" });
|
|
6271
|
+
return { ok: false, marked: false };
|
|
6272
|
+
}
|
|
6273
|
+
if (!token) {
|
|
6274
|
+
this.health.recordUpstreamOutcome(providerId, accountId, { status: 401, now });
|
|
6275
|
+
this.record(providerId, accountId, { ts: now, ok: false, status: 401, tier: "local" });
|
|
6276
|
+
return { ok: false, marked: true };
|
|
6277
|
+
}
|
|
6278
|
+
const plan = this.planFor(providerId);
|
|
6279
|
+
if (plan.kind === "local") {
|
|
6280
|
+
this.record(providerId, accountId, { ts: now, ok: true, tier: "local" });
|
|
6281
|
+
return { ok: true, marked: false };
|
|
6282
|
+
}
|
|
6283
|
+
const start = this.now();
|
|
6284
|
+
let status = null;
|
|
6285
|
+
let bodyText;
|
|
6286
|
+
try {
|
|
6287
|
+
const res = await this.fetchImpl(
|
|
6288
|
+
plan.url,
|
|
6289
|
+
{ ...plan.buildInit(token), signal: AbortSignal.timeout(this.config.timeoutMs) },
|
|
6290
|
+
{ providerId, accountId }
|
|
6291
|
+
);
|
|
6292
|
+
status = res.status;
|
|
6293
|
+
if (status === 403) bodyText = await this.readBounded(res);
|
|
6294
|
+
} catch {
|
|
6295
|
+
status = null;
|
|
6296
|
+
}
|
|
6297
|
+
const latencyMs = this.now() - start;
|
|
6298
|
+
const marked = this.applyOutcome(providerId, accountId, status, bodyText, now);
|
|
6299
|
+
this.record(providerId, accountId, {
|
|
6300
|
+
ts: now,
|
|
6301
|
+
ok: status !== null && status >= 200 && status < 300,
|
|
6302
|
+
status,
|
|
6303
|
+
latencyMs,
|
|
6304
|
+
tier: "upstream"
|
|
6305
|
+
});
|
|
6306
|
+
return { ok: status !== null && status < 400, marked };
|
|
6307
|
+
}
|
|
6308
|
+
/** Per-account rolling history for the authed admin surface (design D5). */
|
|
6309
|
+
getAllHistory() {
|
|
6310
|
+
const out = [];
|
|
6311
|
+
for (const [key, records] of this.history) {
|
|
6312
|
+
const [providerId, accountId] = this.parseKey(key);
|
|
6313
|
+
out.push({ providerId, accountId, records: records.slice() });
|
|
6314
|
+
}
|
|
6315
|
+
return out;
|
|
6316
|
+
}
|
|
6317
|
+
/**
|
|
6318
|
+
* The coarse, account-ANONYMOUS `/health` signal (design D5): `true` when no
|
|
6319
|
+
* probed account is currently unhealthy (per #2's tracker). No ids, no counts —
|
|
6320
|
+
* safe for the unauthenticated `/health`. Vacuously `true` when nothing probed.
|
|
6321
|
+
*/
|
|
6322
|
+
probedAccountsHealthy(now = this.now()) {
|
|
6323
|
+
for (const key of this.history.keys()) {
|
|
6324
|
+
const [providerId, accountId] = this.parseKey(key);
|
|
6325
|
+
if (!this.health.isSchedulable(providerId, accountId, now)) return false;
|
|
6326
|
+
}
|
|
6327
|
+
return true;
|
|
6328
|
+
}
|
|
6329
|
+
/**
|
|
6330
|
+
* Feed ONLY the account/auth-decisive statuses to #2 (LEAD constraint):
|
|
6331
|
+
* 401/403 → mark; 2xx → clear transient; 429 / 5xx / other 4xx / null →
|
|
6332
|
+
* NOT forwarded (never a mark). Returns whether a NEGATIVE mark was applied.
|
|
6333
|
+
*/
|
|
6334
|
+
applyOutcome(providerId, accountId, status, bodyText, now) {
|
|
6335
|
+
if (status === null) return false;
|
|
6336
|
+
if (status === 401 || status === 403) {
|
|
6337
|
+
this.health.recordUpstreamOutcome(providerId, accountId, { status, bodyText, now });
|
|
6338
|
+
return true;
|
|
6339
|
+
}
|
|
6340
|
+
if (status >= 200 && status < 300) {
|
|
6341
|
+
this.health.clearTransientMark(providerId, accountId);
|
|
6342
|
+
return false;
|
|
6343
|
+
}
|
|
6344
|
+
return false;
|
|
6345
|
+
}
|
|
6346
|
+
/** Append a record, capping the ring at `historySize` (drop oldest). */
|
|
6347
|
+
record(providerId, accountId, rec) {
|
|
6348
|
+
const key = this.key(providerId, accountId);
|
|
6349
|
+
const list = this.history.get(key) ?? [];
|
|
6350
|
+
list.push(rec);
|
|
6351
|
+
const overflow = list.length - this.config.historySize;
|
|
6352
|
+
if (overflow > 0) list.splice(0, overflow);
|
|
6353
|
+
this.history.set(key, list);
|
|
6354
|
+
}
|
|
6355
|
+
/** Read a bounded slice of the response body for the 403-ban sniff (never throws). */
|
|
6356
|
+
async readBounded(res) {
|
|
6357
|
+
try {
|
|
6358
|
+
return (await res.text()).slice(0, MAX_BODY_SNIFF);
|
|
6359
|
+
} catch {
|
|
6360
|
+
return "";
|
|
6361
|
+
}
|
|
6362
|
+
}
|
|
6363
|
+
key(providerId, accountId) {
|
|
6364
|
+
return `${providerId}${KEY_SEP}${accountId}`;
|
|
6365
|
+
}
|
|
6366
|
+
parseKey(key) {
|
|
6367
|
+
const idx = key.indexOf(KEY_SEP);
|
|
6368
|
+
return [key.slice(0, idx), key.slice(idx + 1)];
|
|
6369
|
+
}
|
|
6370
|
+
};
|
|
6371
|
+
|
|
6372
|
+
// src/AccountHealthSweeper.ts
|
|
4362
6373
|
var REFRESH_LEAD_MS = 5 * 6e4;
|
|
4363
6374
|
var SWEEP_INTERVAL_MS = 6e4;
|
|
4364
6375
|
var OAUTH_PROVIDERS = ["claude", "codex", "gemini"];
|
|
4365
|
-
|
|
4366
|
-
|
|
6376
|
+
function isOAuthProvider(providerId) {
|
|
6377
|
+
return OAUTH_PROVIDERS.includes(providerId);
|
|
6378
|
+
}
|
|
6379
|
+
var AccountHealthSweeper = class {
|
|
6380
|
+
constructor(store, health2, logger, intervalMs = SWEEP_INTERVAL_MS, leadMs = REFRESH_LEAD_MS) {
|
|
4367
6381
|
this.store = store;
|
|
6382
|
+
this.health = health2;
|
|
4368
6383
|
this.logger = logger;
|
|
4369
6384
|
this.intervalMs = intervalMs;
|
|
4370
6385
|
this.leadMs = leadMs;
|
|
4371
6386
|
}
|
|
4372
6387
|
store;
|
|
6388
|
+
health;
|
|
4373
6389
|
logger;
|
|
4374
6390
|
intervalMs;
|
|
4375
6391
|
leadMs;
|
|
@@ -4388,21 +6404,26 @@ var TokenRefreshScheduler = class {
|
|
|
4388
6404
|
this.timer = null;
|
|
4389
6405
|
}
|
|
4390
6406
|
}
|
|
4391
|
-
/**
|
|
6407
|
+
/**
|
|
6408
|
+
* One sweep: surface accounts that just recovered (emits the recovery signal
|
|
6409
|
+
* through the tracker's hook) and nudge a fresh token for any recovered OAuth
|
|
6410
|
+
* account whose token is near expiry. Exposed for tests. Never throws.
|
|
6411
|
+
*/
|
|
4392
6412
|
async sweep(now = Date.now()) {
|
|
4393
6413
|
if (this.sweeping) return;
|
|
4394
6414
|
this.sweeping = true;
|
|
4395
6415
|
try {
|
|
6416
|
+
const recovered = this.health.sweepRecoveries(now);
|
|
6417
|
+
if (recovered.length === 0) return;
|
|
4396
6418
|
const config = await this.store.getFullConfig();
|
|
4397
|
-
for (const
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
}
|
|
6419
|
+
for (const event of recovered) {
|
|
6420
|
+
if (!isOAuthProvider(event.providerId)) continue;
|
|
6421
|
+
const account = getAccountById(config, event.providerId, event.accountId);
|
|
6422
|
+
if (!account || !this.needsRefresh(account.tokens, now)) continue;
|
|
6423
|
+
await this.refreshOne(event.providerId, event.accountId);
|
|
4403
6424
|
}
|
|
4404
6425
|
} catch (error) {
|
|
4405
|
-
this.logger.warn("
|
|
6426
|
+
this.logger.warn("account-health sweep failed", {
|
|
4406
6427
|
error: error instanceof Error ? error.message : String(error)
|
|
4407
6428
|
});
|
|
4408
6429
|
} finally {
|
|
@@ -4417,46 +6438,763 @@ var TokenRefreshScheduler = class {
|
|
|
4417
6438
|
const expiresAt = Date.parse(t.expiresAt);
|
|
4418
6439
|
return Number.isFinite(expiresAt) && now >= expiresAt - this.leadMs;
|
|
4419
6440
|
}
|
|
4420
|
-
/** Refresh one account; failures are logged, never thrown
|
|
4421
|
-
|
|
4422
|
-
async refreshOne(provider, id, isActive) {
|
|
6441
|
+
/** Refresh one recovered account by id; failures are logged, never thrown. */
|
|
6442
|
+
async refreshOne(provider, id) {
|
|
4423
6443
|
try {
|
|
4424
|
-
const ok =
|
|
4425
|
-
if (
|
|
4426
|
-
|
|
4427
|
-
} else {
|
|
4428
|
-
this.logger.info("background token refresh succeeded", { provider, accountId: id });
|
|
4429
|
-
}
|
|
6444
|
+
const ok = await this.store.refreshAccountById(provider, id);
|
|
6445
|
+
if (ok) this.logger.info("account-health recovery refresh succeeded", { provider, accountId: id });
|
|
6446
|
+
else this.logger.warn("account-health recovery refresh failed", { provider, accountId: id });
|
|
4430
6447
|
} catch (error) {
|
|
4431
|
-
this.logger.warn("
|
|
6448
|
+
this.logger.warn("account-health recovery refresh threw", {
|
|
4432
6449
|
provider,
|
|
4433
6450
|
accountId: id,
|
|
4434
6451
|
error: error instanceof Error ? error.message : String(error)
|
|
4435
6452
|
});
|
|
4436
6453
|
}
|
|
4437
6454
|
}
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
6455
|
+
};
|
|
6456
|
+
|
|
6457
|
+
// src/audit/AuditPruneSweeper.ts
|
|
6458
|
+
import { existsSync as existsSync11, readdirSync, unlinkSync } from "fs";
|
|
6459
|
+
import { join as join5 } from "path";
|
|
6460
|
+
|
|
6461
|
+
// src/audit/auditFiles.ts
|
|
6462
|
+
var AUDIT_FILE_RE = /^audit-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
6463
|
+
var pad22 = (n) => String(n).padStart(2, "0");
|
|
6464
|
+
function auditFileName(ts) {
|
|
6465
|
+
const d = new Date(ts);
|
|
6466
|
+
return `audit-${d.getFullYear()}-${pad22(d.getMonth() + 1)}-${pad22(d.getDate())}.jsonl`;
|
|
6467
|
+
}
|
|
6468
|
+
function auditFileDateMs(fileName) {
|
|
6469
|
+
const m = AUDIT_FILE_RE.exec(fileName);
|
|
6470
|
+
if (!m) return null;
|
|
6471
|
+
const year = Number(m[1]);
|
|
6472
|
+
const month = Number(m[2]);
|
|
6473
|
+
const day = Number(m[3]);
|
|
6474
|
+
const d = new Date(year, month - 1, day);
|
|
6475
|
+
if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) {
|
|
6476
|
+
return null;
|
|
6477
|
+
}
|
|
6478
|
+
return d.getTime();
|
|
6479
|
+
}
|
|
6480
|
+
|
|
6481
|
+
// src/audit/AuditPruneSweeper.ts
|
|
6482
|
+
var DAY_MS = 24 * 60 * 6e4;
|
|
6483
|
+
var SWEEP_INTERVAL_MS2 = 60 * 6e4;
|
|
6484
|
+
var AuditPruneSweeper = class {
|
|
6485
|
+
constructor(auditDir, logger, config, intervalMs = SWEEP_INTERVAL_MS2, now = Date.now) {
|
|
6486
|
+
this.auditDir = auditDir;
|
|
6487
|
+
this.logger = logger;
|
|
6488
|
+
this.config = config;
|
|
6489
|
+
this.intervalMs = intervalMs;
|
|
6490
|
+
this.now = now;
|
|
6491
|
+
}
|
|
6492
|
+
auditDir;
|
|
6493
|
+
logger;
|
|
6494
|
+
config;
|
|
6495
|
+
intervalMs;
|
|
6496
|
+
now;
|
|
6497
|
+
timer = null;
|
|
6498
|
+
sweeping = false;
|
|
6499
|
+
/** Whether pruning is active (audit enabled). */
|
|
6500
|
+
get enabled() {
|
|
6501
|
+
return this.config.enabled;
|
|
6502
|
+
}
|
|
6503
|
+
/** Re-apply config to the live instance (boot + admin PUT hot-reload). */
|
|
6504
|
+
configure(config) {
|
|
6505
|
+
this.config = config;
|
|
6506
|
+
}
|
|
6507
|
+
/**
|
|
6508
|
+
* Arm the prune interval AND run one prune immediately (boot cleanup). No-op
|
|
6509
|
+
* when audit is disabled (zero regression). Idempotent.
|
|
6510
|
+
*/
|
|
6511
|
+
start() {
|
|
6512
|
+
if (this.timer || !this.config.enabled) return;
|
|
6513
|
+
void this.sweep();
|
|
6514
|
+
this.timer = setInterval(() => void this.sweep(), this.intervalMs);
|
|
6515
|
+
this.timer.unref?.();
|
|
6516
|
+
}
|
|
6517
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6518
|
+
dispose() {
|
|
6519
|
+
if (this.timer) {
|
|
6520
|
+
clearInterval(this.timer);
|
|
6521
|
+
this.timer = null;
|
|
6522
|
+
}
|
|
6523
|
+
}
|
|
6524
|
+
/**
|
|
6525
|
+
* One prune: unlink every audit date file strictly OLDER than the retention
|
|
6526
|
+
* cutoff (`now - retentionDays` days, at local-midnight granularity). Exposed
|
|
6527
|
+
* for tests; never throws. Returns the number of files removed.
|
|
6528
|
+
*/
|
|
6529
|
+
async sweep() {
|
|
6530
|
+
if (!this.config.enabled || this.sweeping) return 0;
|
|
6531
|
+
this.sweeping = true;
|
|
6532
|
+
try {
|
|
6533
|
+
if (!existsSync11(this.auditDir)) return 0;
|
|
6534
|
+
const today = new Date(this.now());
|
|
6535
|
+
const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
|
|
6536
|
+
const cutoff = todayMidnight - (this.config.retentionDays - 1) * DAY_MS;
|
|
6537
|
+
let removed = 0;
|
|
6538
|
+
for (const file of readdirSync(this.auditDir)) {
|
|
6539
|
+
const dateMs = auditFileDateMs(file);
|
|
6540
|
+
if (dateMs === null || dateMs >= cutoff) continue;
|
|
6541
|
+
try {
|
|
6542
|
+
unlinkSync(join5(this.auditDir, file));
|
|
6543
|
+
removed += 1;
|
|
6544
|
+
} catch (error) {
|
|
6545
|
+
this.logger.warn("[AuditPruneSweeper] failed to unlink expired audit file", {
|
|
6546
|
+
file,
|
|
6547
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6548
|
+
});
|
|
6549
|
+
}
|
|
6550
|
+
}
|
|
6551
|
+
if (removed > 0) this.logger.debug("audit prune complete", { removed });
|
|
6552
|
+
return removed;
|
|
6553
|
+
} catch (error) {
|
|
6554
|
+
this.logger.warn("audit prune sweep failed", {
|
|
6555
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6556
|
+
});
|
|
6557
|
+
return 0;
|
|
6558
|
+
} finally {
|
|
6559
|
+
this.sweeping = false;
|
|
4446
6560
|
}
|
|
4447
6561
|
}
|
|
4448
6562
|
};
|
|
4449
6563
|
|
|
6564
|
+
// src/audit/auditReader.ts
|
|
6565
|
+
import { existsSync as existsSync12, readdirSync as readdirSync2, readFileSync as readFileSync11 } from "fs";
|
|
6566
|
+
import { join as join6 } from "path";
|
|
6567
|
+
var DEFAULT_LIMIT = 200;
|
|
6568
|
+
var MAX_LIMIT = 2e3;
|
|
6569
|
+
function readAuditRecords(auditDir, query = {}) {
|
|
6570
|
+
if (!existsSync12(auditDir)) return [];
|
|
6571
|
+
let files;
|
|
6572
|
+
try {
|
|
6573
|
+
files = readdirSync2(auditDir).filter((f) => AUDIT_FILE_RE.test(f));
|
|
6574
|
+
} catch {
|
|
6575
|
+
return [];
|
|
6576
|
+
}
|
|
6577
|
+
const from = typeof query.from === "number" ? query.from : -Infinity;
|
|
6578
|
+
const to = typeof query.to === "number" ? query.to : Infinity;
|
|
6579
|
+
const limit = Math.min(MAX_LIMIT, Math.max(1, Math.trunc(query.limit ?? DEFAULT_LIMIT)));
|
|
6580
|
+
const matched = [];
|
|
6581
|
+
for (const file of files.sort().reverse()) {
|
|
6582
|
+
let raw;
|
|
6583
|
+
try {
|
|
6584
|
+
raw = readFileSync11(join6(auditDir, file), "utf8");
|
|
6585
|
+
} catch {
|
|
6586
|
+
continue;
|
|
6587
|
+
}
|
|
6588
|
+
for (const line of raw.split("\n")) {
|
|
6589
|
+
const trimmed = line.trim();
|
|
6590
|
+
if (!trimmed) continue;
|
|
6591
|
+
let rec;
|
|
6592
|
+
try {
|
|
6593
|
+
rec = JSON.parse(trimmed);
|
|
6594
|
+
} catch {
|
|
6595
|
+
continue;
|
|
6596
|
+
}
|
|
6597
|
+
if (!isAuditRecord(rec)) continue;
|
|
6598
|
+
if (query.keyId !== void 0 && rec.keyId !== query.keyId) continue;
|
|
6599
|
+
if (rec.ts < from || rec.ts > to) continue;
|
|
6600
|
+
matched.push(rec);
|
|
6601
|
+
}
|
|
6602
|
+
}
|
|
6603
|
+
matched.sort((a, b) => b.ts - a.ts);
|
|
6604
|
+
return matched.slice(0, limit);
|
|
6605
|
+
}
|
|
6606
|
+
function isAuditRecord(value) {
|
|
6607
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
6608
|
+
const r = value;
|
|
6609
|
+
return typeof r["id"] === "string" && typeof r["ts"] === "number" && typeof r["method"] === "string" && typeof r["path"] === "string" && typeof r["status"] === "number";
|
|
6610
|
+
}
|
|
6611
|
+
|
|
6612
|
+
// src/audit/AuditWriter.ts
|
|
6613
|
+
import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync4 } from "fs";
|
|
6614
|
+
import { join as join7 } from "path";
|
|
6615
|
+
var AuditWriter = class {
|
|
6616
|
+
constructor(auditDir, logger, defer = (fn) => setTimeout(fn, 0)) {
|
|
6617
|
+
this.auditDir = auditDir;
|
|
6618
|
+
this.logger = logger;
|
|
6619
|
+
this.defer = defer;
|
|
6620
|
+
}
|
|
6621
|
+
auditDir;
|
|
6622
|
+
logger;
|
|
6623
|
+
defer;
|
|
6624
|
+
dirEnsured = false;
|
|
6625
|
+
/**
|
|
6626
|
+
* Enqueue one record for append. Returns IMMEDIATELY (fire-and-forget); the fs
|
|
6627
|
+
* write happens on the deferred tick. A failure is logged, never thrown.
|
|
6628
|
+
*/
|
|
6629
|
+
record(record) {
|
|
6630
|
+
this.defer(() => {
|
|
6631
|
+
try {
|
|
6632
|
+
this.appendNow(record);
|
|
6633
|
+
} catch (error) {
|
|
6634
|
+
this.logger.warn("[AuditWriter] failed to append audit record", {
|
|
6635
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6636
|
+
});
|
|
6637
|
+
}
|
|
6638
|
+
});
|
|
6639
|
+
}
|
|
6640
|
+
/**
|
|
6641
|
+
* Append synchronously — the awaitable form tests use to assert the line landed.
|
|
6642
|
+
* Ensures the `audit/` directory exists on first write (lazy, like the usage
|
|
6643
|
+
* store's lazy file creation).
|
|
6644
|
+
*/
|
|
6645
|
+
appendNow(record) {
|
|
6646
|
+
if (!this.dirEnsured) {
|
|
6647
|
+
mkdirSync4(this.auditDir, { recursive: true });
|
|
6648
|
+
this.dirEnsured = true;
|
|
6649
|
+
}
|
|
6650
|
+
const file = join7(this.auditDir, auditFileName(record.ts));
|
|
6651
|
+
appendFileSync2(file, JSON.stringify(record) + "\n", "utf8");
|
|
6652
|
+
}
|
|
6653
|
+
};
|
|
6654
|
+
|
|
6655
|
+
// src/billing/BillingPublisher.ts
|
|
6656
|
+
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync5 } from "fs";
|
|
6657
|
+
import { createHmac } from "crypto";
|
|
6658
|
+
import { join as join8 } from "path";
|
|
6659
|
+
import { fetchUpstream as fetchUpstream4 } from "@omnicross/core/pipeline/upstreamFetch";
|
|
6660
|
+
|
|
6661
|
+
// src/billing/billingFiles.ts
|
|
6662
|
+
var BILLING_FILE_RE = /^billing-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
6663
|
+
var DELIVERED_FILE_RE = /^delivered-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
6664
|
+
var pad23 = (n) => String(n).padStart(2, "0");
|
|
6665
|
+
function dateStamp(ts) {
|
|
6666
|
+
const d = new Date(ts);
|
|
6667
|
+
return `${d.getFullYear()}-${pad23(d.getMonth() + 1)}-${pad23(d.getDate())}`;
|
|
6668
|
+
}
|
|
6669
|
+
function billingFileName(ts) {
|
|
6670
|
+
return `billing-${dateStamp(ts)}.jsonl`;
|
|
6671
|
+
}
|
|
6672
|
+
function deliveredFileName(ts) {
|
|
6673
|
+
return `delivered-${dateStamp(ts)}.jsonl`;
|
|
6674
|
+
}
|
|
6675
|
+
|
|
6676
|
+
// src/billing/BillingPublisher.ts
|
|
6677
|
+
var BILLING_POST_TIMEOUT_MS = 1e4;
|
|
6678
|
+
var BillingPublisher = class {
|
|
6679
|
+
constructor(billingDir, logger, opts = {}) {
|
|
6680
|
+
this.billingDir = billingDir;
|
|
6681
|
+
this.logger = logger;
|
|
6682
|
+
this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetchUpstream4(url, init));
|
|
6683
|
+
this.defer = opts.defer ?? ((fn) => setTimeout(fn, 0));
|
|
6684
|
+
this.timeoutMs = opts.timeoutMs ?? BILLING_POST_TIMEOUT_MS;
|
|
6685
|
+
this.now = opts.now ?? Date.now;
|
|
6686
|
+
}
|
|
6687
|
+
billingDir;
|
|
6688
|
+
logger;
|
|
6689
|
+
config;
|
|
6690
|
+
dirEnsured = false;
|
|
6691
|
+
fetchImpl;
|
|
6692
|
+
defer;
|
|
6693
|
+
timeoutMs;
|
|
6694
|
+
now;
|
|
6695
|
+
/** Install/replace the live billing config (endpoint + secret + retry bound). */
|
|
6696
|
+
setConfig(config) {
|
|
6697
|
+
this.config = config;
|
|
6698
|
+
}
|
|
6699
|
+
/**
|
|
6700
|
+
* Record one billing event. DURABLE-FIRST: append synchronously (the event is
|
|
6701
|
+
* now on disk, never lost), THEN schedule a best-effort POST off the caller's
|
|
6702
|
+
* stack (non-blocking; ledger-only when no endpoint). Returns IMMEDIATELY and
|
|
6703
|
+
* NEVER throws — a failing append/POST is logged, never propagated.
|
|
6704
|
+
*/
|
|
6705
|
+
record(event) {
|
|
6706
|
+
let appended = false;
|
|
6707
|
+
try {
|
|
6708
|
+
this.appendNow(event);
|
|
6709
|
+
appended = true;
|
|
6710
|
+
} catch (error) {
|
|
6711
|
+
this.logger.warn("[BillingPublisher] failed to append billing event", {
|
|
6712
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6713
|
+
});
|
|
6714
|
+
}
|
|
6715
|
+
if (appended && this.config?.endpoint) {
|
|
6716
|
+
this.defer(() => {
|
|
6717
|
+
void this.deliverNow(event).catch(() => {
|
|
6718
|
+
});
|
|
6719
|
+
});
|
|
6720
|
+
}
|
|
6721
|
+
}
|
|
6722
|
+
/**
|
|
6723
|
+
* Append the event as one JSON line to `billing-YYYY-MM-DD.jsonl` (the event's
|
|
6724
|
+
* LOCAL date). Synchronous — the awaitable form tests use to assert the ledger
|
|
6725
|
+
* line landed BEFORE any delivery. Ensures the `billing/` directory on first write.
|
|
6726
|
+
*/
|
|
6727
|
+
appendNow(event) {
|
|
6728
|
+
this.ensureDir();
|
|
6729
|
+
const file = join8(this.billingDir, billingFileName(event.ts));
|
|
6730
|
+
appendFileSync3(file, JSON.stringify(event) + "\n", "utf8");
|
|
6731
|
+
}
|
|
6732
|
+
/**
|
|
6733
|
+
* One best-effort delivery attempt for an event ALREADY in the ledger. POSTs the
|
|
6734
|
+
* event JSON to the configured endpoint (optionally HMAC-signed); on a 2xx ack
|
|
6735
|
+
* appends a delivery marker and returns `true`. Any non-2xx / thrown / timed-out
|
|
6736
|
+
* attempt returns `false` — the event stays UNdelivered in the ledger (never
|
|
6737
|
+
* lost). NEVER rejects. A no-op `false` when no endpoint is configured.
|
|
6738
|
+
*/
|
|
6739
|
+
async deliverNow(event) {
|
|
6740
|
+
const endpoint = this.config?.endpoint;
|
|
6741
|
+
if (!endpoint) return false;
|
|
6742
|
+
try {
|
|
6743
|
+
const body = JSON.stringify(event);
|
|
6744
|
+
const headers = { "Content-Type": "application/json" };
|
|
6745
|
+
const secret = this.config?.secret;
|
|
6746
|
+
if (secret) {
|
|
6747
|
+
const hmac = createHmac("sha256", secret).update(body).digest("hex");
|
|
6748
|
+
headers["X-Omnicross-Billing-Signature"] = `sha256=${hmac}`;
|
|
6749
|
+
}
|
|
6750
|
+
const res = await this.fetchImpl(endpoint, {
|
|
6751
|
+
method: "POST",
|
|
6752
|
+
headers,
|
|
6753
|
+
body,
|
|
6754
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
6755
|
+
});
|
|
6756
|
+
if (!res.ok) {
|
|
6757
|
+
this.logger.debug(`[billing] delivery failed ${event.id} (HTTP ${res.status})`);
|
|
6758
|
+
return false;
|
|
6759
|
+
}
|
|
6760
|
+
this.markDelivered(event);
|
|
6761
|
+
this.logger.debug(`[billing] delivered ${event.id}`);
|
|
6762
|
+
return true;
|
|
6763
|
+
} catch (error) {
|
|
6764
|
+
this.logger.debug(
|
|
6765
|
+
`[billing] delivery error ${event.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
6766
|
+
);
|
|
6767
|
+
return false;
|
|
6768
|
+
}
|
|
6769
|
+
}
|
|
6770
|
+
/**
|
|
6771
|
+
* Append a delivery marker `{ id, deliveredAt }` to `delivered-YYYY-MM-DD.jsonl`
|
|
6772
|
+
* (keyed by the EVENT's date so the reader finds both together). Idempotent at
|
|
6773
|
+
* the reconciliation layer — the reader unions marker ids into a delivered set,
|
|
6774
|
+
* so a duplicate marker is harmless. A marker-write failure is logged, never thrown.
|
|
6775
|
+
*/
|
|
6776
|
+
markDelivered(event) {
|
|
6777
|
+
try {
|
|
6778
|
+
this.ensureDir();
|
|
6779
|
+
const file = join8(this.billingDir, deliveredFileName(event.ts));
|
|
6780
|
+
appendFileSync3(file, JSON.stringify({ id: event.id, deliveredAt: this.now() }) + "\n", "utf8");
|
|
6781
|
+
} catch (error) {
|
|
6782
|
+
this.logger.warn("[BillingPublisher] failed to append delivery marker", {
|
|
6783
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6784
|
+
});
|
|
6785
|
+
}
|
|
6786
|
+
}
|
|
6787
|
+
ensureDir() {
|
|
6788
|
+
if (this.dirEnsured) return;
|
|
6789
|
+
mkdirSync5(this.billingDir, { recursive: true });
|
|
6790
|
+
this.dirEnsured = true;
|
|
6791
|
+
}
|
|
6792
|
+
};
|
|
6793
|
+
|
|
6794
|
+
// src/billing/billingReader.ts
|
|
6795
|
+
import { existsSync as existsSync13, readdirSync as readdirSync3, readFileSync as readFileSync12 } from "fs";
|
|
6796
|
+
import { join as join9 } from "path";
|
|
6797
|
+
function readBillingLedger(billingDir) {
|
|
6798
|
+
const view = { events: [], deliveredIds: /* @__PURE__ */ new Set() };
|
|
6799
|
+
if (!existsSync13(billingDir)) return view;
|
|
6800
|
+
let files;
|
|
6801
|
+
try {
|
|
6802
|
+
files = readdirSync3(billingDir);
|
|
6803
|
+
} catch {
|
|
6804
|
+
return view;
|
|
6805
|
+
}
|
|
6806
|
+
for (const file of files.sort()) {
|
|
6807
|
+
if (BILLING_FILE_RE.test(file)) {
|
|
6808
|
+
for (const rec of parseLines(billingDir, file)) {
|
|
6809
|
+
if (isBillingEvent(rec)) view.events.push(rec);
|
|
6810
|
+
}
|
|
6811
|
+
} else if (DELIVERED_FILE_RE.test(file)) {
|
|
6812
|
+
for (const rec of parseLines(billingDir, file)) {
|
|
6813
|
+
const id = rec.id;
|
|
6814
|
+
if (typeof id === "string") view.deliveredIds.add(id);
|
|
6815
|
+
}
|
|
6816
|
+
}
|
|
6817
|
+
}
|
|
6818
|
+
return view;
|
|
6819
|
+
}
|
|
6820
|
+
function readUndeliveredEvents(billingDir) {
|
|
6821
|
+
const { events, deliveredIds } = readBillingLedger(billingDir);
|
|
6822
|
+
return events.filter((e) => !deliveredIds.has(e.id)).sort((a, b) => a.ts - b.ts);
|
|
6823
|
+
}
|
|
6824
|
+
function readBillingStatus(billingDir) {
|
|
6825
|
+
const { events, deliveredIds } = readBillingLedger(billingDir);
|
|
6826
|
+
let delivered = 0;
|
|
6827
|
+
for (const e of events) if (deliveredIds.has(e.id)) delivered += 1;
|
|
6828
|
+
return { total: events.length, delivered, pending: events.length - delivered };
|
|
6829
|
+
}
|
|
6830
|
+
function parseLines(dir, file) {
|
|
6831
|
+
let raw;
|
|
6832
|
+
try {
|
|
6833
|
+
raw = readFileSync12(join9(dir, file), "utf8");
|
|
6834
|
+
} catch {
|
|
6835
|
+
return [];
|
|
6836
|
+
}
|
|
6837
|
+
const out = [];
|
|
6838
|
+
for (const line of raw.split("\n")) {
|
|
6839
|
+
const trimmed = line.trim();
|
|
6840
|
+
if (!trimmed) continue;
|
|
6841
|
+
try {
|
|
6842
|
+
out.push(JSON.parse(trimmed));
|
|
6843
|
+
} catch {
|
|
6844
|
+
}
|
|
6845
|
+
}
|
|
6846
|
+
return out;
|
|
6847
|
+
}
|
|
6848
|
+
function isBillingEvent(value) {
|
|
6849
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
6850
|
+
const r = value;
|
|
6851
|
+
return typeof r["id"] === "string" && typeof r["ts"] === "number" && typeof r["model"] === "string" && typeof r["status"] === "number";
|
|
6852
|
+
}
|
|
6853
|
+
|
|
6854
|
+
// src/billing/BillingRetrySweeper.ts
|
|
6855
|
+
var SWEEP_INTERVAL_MS3 = 5 * 6e4;
|
|
6856
|
+
var BillingRetrySweeper = class {
|
|
6857
|
+
constructor(billingDir, publisher2, logger, config, intervalMs = SWEEP_INTERVAL_MS3, now = Date.now) {
|
|
6858
|
+
this.billingDir = billingDir;
|
|
6859
|
+
this.publisher = publisher2;
|
|
6860
|
+
this.logger = logger;
|
|
6861
|
+
this.config = config;
|
|
6862
|
+
this.intervalMs = intervalMs;
|
|
6863
|
+
this.now = now;
|
|
6864
|
+
}
|
|
6865
|
+
billingDir;
|
|
6866
|
+
publisher;
|
|
6867
|
+
logger;
|
|
6868
|
+
config;
|
|
6869
|
+
intervalMs;
|
|
6870
|
+
now;
|
|
6871
|
+
timer = null;
|
|
6872
|
+
sweeping = false;
|
|
6873
|
+
/** Whether retrying is active: billing enabled AND an endpoint is configured. */
|
|
6874
|
+
get enabled() {
|
|
6875
|
+
return this.config.enabled && typeof this.config.endpoint === "string" && this.config.endpoint.length > 0;
|
|
6876
|
+
}
|
|
6877
|
+
/** Re-apply config to the live instance (boot + admin PUT hot-reload). */
|
|
6878
|
+
configure(config) {
|
|
6879
|
+
this.config = config;
|
|
6880
|
+
}
|
|
6881
|
+
/**
|
|
6882
|
+
* Arm the retry interval AND run one sweep immediately (boot catch-up for events
|
|
6883
|
+
* that failed to deliver while the daemon was down). No-op when disabled or in
|
|
6884
|
+
* ledger-only mode (no endpoint to POST to). Idempotent.
|
|
6885
|
+
*/
|
|
6886
|
+
start() {
|
|
6887
|
+
if (this.timer || !this.enabled) return;
|
|
6888
|
+
void this.sweep();
|
|
6889
|
+
this.timer = setInterval(() => void this.sweep(), this.intervalMs);
|
|
6890
|
+
this.timer.unref?.();
|
|
6891
|
+
}
|
|
6892
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6893
|
+
dispose() {
|
|
6894
|
+
if (this.timer) {
|
|
6895
|
+
clearInterval(this.timer);
|
|
6896
|
+
this.timer = null;
|
|
6897
|
+
}
|
|
6898
|
+
}
|
|
6899
|
+
/**
|
|
6900
|
+
* One sweep: re-POST every UNdelivered ledger event still within
|
|
6901
|
+
* `maxRetryAgeMs`; leave over-age undelivered events for reconciliation (NEVER
|
|
6902
|
+
* deleted). Exposed for tests; never throws. Returns the number of events a
|
|
6903
|
+
* re-POST was attempted for.
|
|
6904
|
+
*/
|
|
6905
|
+
async sweep() {
|
|
6906
|
+
if (!this.enabled || this.sweeping) return 0;
|
|
6907
|
+
this.sweeping = true;
|
|
6908
|
+
try {
|
|
6909
|
+
const cutoff = this.now() - this.config.maxRetryAgeMs;
|
|
6910
|
+
let attempted = 0;
|
|
6911
|
+
for (const event of readUndeliveredEvents(this.billingDir)) {
|
|
6912
|
+
if (event.ts < cutoff) continue;
|
|
6913
|
+
attempted += 1;
|
|
6914
|
+
await this.publisher.deliverNow(event);
|
|
6915
|
+
}
|
|
6916
|
+
if (attempted > 0) this.logger.debug("billing retry sweep complete", { attempted });
|
|
6917
|
+
return attempted;
|
|
6918
|
+
} catch (error) {
|
|
6919
|
+
this.logger.warn("billing retry sweep failed", {
|
|
6920
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6921
|
+
});
|
|
6922
|
+
return 0;
|
|
6923
|
+
} finally {
|
|
6924
|
+
this.sweeping = false;
|
|
6925
|
+
}
|
|
6926
|
+
}
|
|
6927
|
+
};
|
|
6928
|
+
|
|
6929
|
+
// src/TokenRefreshScheduler.ts
|
|
6930
|
+
var REFRESH_LEAD_MS2 = 5 * 6e4;
|
|
6931
|
+
var SWEEP_INTERVAL_MS4 = 6e4;
|
|
6932
|
+
var OAUTH_PROVIDERS2 = ["claude", "codex", "gemini"];
|
|
6933
|
+
var TokenRefreshScheduler = class {
|
|
6934
|
+
constructor(store, logger, intervalMs = SWEEP_INTERVAL_MS4, leadMs = REFRESH_LEAD_MS2) {
|
|
6935
|
+
this.store = store;
|
|
6936
|
+
this.logger = logger;
|
|
6937
|
+
this.intervalMs = intervalMs;
|
|
6938
|
+
this.leadMs = leadMs;
|
|
6939
|
+
}
|
|
6940
|
+
store;
|
|
6941
|
+
logger;
|
|
6942
|
+
intervalMs;
|
|
6943
|
+
leadMs;
|
|
6944
|
+
timer = null;
|
|
6945
|
+
sweeping = false;
|
|
6946
|
+
/** Arm the sweep interval. Idempotent. The timer never holds the loop open. */
|
|
6947
|
+
start() {
|
|
6948
|
+
if (this.timer) return;
|
|
6949
|
+
this.timer = setInterval(() => void this.sweep(), this.intervalMs);
|
|
6950
|
+
this.timer.unref?.();
|
|
6951
|
+
}
|
|
6952
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6953
|
+
dispose() {
|
|
6954
|
+
if (this.timer) {
|
|
6955
|
+
clearInterval(this.timer);
|
|
6956
|
+
this.timer = null;
|
|
6957
|
+
}
|
|
6958
|
+
}
|
|
6959
|
+
/** One sweep over every account of every OAuth provider. Exposed for tests. */
|
|
6960
|
+
async sweep(now = Date.now()) {
|
|
6961
|
+
if (this.sweeping) return;
|
|
6962
|
+
this.sweeping = true;
|
|
6963
|
+
try {
|
|
6964
|
+
const config = await this.store.getFullConfig();
|
|
6965
|
+
for (const provider of OAUTH_PROVIDERS2) {
|
|
6966
|
+
const activeId = getActiveAccount(config, provider)?.id;
|
|
6967
|
+
for (const account of listAccounts(config, provider)) {
|
|
6968
|
+
if (!this.needsRefresh(account.tokens, now)) continue;
|
|
6969
|
+
await this.refreshOne(provider, account.id, account.id === activeId);
|
|
6970
|
+
}
|
|
6971
|
+
}
|
|
6972
|
+
} catch (error) {
|
|
6973
|
+
this.logger.warn("token-refresh sweep failed", {
|
|
6974
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6975
|
+
});
|
|
6976
|
+
} finally {
|
|
6977
|
+
this.sweeping = false;
|
|
6978
|
+
}
|
|
6979
|
+
}
|
|
6980
|
+
/** Expiring within the lead window, refreshable, and not already dead. */
|
|
6981
|
+
needsRefresh(tokens, now) {
|
|
6982
|
+
const t = tokens;
|
|
6983
|
+
if (!t.refreshToken || t.status === "expired" || t.status === "error") return false;
|
|
6984
|
+
if (!t.expiresAt) return false;
|
|
6985
|
+
const expiresAt = Date.parse(t.expiresAt);
|
|
6986
|
+
return Number.isFinite(expiresAt) && now >= expiresAt - this.leadMs;
|
|
6987
|
+
}
|
|
6988
|
+
/** Refresh one account; failures are logged, never thrown (the store has
|
|
6989
|
+
* already flagged the account `expired`). */
|
|
6990
|
+
async refreshOne(provider, id, isActive) {
|
|
6991
|
+
try {
|
|
6992
|
+
const ok = isActive ? await this.refreshActive(provider) : await this.store.refreshAccountById(provider, id);
|
|
6993
|
+
if (!ok) {
|
|
6994
|
+
this.logger.warn("background token refresh failed", { provider, accountId: id });
|
|
6995
|
+
} else {
|
|
6996
|
+
this.logger.info("background token refresh succeeded", { provider, accountId: id });
|
|
6997
|
+
}
|
|
6998
|
+
} catch (error) {
|
|
6999
|
+
this.logger.warn("background token refresh threw", {
|
|
7000
|
+
provider,
|
|
7001
|
+
accountId: id,
|
|
7002
|
+
error: error instanceof Error ? error.message : String(error)
|
|
7003
|
+
});
|
|
7004
|
+
}
|
|
7005
|
+
}
|
|
7006
|
+
refreshActive(provider) {
|
|
7007
|
+
switch (provider) {
|
|
7008
|
+
case "claude":
|
|
7009
|
+
return this.store.refreshClaudeToken();
|
|
7010
|
+
case "codex":
|
|
7011
|
+
return this.store.refreshCodexToken();
|
|
7012
|
+
case "gemini":
|
|
7013
|
+
return this.store.refreshGeminiToken();
|
|
7014
|
+
}
|
|
7015
|
+
}
|
|
7016
|
+
};
|
|
7017
|
+
|
|
7018
|
+
// src/webhook/WebhookDispatcher.ts
|
|
7019
|
+
import { createHmac as createHmac2 } from "crypto";
|
|
7020
|
+
import { fetchUpstream as fetchUpstream5 } from "@omnicross/core/pipeline/upstreamFetch";
|
|
7021
|
+
var WEBHOOK_MAX_ATTEMPTS = 3;
|
|
7022
|
+
var WEBHOOK_QUEUE_MAX = 1e3;
|
|
7023
|
+
var WEBHOOK_SEND_TIMEOUT_MS = 1e4;
|
|
7024
|
+
var WEBHOOK_BASE_BACKOFF_MS = 200;
|
|
7025
|
+
var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
7026
|
+
var WebhookDispatcher = class {
|
|
7027
|
+
config;
|
|
7028
|
+
queue = [];
|
|
7029
|
+
draining = false;
|
|
7030
|
+
warnedFull = false;
|
|
7031
|
+
fetchImpl;
|
|
7032
|
+
logger;
|
|
7033
|
+
maxAttempts;
|
|
7034
|
+
queueMax;
|
|
7035
|
+
timeoutMs;
|
|
7036
|
+
baseBackoffMs;
|
|
7037
|
+
sleep;
|
|
7038
|
+
now;
|
|
7039
|
+
constructor(opts = {}) {
|
|
7040
|
+
this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetchUpstream5(url, init));
|
|
7041
|
+
this.logger = opts.logger;
|
|
7042
|
+
this.maxAttempts = opts.maxAttempts ?? WEBHOOK_MAX_ATTEMPTS;
|
|
7043
|
+
this.queueMax = opts.queueMax ?? WEBHOOK_QUEUE_MAX;
|
|
7044
|
+
this.timeoutMs = opts.timeoutMs ?? WEBHOOK_SEND_TIMEOUT_MS;
|
|
7045
|
+
this.baseBackoffMs = opts.baseBackoffMs ?? WEBHOOK_BASE_BACKOFF_MS;
|
|
7046
|
+
this.sleep = opts.sleep ?? defaultSleep;
|
|
7047
|
+
this.now = opts.now ?? Date.now;
|
|
7048
|
+
}
|
|
7049
|
+
/** Install/replace the live webhook config (destinations + master switch). */
|
|
7050
|
+
setConfig(config) {
|
|
7051
|
+
this.config = config;
|
|
7052
|
+
}
|
|
7053
|
+
/**
|
|
7054
|
+
* Enqueue an event and return IMMEDIATELY (fire-and-forget). NEVER awaits a
|
|
7055
|
+
* send, NEVER throws — the drain loop does all sending on a side channel. A
|
|
7056
|
+
* full queue drops the OLDEST event (with a one-shot warn) so a runaway source
|
|
7057
|
+
* can't OOM the process.
|
|
7058
|
+
*/
|
|
7059
|
+
emit(event) {
|
|
7060
|
+
if (this.queue.length >= this.queueMax) {
|
|
7061
|
+
this.queue.shift();
|
|
7062
|
+
if (!this.warnedFull) {
|
|
7063
|
+
this.logger?.warn("[webhook] queue full \u2014 dropping oldest events");
|
|
7064
|
+
this.warnedFull = true;
|
|
7065
|
+
}
|
|
7066
|
+
}
|
|
7067
|
+
this.queue.push(event);
|
|
7068
|
+
if (!this.draining) {
|
|
7069
|
+
this.draining = true;
|
|
7070
|
+
queueMicrotask(() => void this.drain());
|
|
7071
|
+
}
|
|
7072
|
+
}
|
|
7073
|
+
/** Drain the queue, sending each event to its matching destinations concurrently. */
|
|
7074
|
+
async drain() {
|
|
7075
|
+
try {
|
|
7076
|
+
while (this.queue.length > 0) {
|
|
7077
|
+
const event = this.queue.shift();
|
|
7078
|
+
const destinations = this.matchingDestinations(event.kind);
|
|
7079
|
+
if (destinations.length === 0) continue;
|
|
7080
|
+
await Promise.all(destinations.map((d) => this.sendWithRetry(event, d)));
|
|
7081
|
+
}
|
|
7082
|
+
} finally {
|
|
7083
|
+
this.draining = false;
|
|
7084
|
+
if (this.queue.length > 0) {
|
|
7085
|
+
this.draining = true;
|
|
7086
|
+
queueMicrotask(() => void this.drain());
|
|
7087
|
+
}
|
|
7088
|
+
}
|
|
7089
|
+
}
|
|
7090
|
+
/** The enabled destinations whose event filter admits this kind (empty ⇒ all). */
|
|
7091
|
+
matchingDestinations(kind) {
|
|
7092
|
+
const cfg = this.config;
|
|
7093
|
+
if (!cfg || !cfg.enabled) return [];
|
|
7094
|
+
return cfg.destinations.filter(
|
|
7095
|
+
(d) => d.enabled && (!d.events || d.events.length === 0 || d.events.includes(kind))
|
|
7096
|
+
);
|
|
7097
|
+
}
|
|
7098
|
+
/** Send with bounded exponential backoff; log-and-drop after `maxAttempts`. */
|
|
7099
|
+
async sendWithRetry(event, dest) {
|
|
7100
|
+
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
|
7101
|
+
const result = await this.sendOnce(event, dest);
|
|
7102
|
+
if (result.ok) {
|
|
7103
|
+
this.logger?.debug(`[webhook] delivered ${event.kind} \u2192 ${dest.id} (${result.status})`);
|
|
7104
|
+
return;
|
|
7105
|
+
}
|
|
7106
|
+
if (attempt < this.maxAttempts) {
|
|
7107
|
+
await this.sleep(this.baseBackoffMs * 2 ** (attempt - 1));
|
|
7108
|
+
} else {
|
|
7109
|
+
this.logger?.warn(
|
|
7110
|
+
`[webhook] dropped ${event.kind} \u2192 ${dest.id} after ${this.maxAttempts} attempts: ${result.error ?? `HTTP ${result.status}`}`
|
|
7111
|
+
);
|
|
7112
|
+
}
|
|
7113
|
+
}
|
|
7114
|
+
}
|
|
7115
|
+
/** One POST attempt. Returns an outcome; a thrown error becomes `{ ok:false }`. */
|
|
7116
|
+
async sendOnce(event, dest) {
|
|
7117
|
+
try {
|
|
7118
|
+
const { body, headers } = buildRequest(event, dest, this.now());
|
|
7119
|
+
const res = await this.fetchImpl(dest.url, {
|
|
7120
|
+
method: "POST",
|
|
7121
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
7122
|
+
body,
|
|
7123
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
7124
|
+
});
|
|
7125
|
+
return res.ok ? { ok: true, status: res.status } : { ok: false, status: res.status };
|
|
7126
|
+
} catch (err5) {
|
|
7127
|
+
return { ok: false, error: err5 instanceof Error ? err5.message : String(err5) };
|
|
7128
|
+
}
|
|
7129
|
+
}
|
|
7130
|
+
/**
|
|
7131
|
+
* ADMIN test path (design D8): deliver a `test` event to ONE destination and
|
|
7132
|
+
* AWAIT the single-attempt result. This is the ONLY awaited send — it runs on
|
|
7133
|
+
* the admin request path (an operator clicking "Test"), NEVER on a relay path,
|
|
7134
|
+
* so awaiting it is safe. Finds the destination regardless of its `enabled`
|
|
7135
|
+
* flag or the master switch (an explicit operator action).
|
|
7136
|
+
*/
|
|
7137
|
+
async deliverTest(destinationId) {
|
|
7138
|
+
const dest = this.config?.destinations.find((d) => d.id === destinationId);
|
|
7139
|
+
if (!dest) return { ok: false, error: "destination not found" };
|
|
7140
|
+
return this.sendOnce({ kind: "test", at: this.now() }, dest);
|
|
7141
|
+
}
|
|
7142
|
+
};
|
|
7143
|
+
function buildRequest(event, dest, nowMs) {
|
|
7144
|
+
if (dest.type === "feishu") return buildFeishu(event, dest, nowMs);
|
|
7145
|
+
return buildCustom(event, dest);
|
|
7146
|
+
}
|
|
7147
|
+
function buildCustom(event, dest) {
|
|
7148
|
+
const body = JSON.stringify(event);
|
|
7149
|
+
const headers = {};
|
|
7150
|
+
if (dest.secret) {
|
|
7151
|
+
const hmac = createHmac2("sha256", dest.secret).update(body).digest("hex");
|
|
7152
|
+
headers["X-Omnicross-Signature"] = `sha256=${hmac}`;
|
|
7153
|
+
}
|
|
7154
|
+
return { body, headers };
|
|
7155
|
+
}
|
|
7156
|
+
function buildFeishu(event, dest, nowMs) {
|
|
7157
|
+
const payload = {
|
|
7158
|
+
msg_type: "text",
|
|
7159
|
+
content: { text: feishuText(event) }
|
|
7160
|
+
};
|
|
7161
|
+
if (dest.secret) {
|
|
7162
|
+
const timestamp = Math.floor(nowMs / 1e3).toString();
|
|
7163
|
+
const stringToSign = `${timestamp}
|
|
7164
|
+
${dest.secret}`;
|
|
7165
|
+
payload["timestamp"] = timestamp;
|
|
7166
|
+
payload["sign"] = createHmac2("sha256", stringToSign).digest("base64");
|
|
7167
|
+
}
|
|
7168
|
+
return { body: JSON.stringify(payload), headers: {} };
|
|
7169
|
+
}
|
|
7170
|
+
function feishuText(event) {
|
|
7171
|
+
switch (event.kind) {
|
|
7172
|
+
case "account.recovery":
|
|
7173
|
+
return `omnicross: account recovered \u2014 ${event.providerId}/${event.accountId}`;
|
|
7174
|
+
case "account.anomaly":
|
|
7175
|
+
return `omnicross: account anomaly [${event.state}] \u2014 ${event.providerId}/${event.accountId}`;
|
|
7176
|
+
case "key.quotaWarning":
|
|
7177
|
+
return `omnicross: key quota warning (${event.scope}) \u2014 $${event.spentUsd.toFixed(2)} of $${event.limitUsd.toFixed(2)} (key ${event.keyId})`;
|
|
7178
|
+
case "key.quotaExceeded":
|
|
7179
|
+
return `omnicross: key quota EXCEEDED (${event.scope}) \u2014 $${event.spentUsd.toFixed(2)} of $${event.limitUsd.toFixed(2)} (key ${event.keyId})`;
|
|
7180
|
+
case "server.error":
|
|
7181
|
+
return `omnicross: server error \u2014 ${event.message}`;
|
|
7182
|
+
case "test":
|
|
7183
|
+
return "omnicross: webhook test";
|
|
7184
|
+
}
|
|
7185
|
+
}
|
|
7186
|
+
|
|
4450
7187
|
// src/bootstrap.ts
|
|
4451
7188
|
function buildDaemon(config, paths) {
|
|
4452
|
-
const logger = new
|
|
7189
|
+
const logger = new ConfigurableLogger(config.logging);
|
|
4453
7190
|
const secretBox3 = new SecretBox(() => resolveMasterKey({ keyFilePath: paths.masterKeyFilePath }));
|
|
4454
7191
|
setSecretBox(secretBox3);
|
|
4455
7192
|
setSecretBox2(secretBox3);
|
|
4456
7193
|
const decryptedConfig = decryptConfigSecrets(config, secretBox3);
|
|
4457
7194
|
const llmConfig = new ConfigFileProviderConfigSource(decryptedConfig);
|
|
4458
7195
|
const keyDb = new JsonOutboundKeyDb(paths.keysPath);
|
|
4459
|
-
const
|
|
7196
|
+
const voucherDb = new JsonVoucherDb(defaultVouchersPath(paths.configPath));
|
|
7197
|
+
const settingsStore = new JsonApiServerSettingsStore(paths.configPath, secretBox3);
|
|
4460
7198
|
const credentialStore = new JsonSubscriptionCredentialStore(paths.tokensPath, secretBox3);
|
|
4461
7199
|
const subscriptionAccounts = new SubscriptionAccountService(credentialStore);
|
|
4462
7200
|
setSubscriptionAccountService(subscriptionAccounts);
|
|
@@ -4465,6 +7203,12 @@ function buildDaemon(config, paths) {
|
|
|
4465
7203
|
credentialStore
|
|
4466
7204
|
);
|
|
4467
7205
|
setSubscriptionProviderRegistry(subscriptionRegistry);
|
|
7206
|
+
setServerProxyConfig(decryptedConfig.server?.proxy);
|
|
7207
|
+
setUpstreamProxyResolver(
|
|
7208
|
+
createUpstreamProxyResolver({
|
|
7209
|
+
getAccountProxy: (providerId, accountId) => credentialStore.getAccountProxy(providerId, accountId)
|
|
7210
|
+
})
|
|
7211
|
+
);
|
|
4468
7212
|
setGeminiCodeAssistResolver(getGeminiCodeAssistProjectResolver());
|
|
4469
7213
|
const autoDisableStore = new AutoDisableStore();
|
|
4470
7214
|
const apiKeyPool = new ApiKeyPoolService(
|
|
@@ -4485,19 +7229,59 @@ function buildDaemon(config, paths) {
|
|
|
4485
7229
|
defaultUsageEventsPath(paths.configPath),
|
|
4486
7230
|
async (providerId, model) => await pricingEngine.getEntry(providerId, model) !== null
|
|
4487
7231
|
);
|
|
4488
|
-
const
|
|
7232
|
+
const keySpendTracker = new KeySpendTracker(usageEventStore);
|
|
7233
|
+
const usageRecorder = new UsageRecorder(usageEventStore, pricingEngine, logger, {
|
|
7234
|
+
onRecord: (apiKeyId, costUsd, at) => keySpendTracker.add(apiKeyId, costUsd, at)
|
|
7235
|
+
});
|
|
4489
7236
|
const providerProxy = getProviderProxy({ llmConfig, apiKeyPool, usageRecorder });
|
|
4490
7237
|
llmConfig.setReloadHook(() => apiKeyPool.invalidateCache());
|
|
7238
|
+
const accountHealthProbeScheduler = new AccountHealthProbeScheduler(
|
|
7239
|
+
credentialStore,
|
|
7240
|
+
getSharedAccountHealth2(),
|
|
7241
|
+
logger,
|
|
7242
|
+
DEFAULT_ACCOUNT_PROBE
|
|
7243
|
+
);
|
|
7244
|
+
const getHealthReport = () => buildHealthReport({
|
|
7245
|
+
version: DAEMON_VERSION,
|
|
7246
|
+
// CRITICAL: the config loaded with a providers array.
|
|
7247
|
+
configPresent: () => Array.isArray(decryptedConfig.providers),
|
|
7248
|
+
// CRITICAL: the credential store's tokens.json is readable WITHOUT
|
|
7249
|
+
// decrypting (a missing file is fine — no accounts yet). A stat/access
|
|
7250
|
+
// only; never reads or decrypts token material.
|
|
7251
|
+
credentialStoreReadable: () => isTokensStoreReadable(paths.tokensPath),
|
|
7252
|
+
outboundServerRunning: () => outboundApiServer.getStatus().running,
|
|
7253
|
+
adminServerRunning: () => adminServer.getStatus().running,
|
|
7254
|
+
// Coarse, account-anonymous probe signal (#8, D5) — added to `checks` ONLY
|
|
7255
|
+
// when probing is ENABLED; disabled ⇒ `undefined` ⇒ key omitted ⇒ the
|
|
7256
|
+
// `/health` body stays byte-identical (zero regression).
|
|
7257
|
+
subscriptionAccountsHealthy: () => accountHealthProbeScheduler.enabled ? accountHealthProbeScheduler.probedAccountsHealthy() : void 0
|
|
7258
|
+
});
|
|
4491
7259
|
const outboundApiServer = getOutboundApiServer({
|
|
4492
7260
|
db: keyDb,
|
|
7261
|
+
// voucher-redemption #9: the key-authenticated `POST /redeem` endpoint redeems
|
|
7262
|
+
// cards against the presenting key (gated on `voucher.enabled`).
|
|
7263
|
+
voucherDb,
|
|
4493
7264
|
llmConfig,
|
|
4494
7265
|
providerProxy,
|
|
4495
|
-
proxyDeps: providerProxy.getDeps()
|
|
7266
|
+
proxyDeps: providerProxy.getDeps(),
|
|
7267
|
+
healthReportProvider: getHealthReport,
|
|
7268
|
+
// outbound-key-policy: the wire layer's 402 cost check reads per-key spend.
|
|
7269
|
+
keySpendTracker,
|
|
7270
|
+
// configurable-logging: route the server's OWN lifecycle + relay dispatch-error
|
|
7271
|
+
// lines through the injected logger (honors level/format/file sink).
|
|
7272
|
+
logger
|
|
4496
7273
|
});
|
|
7274
|
+
const auditDir = defaultAuditDir(paths.configPath);
|
|
7275
|
+
const billingDir = defaultBillingDir(paths.configPath);
|
|
4497
7276
|
const adminServer = new AdminServer({
|
|
4498
7277
|
configPath: paths.configPath,
|
|
4499
7278
|
llmConfig,
|
|
4500
7279
|
keyDb,
|
|
7280
|
+
// voucher-redemption #9: the admin `/admin/api/voucher` surface generates/
|
|
7281
|
+
// lists/revokes redemption cards (gated on `voucher.enabled`).
|
|
7282
|
+
voucherDb,
|
|
7283
|
+
// outbound-key-policy: the admin key list surfaces each key's OWN spend.
|
|
7284
|
+
keySpendReader: keySpendTracker,
|
|
4501
7285
|
settingsStore,
|
|
4502
7286
|
outboundApiServer,
|
|
4503
7287
|
subscriptionAccounts,
|
|
@@ -4519,7 +7303,9 @@ function buildDaemon(config, paths) {
|
|
|
4519
7303
|
oauthSessions: new OAuthSessionStore(),
|
|
4520
7304
|
// Real global fetch by default; a test seam (`paths.oauthExchangeFetch`) can
|
|
4521
7305
|
// inject a mock so no real token endpoint is hit.
|
|
4522
|
-
|
|
7306
|
+
// upstream-proxy: default the OAuth token-exchange fetch to the proxy-aware
|
|
7307
|
+
// helper so interactive login honors a configured proxy (global/env layers).
|
|
7308
|
+
oauthExchangeFetch: paths.oauthExchangeFetch ?? ((url, init) => fetchUpstream6(url, init)),
|
|
4523
7309
|
subscriptionAccountAppender: credentialStore,
|
|
4524
7310
|
// Codex interactive OAuth (app-parity-2 child 5) — the async loopback flow store
|
|
4525
7311
|
// + the one-shot 127.0.0.1:1455 listener. Token captured + persisted daemon-side;
|
|
@@ -4546,9 +7332,48 @@ function buildDaemon(config, paths) {
|
|
|
4546
7332
|
pricingStore,
|
|
4547
7333
|
// Use the DECRYPTED config so `admin.token` (if stored as `enc:`) is the
|
|
4548
7334
|
// plaintext bearer the AdminServer's constant-time compare expects (D4).
|
|
4549
|
-
getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin)
|
|
7335
|
+
getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin),
|
|
7336
|
+
// Unauthenticated `/health` probe (daemon-health-endpoint) — the SAME shared
|
|
7337
|
+
// builder the outbound server uses, served before the admin auth gate.
|
|
7338
|
+
getHealthReport,
|
|
7339
|
+
// configurable-logging: the admin listener's lifecycle lines route through
|
|
7340
|
+
// the injected logger.
|
|
7341
|
+
logger,
|
|
7342
|
+
// subscription-account-probe #8: the AUTHED `GET /admin/api/account-probes`
|
|
7343
|
+
// reads per-account probe history from the scheduler (secret-free — ids +
|
|
7344
|
+
// status labels only). Routed in `AdminServer` (not `adminApi.ts`).
|
|
7345
|
+
probeHistoryReader: accountHealthProbeScheduler,
|
|
7346
|
+
// request-audit-log: the AUTHED `GET /admin/api/audit` reads + filters the
|
|
7347
|
+
// date-rotated audit store. Bound to the store dir here so the AdminServer
|
|
7348
|
+
// carries no path/store coupling. Records hold IP/UA/bodies → admin-only,
|
|
7349
|
+
// NEVER unauth, NEVER on `/health`. Routed in `AdminServer` (not `adminApi.ts`).
|
|
7350
|
+
auditReader: (query) => readAuditRecords(auditDir, query),
|
|
7351
|
+
// billing-event-stream: the AUTHED `GET /admin/api/billing-status` returns the
|
|
7352
|
+
// secret-free total/delivered/pending counts of the durable ledger.
|
|
7353
|
+
billingStatusReader: () => readBillingStatus(billingDir)
|
|
4550
7354
|
});
|
|
7355
|
+
const webhookDispatcher = new WebhookDispatcher({
|
|
7356
|
+
logger,
|
|
7357
|
+
fetchImpl: (url, init) => fetchUpstream6(url, init)
|
|
7358
|
+
});
|
|
7359
|
+
setWebhookRuntime(webhookDispatcher, getSharedAccountHealth2());
|
|
7360
|
+
const auditWriter = new AuditWriter(auditDir, logger);
|
|
7361
|
+
const auditPruneSweeper = new AuditPruneSweeper(auditDir, logger, DEFAULT_AUDIT_CONFIG);
|
|
7362
|
+
setAuditRuntime(auditWriter, auditPruneSweeper);
|
|
7363
|
+
const billingPublisher = new BillingPublisher(billingDir, logger);
|
|
7364
|
+
const billingRetrySweeper = new BillingRetrySweeper(
|
|
7365
|
+
billingDir,
|
|
7366
|
+
billingPublisher,
|
|
7367
|
+
logger,
|
|
7368
|
+
DEFAULT_BILLING_CONFIG
|
|
7369
|
+
);
|
|
7370
|
+
setBillingRuntime(billingPublisher, billingRetrySweeper);
|
|
4551
7371
|
const tokenRefreshScheduler = new TokenRefreshScheduler(credentialStore, logger);
|
|
7372
|
+
const accountHealthSweeper = new AccountHealthSweeper(
|
|
7373
|
+
credentialStore,
|
|
7374
|
+
getSharedAccountHealth2(),
|
|
7375
|
+
logger
|
|
7376
|
+
);
|
|
4552
7377
|
return {
|
|
4553
7378
|
logger,
|
|
4554
7379
|
llmConfig,
|
|
@@ -4565,7 +7390,14 @@ function buildDaemon(config, paths) {
|
|
|
4565
7390
|
pricingEngine,
|
|
4566
7391
|
usageRecorder,
|
|
4567
7392
|
adminServer,
|
|
4568
|
-
tokenRefreshScheduler
|
|
7393
|
+
tokenRefreshScheduler,
|
|
7394
|
+
accountHealthSweeper,
|
|
7395
|
+
accountHealthProbeScheduler,
|
|
7396
|
+
webhookDispatcher,
|
|
7397
|
+
auditWriter,
|
|
7398
|
+
auditPruneSweeper,
|
|
7399
|
+
billingPublisher,
|
|
7400
|
+
billingRetrySweeper
|
|
4569
7401
|
};
|
|
4570
7402
|
}
|
|
4571
7403
|
function resetDaemonSingletonsForTests() {
|
|
@@ -4574,10 +7406,46 @@ function resetDaemonSingletonsForTests() {
|
|
|
4574
7406
|
setSubscriptionRegistryForOutbound(null);
|
|
4575
7407
|
setSubscriptionProviderRegistry(null);
|
|
4576
7408
|
setSubscriptionAccountService(null);
|
|
7409
|
+
setUpstreamProxyResolver(null);
|
|
7410
|
+
setServerProxyConfig(void 0);
|
|
4577
7411
|
setGeminiCodeAssistResolver(null);
|
|
4578
7412
|
setSecretBox(null);
|
|
4579
7413
|
setSecretBox2(null);
|
|
7414
|
+
resetWebhookRuntimeForTests();
|
|
7415
|
+
resetAuditRuntimeForTests();
|
|
7416
|
+
resetBillingRuntimeForTests();
|
|
7417
|
+
__resetSharedIdentityStoreForTests();
|
|
4580
7418
|
}
|
|
7419
|
+
function isTokensStoreReadable(tokensPath) {
|
|
7420
|
+
try {
|
|
7421
|
+
if (!existsSync14(tokensPath)) return true;
|
|
7422
|
+
accessSync(tokensPath, fsConstants.R_OK);
|
|
7423
|
+
return true;
|
|
7424
|
+
} catch {
|
|
7425
|
+
return false;
|
|
7426
|
+
}
|
|
7427
|
+
}
|
|
7428
|
+
|
|
7429
|
+
// src/ports/ConsoleLogger.ts
|
|
7430
|
+
var ConsoleLogger = class {
|
|
7431
|
+
info(message, meta) {
|
|
7432
|
+
if (meta === void 0) console.info(message);
|
|
7433
|
+
else console.info(message, meta);
|
|
7434
|
+
}
|
|
7435
|
+
warn(message, meta) {
|
|
7436
|
+
if (meta === void 0) console.warn(message);
|
|
7437
|
+
else console.warn(message, meta);
|
|
7438
|
+
}
|
|
7439
|
+
error(message, error, meta) {
|
|
7440
|
+
if (error === void 0 && meta === void 0) console.error(message);
|
|
7441
|
+
else if (meta === void 0) console.error(message, error);
|
|
7442
|
+
else console.error(message, error, meta);
|
|
7443
|
+
}
|
|
7444
|
+
debug(message, meta) {
|
|
7445
|
+
if (meta === void 0) console.debug(message);
|
|
7446
|
+
else console.debug(message, meta);
|
|
7447
|
+
}
|
|
7448
|
+
};
|
|
4581
7449
|
|
|
4582
7450
|
// src/ccr-import.ts
|
|
4583
7451
|
function parseCcrConfig(raw) {
|
|
@@ -4653,12 +7521,14 @@ function mapCcrToOmnicross(ccr) {
|
|
|
4653
7521
|
export {
|
|
4654
7522
|
AdminServer,
|
|
4655
7523
|
ConfigFileProviderConfigSource,
|
|
7524
|
+
ConfigurableLogger,
|
|
4656
7525
|
ConsoleLogger,
|
|
4657
7526
|
DEFAULT_ADMIN_PORT,
|
|
4658
7527
|
JsonApiServerSettingsStore,
|
|
4659
7528
|
JsonOutboundKeyDb,
|
|
4660
7529
|
JsonSubscriptionCredentialStore,
|
|
4661
7530
|
buildDaemon,
|
|
7531
|
+
buildHealthReport,
|
|
4662
7532
|
handleAdminApi,
|
|
4663
7533
|
inferApiFormat,
|
|
4664
7534
|
loadConfig,
|