@omnicross/daemon 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +3234 -332
- package/dist/cli.js +3238 -318
- package/dist/index.cjs +3144 -266
- package/dist/index.d.cts +960 -33
- package/dist/index.d.ts +960 -33
- package/dist/index.js +3150 -256
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -32,12 +32,14 @@ var src_exports = {};
|
|
|
32
32
|
__export(src_exports, {
|
|
33
33
|
AdminServer: () => AdminServer,
|
|
34
34
|
ConfigFileProviderConfigSource: () => ConfigFileProviderConfigSource,
|
|
35
|
+
ConfigurableLogger: () => ConfigurableLogger,
|
|
35
36
|
ConsoleLogger: () => ConsoleLogger,
|
|
36
37
|
DEFAULT_ADMIN_PORT: () => DEFAULT_ADMIN_PORT,
|
|
37
38
|
JsonApiServerSettingsStore: () => JsonApiServerSettingsStore,
|
|
38
39
|
JsonOutboundKeyDb: () => JsonOutboundKeyDb,
|
|
39
40
|
JsonSubscriptionCredentialStore: () => JsonSubscriptionCredentialStore,
|
|
40
41
|
buildDaemon: () => buildDaemon,
|
|
42
|
+
buildHealthReport: () => buildHealthReport,
|
|
41
43
|
handleAdminApi: () => handleAdminApi,
|
|
42
44
|
inferApiFormat: () => inferApiFormat,
|
|
43
45
|
loadConfig: () => loadConfig,
|
|
@@ -51,12 +53,19 @@ __export(src_exports, {
|
|
|
51
53
|
module.exports = __toCommonJS(src_exports);
|
|
52
54
|
|
|
53
55
|
// src/bootstrap.ts
|
|
56
|
+
var import_node_fs19 = require("fs");
|
|
57
|
+
var import_audit_types = require("@omnicross/contracts/audit-types");
|
|
58
|
+
var import_billing_types = require("@omnicross/contracts/billing-types");
|
|
54
59
|
var import_GeminiCodeAssistProjectResolver = require("@omnicross/core/auth/GeminiCodeAssistProjectResolver");
|
|
55
60
|
var import_ApiKeyPoolService = require("@omnicross/core/completion/ApiKeyPoolService");
|
|
56
|
-
var
|
|
61
|
+
var import_outbound_api4 = require("@omnicross/core/outbound-api");
|
|
57
62
|
var import_subscriptionRegistryPort = require("@omnicross/core/outbound-api/subscriptionRegistryPort");
|
|
63
|
+
var import_SubscriptionAccountHealth2 = require("@omnicross/core/pipeline/SubscriptionAccountHealth");
|
|
64
|
+
var import_upstreamFetch7 = require("@omnicross/core/pipeline/upstreamFetch");
|
|
65
|
+
var import_SubscriptionIdentityStore2 = require("@omnicross/core/provider-proxy/identity/SubscriptionIdentityStore");
|
|
58
66
|
var import_gemini_code_assist_resolver = require("@omnicross/core/ports/gemini-code-assist-resolver");
|
|
59
67
|
var import_provider_proxy = require("@omnicross/core/provider-proxy");
|
|
68
|
+
var import_outbound_api5 = require("@omnicross/core/outbound-api");
|
|
60
69
|
var import_usage = require("@omnicross/core/usage");
|
|
61
70
|
var import_subscriptions4 = require("@omnicross/subscriptions");
|
|
62
71
|
|
|
@@ -71,6 +80,7 @@ var CodexOAuthSessionStore = class {
|
|
|
71
80
|
ttlMs;
|
|
72
81
|
sessions = /* @__PURE__ */ new Map();
|
|
73
82
|
activeSessionId = null;
|
|
83
|
+
aborters = /* @__PURE__ */ new Map();
|
|
74
84
|
/** Whether a codex sign-in is currently in flight (port 1455 held). */
|
|
75
85
|
isBusy() {
|
|
76
86
|
this.sweep();
|
|
@@ -82,13 +92,22 @@ var CodexOAuthSessionStore = class {
|
|
|
82
92
|
const sessionId = import_node_crypto.default.randomBytes(24).toString("base64url");
|
|
83
93
|
this.sessions.set(sessionId, { status: "pending", createdAt: Date.now() });
|
|
84
94
|
this.activeSessionId = sessionId;
|
|
85
|
-
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
this.aborters.set(sessionId, controller);
|
|
97
|
+
return { sessionId, signal: controller.signal };
|
|
86
98
|
}
|
|
87
99
|
/** Settle a flow (done/error) + free the active slot. */
|
|
88
100
|
settle(sessionId, status, error) {
|
|
89
101
|
const prior = this.sessions.get(sessionId);
|
|
90
102
|
this.sessions.set(sessionId, { status, error, createdAt: prior?.createdAt ?? Date.now() });
|
|
91
103
|
if (this.activeSessionId === sessionId) this.activeSessionId = null;
|
|
104
|
+
this.aborters.delete(sessionId);
|
|
105
|
+
}
|
|
106
|
+
cancel(sessionId) {
|
|
107
|
+
if (!this.sessions.has(sessionId)) return false;
|
|
108
|
+
this.aborters.get(sessionId)?.abort();
|
|
109
|
+
this.settle(sessionId, "error", "login: cancelled");
|
|
110
|
+
return true;
|
|
92
111
|
}
|
|
93
112
|
/** Read a flow's status (token-free), or null when unknown/expired. */
|
|
94
113
|
get(sessionId) {
|
|
@@ -117,13 +136,13 @@ function handleCodexOAuthStart(deps) {
|
|
|
117
136
|
);
|
|
118
137
|
}
|
|
119
138
|
const { authUrl, codeVerifier, state } = import_subscriptions.codexOAuth.generateAuthParams();
|
|
120
|
-
const sessionId = deps.codexSessions.begin();
|
|
121
|
-
void runCodexLoopback(sessionId, codeVerifier, state, deps);
|
|
139
|
+
const { sessionId, signal } = deps.codexSessions.begin();
|
|
140
|
+
void runCodexLoopback(sessionId, codeVerifier, state, signal, deps);
|
|
122
141
|
return { status: 200, body: { authUrl, sessionId } };
|
|
123
142
|
}
|
|
124
|
-
async function runCodexLoopback(sessionId, codeVerifier, state, deps) {
|
|
143
|
+
async function runCodexLoopback(sessionId, codeVerifier, state, signal, deps) {
|
|
125
144
|
try {
|
|
126
|
-
const code = await deps.codexAwaitLoopback(state);
|
|
145
|
+
const code = await deps.codexAwaitLoopback(state, void 0, signal);
|
|
127
146
|
const result = await import_subscriptions.codexOAuth.exchangeCodeForTokens(
|
|
128
147
|
{ authorizationCode: code, codeVerifier, state },
|
|
129
148
|
deps.oauthExchangeFetch
|
|
@@ -145,6 +164,10 @@ async function runCodexLoopback(sessionId, codeVerifier, state, deps) {
|
|
|
145
164
|
deps.codexSessions.settle(sessionId, "error", reason);
|
|
146
165
|
}
|
|
147
166
|
}
|
|
167
|
+
function handleCodexOAuthCancel(sessionId, deps) {
|
|
168
|
+
if (!deps.codexSessions.cancel(sessionId)) return err(404, "unknown or expired codex sign-in session");
|
|
169
|
+
return { status: 200, body: { ok: true } };
|
|
170
|
+
}
|
|
148
171
|
function handleCodexOAuthStatus(sessionId, deps) {
|
|
149
172
|
const s = deps.codexSessions.get(sessionId);
|
|
150
173
|
if (!s) return err(404, "unknown or expired codex sign-in session");
|
|
@@ -154,10 +177,136 @@ function handleCodexOAuthStatus(sessionId, deps) {
|
|
|
154
177
|
// src/admin/AdminServer.ts
|
|
155
178
|
var import_node_crypto7 = require("crypto");
|
|
156
179
|
var import_node_http2 = __toESM(require("http"), 1);
|
|
180
|
+
var import_health_logging_types = require("@omnicross/contracts/health-logging-types");
|
|
181
|
+
|
|
182
|
+
// src/admin/accountProbesApi.ts
|
|
183
|
+
function handleAccountProbes(res, reader) {
|
|
184
|
+
const accounts = reader ? reader.getAllHistory() : [];
|
|
185
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
186
|
+
res.end(JSON.stringify({ accounts }));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/admin/auditQueryApi.ts
|
|
190
|
+
function intParam(value) {
|
|
191
|
+
if (value === null || value.trim() === "") return void 0;
|
|
192
|
+
const n = Number(value);
|
|
193
|
+
return Number.isFinite(n) ? Math.trunc(n) : void 0;
|
|
194
|
+
}
|
|
195
|
+
function handleAuditQuery(req, res, reader) {
|
|
196
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
197
|
+
const query = {};
|
|
198
|
+
const keyId = url.searchParams.get("keyId");
|
|
199
|
+
if (keyId && keyId.trim()) query.keyId = keyId.trim();
|
|
200
|
+
const from = intParam(url.searchParams.get("from"));
|
|
201
|
+
if (from !== void 0) query.from = from;
|
|
202
|
+
const to = intParam(url.searchParams.get("to"));
|
|
203
|
+
if (to !== void 0) query.to = to;
|
|
204
|
+
const limit = intParam(url.searchParams.get("limit"));
|
|
205
|
+
if (limit !== void 0) query.limit = limit;
|
|
206
|
+
const records = reader ? reader(query) : [];
|
|
207
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
208
|
+
res.end(JSON.stringify({ records }));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/admin/billingStatusApi.ts
|
|
212
|
+
function handleBillingStatus(res, reader) {
|
|
213
|
+
const status = reader ? reader() : { total: 0, delivered: 0, pending: 0 };
|
|
214
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
215
|
+
res.end(JSON.stringify({ status }));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// src/webhook/webhookRuntime.ts
|
|
219
|
+
var import_webhookEmit = require("@omnicross/core/pipeline/webhookEmit");
|
|
220
|
+
var dispatcher = null;
|
|
221
|
+
var health = null;
|
|
222
|
+
var unsubscribers = [];
|
|
223
|
+
var wired = false;
|
|
224
|
+
function setWebhookRuntime(d, h) {
|
|
225
|
+
dispatcher = d;
|
|
226
|
+
health = h;
|
|
227
|
+
}
|
|
228
|
+
function applyWebhookConfig(config) {
|
|
229
|
+
if (!dispatcher) return;
|
|
230
|
+
dispatcher.setConfig(config);
|
|
231
|
+
const shouldWire = config?.enabled === true && (config?.destinations.length ?? 0) > 0;
|
|
232
|
+
if (shouldWire && !wired) {
|
|
233
|
+
const active = dispatcher;
|
|
234
|
+
(0, import_webhookEmit.setWebhookSink)((event) => active.emit(event));
|
|
235
|
+
if (health) {
|
|
236
|
+
unsubscribers.push(
|
|
237
|
+
health.onRecovered(
|
|
238
|
+
(e) => active.emit({ kind: "account.recovery", at: e.at, providerId: e.providerId, accountId: e.accountId })
|
|
239
|
+
)
|
|
240
|
+
);
|
|
241
|
+
unsubscribers.push(
|
|
242
|
+
health.onAnomaly(
|
|
243
|
+
(e) => active.emit({
|
|
244
|
+
kind: "account.anomaly",
|
|
245
|
+
at: e.at,
|
|
246
|
+
providerId: e.providerId,
|
|
247
|
+
accountId: e.accountId,
|
|
248
|
+
state: e.state
|
|
249
|
+
})
|
|
250
|
+
)
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
wired = true;
|
|
254
|
+
} else if (!shouldWire && wired) {
|
|
255
|
+
teardown();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async function deliverWebhookTest(destinationId) {
|
|
259
|
+
if (!dispatcher) return { ok: false, error: "webhook dispatcher not wired" };
|
|
260
|
+
return dispatcher.deliverTest(destinationId);
|
|
261
|
+
}
|
|
262
|
+
function teardown() {
|
|
263
|
+
(0, import_webhookEmit.setWebhookSink)(null);
|
|
264
|
+
for (const unsub of unsubscribers) unsub();
|
|
265
|
+
unsubscribers = [];
|
|
266
|
+
wired = false;
|
|
267
|
+
}
|
|
268
|
+
function resetWebhookRuntimeForTests() {
|
|
269
|
+
if (wired) teardown();
|
|
270
|
+
dispatcher = null;
|
|
271
|
+
health = null;
|
|
272
|
+
unsubscribers = [];
|
|
273
|
+
wired = false;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/admin/webhookTestApi.ts
|
|
277
|
+
function readJsonBody(req) {
|
|
278
|
+
return new Promise((resolve) => {
|
|
279
|
+
const chunks = [];
|
|
280
|
+
req.on("data", (c) => chunks.push(c));
|
|
281
|
+
req.on("end", () => {
|
|
282
|
+
try {
|
|
283
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
284
|
+
const parsed = raw ? JSON.parse(raw) : {};
|
|
285
|
+
resolve(parsed && typeof parsed === "object" ? parsed : {});
|
|
286
|
+
} catch {
|
|
287
|
+
resolve({});
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
req.on("error", () => resolve({}));
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
async function handleWebhookTest(req, res) {
|
|
294
|
+
const body = await readJsonBody(req);
|
|
295
|
+
const destinationId = body["destinationId"];
|
|
296
|
+
if (typeof destinationId !== "string" || !destinationId.trim()) {
|
|
297
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
298
|
+
res.end(JSON.stringify({ error: { type: "bad_request", message: "destinationId is required" } }));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const result = await deliverWebhookTest(destinationId.trim());
|
|
302
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
303
|
+
res.end(JSON.stringify({ result }));
|
|
304
|
+
}
|
|
157
305
|
|
|
158
306
|
// src/admin/adminApi.ts
|
|
159
307
|
var import_node_http = __toESM(require("http"), 1);
|
|
160
|
-
var
|
|
308
|
+
var import_outbound_api2 = require("@omnicross/core/outbound-api");
|
|
309
|
+
var import_upstreamFetch2 = require("@omnicross/core/pipeline/upstreamFetch");
|
|
161
310
|
|
|
162
311
|
// src/config.ts
|
|
163
312
|
var import_node_fs2 = require("fs");
|
|
@@ -353,6 +502,70 @@ var SecretBox = class {
|
|
|
353
502
|
};
|
|
354
503
|
|
|
355
504
|
// src/secrets/secretFields.ts
|
|
505
|
+
function urlHasInlineCredential(url) {
|
|
506
|
+
try {
|
|
507
|
+
const u = new URL(url);
|
|
508
|
+
return u.username.length > 0 || u.password.length > 0;
|
|
509
|
+
} catch {
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
function transformProxyConfig(cfg, fn) {
|
|
514
|
+
if ("url" in cfg) {
|
|
515
|
+
if (isEnvelope(cfg.url) || urlHasInlineCredential(cfg.url)) {
|
|
516
|
+
return { url: fn(cfg.url) };
|
|
517
|
+
}
|
|
518
|
+
return cfg;
|
|
519
|
+
}
|
|
520
|
+
if (typeof cfg.password === "string" && cfg.password.length > 0) {
|
|
521
|
+
return { ...cfg, password: fn(cfg.password) };
|
|
522
|
+
}
|
|
523
|
+
return cfg;
|
|
524
|
+
}
|
|
525
|
+
function transformOutboundProxy(proxy, fn) {
|
|
526
|
+
const next = {};
|
|
527
|
+
if (proxy.global) next.global = transformProxyConfig(proxy.global, fn);
|
|
528
|
+
if (proxy.byProvider) {
|
|
529
|
+
const byProvider = {};
|
|
530
|
+
for (const [key, value] of Object.entries(proxy.byProvider)) {
|
|
531
|
+
byProvider[key] = transformProxyConfig(value, fn);
|
|
532
|
+
}
|
|
533
|
+
next.byProvider = byProvider;
|
|
534
|
+
}
|
|
535
|
+
return next;
|
|
536
|
+
}
|
|
537
|
+
function encryptProxySegment(proxy, box) {
|
|
538
|
+
return transformOutboundProxy(proxy, (v) => box.encryptMaybe(v));
|
|
539
|
+
}
|
|
540
|
+
function decryptProxySegment(proxy, box) {
|
|
541
|
+
return transformOutboundProxy(proxy, (v) => box.decryptMaybe(v));
|
|
542
|
+
}
|
|
543
|
+
function transformWebhookSegment(webhook, fn) {
|
|
544
|
+
return {
|
|
545
|
+
...webhook,
|
|
546
|
+
destinations: webhook.destinations.map(
|
|
547
|
+
(d) => typeof d.secret === "string" && d.secret.length > 0 ? { ...d, secret: fn(d.secret) } : d
|
|
548
|
+
)
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
function encryptWebhookSegment(webhook, box) {
|
|
552
|
+
return transformWebhookSegment(webhook, (v) => box.encryptMaybe(v));
|
|
553
|
+
}
|
|
554
|
+
function decryptWebhookSegment(webhook, box) {
|
|
555
|
+
return transformWebhookSegment(webhook, (v) => box.decryptMaybe(v));
|
|
556
|
+
}
|
|
557
|
+
function transformBillingSegment(billing, fn) {
|
|
558
|
+
if (typeof billing.secret === "string" && billing.secret.length > 0) {
|
|
559
|
+
return { ...billing, secret: fn(billing.secret) };
|
|
560
|
+
}
|
|
561
|
+
return billing;
|
|
562
|
+
}
|
|
563
|
+
function encryptBillingSegment(billing, box) {
|
|
564
|
+
return transformBillingSegment(billing, (v) => box.encryptMaybe(v));
|
|
565
|
+
}
|
|
566
|
+
function decryptBillingSegment(billing, box) {
|
|
567
|
+
return transformBillingSegment(billing, (v) => box.decryptMaybe(v));
|
|
568
|
+
}
|
|
356
569
|
function transformProvider(provider, fn) {
|
|
357
570
|
const next = { ...provider, apiKey: fn(provider.apiKey) };
|
|
358
571
|
if (provider.apiKeys) {
|
|
@@ -376,6 +589,17 @@ function transformConfigSecrets(cfg, fn) {
|
|
|
376
589
|
if (cfg.admin && typeof cfg.admin.token === "string" && cfg.admin.token.length > 0) {
|
|
377
590
|
next.admin = { ...cfg.admin, token: fn(cfg.admin.token) };
|
|
378
591
|
}
|
|
592
|
+
const proxy = cfg.server?.proxy;
|
|
593
|
+
const webhook = cfg.server?.webhook;
|
|
594
|
+
const billing = cfg.server?.billing;
|
|
595
|
+
if (cfg.server && (proxy?.global || proxy?.byProvider || webhook || billing?.secret)) {
|
|
596
|
+
next.server = { ...cfg.server };
|
|
597
|
+
if (proxy && (proxy.global || proxy.byProvider)) {
|
|
598
|
+
next.server.proxy = transformOutboundProxy(proxy, fn);
|
|
599
|
+
}
|
|
600
|
+
if (webhook) next.server.webhook = transformWebhookSegment(webhook, fn);
|
|
601
|
+
if (billing?.secret) next.server.billing = transformBillingSegment(billing, fn);
|
|
602
|
+
}
|
|
379
603
|
return next;
|
|
380
604
|
}
|
|
381
605
|
function encryptConfigSecrets(cfg, box) {
|
|
@@ -413,7 +637,7 @@ function transformTokens(tokens, fn) {
|
|
|
413
637
|
if (Array.isArray(accounts)) {
|
|
414
638
|
bag[accountsKey] = accounts.map((entry) => {
|
|
415
639
|
if (entry && typeof entry === "object" && "tokens" in entry && entry.tokens && typeof entry.tokens === "object") {
|
|
416
|
-
|
|
640
|
+
const nextEntry = {
|
|
417
641
|
...entry,
|
|
418
642
|
tokens: transformTokenBlock(
|
|
419
643
|
entry.tokens,
|
|
@@ -421,6 +645,11 @@ function transformTokens(tokens, fn) {
|
|
|
421
645
|
fn
|
|
422
646
|
)
|
|
423
647
|
};
|
|
648
|
+
const proxy = entry.proxy;
|
|
649
|
+
if (proxy && typeof proxy === "object") {
|
|
650
|
+
nextEntry.proxy = transformProxyConfig(proxy, fn);
|
|
651
|
+
}
|
|
652
|
+
return nextEntry;
|
|
424
653
|
}
|
|
425
654
|
return entry;
|
|
426
655
|
});
|
|
@@ -455,6 +684,17 @@ function resolveAdminConfig(admin) {
|
|
|
455
684
|
token: typeof admin?.token === "string" && admin.token.length > 0 ? admin.token : void 0
|
|
456
685
|
};
|
|
457
686
|
}
|
|
687
|
+
function validateLogging(raw) {
|
|
688
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
689
|
+
const l = raw;
|
|
690
|
+
const out = {};
|
|
691
|
+
if (l["level"] === "error" || l["level"] === "warn" || l["level"] === "info" || l["level"] === "debug") {
|
|
692
|
+
out.level = l["level"];
|
|
693
|
+
}
|
|
694
|
+
if (l["format"] === "text" || l["format"] === "json") out.format = l["format"];
|
|
695
|
+
if (typeof l["file"] === "string" && l["file"].length > 0) out.file = l["file"];
|
|
696
|
+
return out.level !== void 0 || out.format !== void 0 || out.file !== void 0 ? out : void 0;
|
|
697
|
+
}
|
|
458
698
|
var VALID_FORMATS = ["openai", "anthropic", "gemini"];
|
|
459
699
|
function validateApiKeys(raw) {
|
|
460
700
|
if (!Array.isArray(raw)) return void 0;
|
|
@@ -629,7 +869,8 @@ function validateConfig(raw) {
|
|
|
629
869
|
const providers = providersRaw.map((p, i) => validateProvider(p, i));
|
|
630
870
|
const server = obj["server"];
|
|
631
871
|
const admin = validateAdmin(obj["admin"]);
|
|
632
|
-
|
|
872
|
+
const logging = validateLogging(obj["logging"]);
|
|
873
|
+
return { providers, server, admin, logging };
|
|
633
874
|
}
|
|
634
875
|
var secretBox = null;
|
|
635
876
|
function setSecretBox(box) {
|
|
@@ -729,6 +970,161 @@ function listMappablePresets() {
|
|
|
729
970
|
return { mappable, excluded };
|
|
730
971
|
}
|
|
731
972
|
|
|
973
|
+
// src/proxy/sanitizeProxy.ts
|
|
974
|
+
function sanitizeProxyConfig(cfg) {
|
|
975
|
+
if ("url" in cfg) {
|
|
976
|
+
let endpoint;
|
|
977
|
+
let username;
|
|
978
|
+
let hasPassword = false;
|
|
979
|
+
try {
|
|
980
|
+
const u = new URL(cfg.url);
|
|
981
|
+
endpoint = u.port ? `${u.hostname}:${u.port}` : u.hostname;
|
|
982
|
+
username = u.username ? decodeURIComponent(u.username) : void 0;
|
|
983
|
+
hasPassword = u.password.length > 0;
|
|
984
|
+
} catch {
|
|
985
|
+
}
|
|
986
|
+
return { kind: "url", endpoint, username, hasPassword };
|
|
987
|
+
}
|
|
988
|
+
return {
|
|
989
|
+
kind: cfg.type,
|
|
990
|
+
endpoint: `${cfg.host}:${cfg.port}`,
|
|
991
|
+
username: cfg.username,
|
|
992
|
+
hasPassword: typeof cfg.password === "string" && cfg.password.length > 0
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
function redactProxyConfig(cfg) {
|
|
996
|
+
if ("url" in cfg) {
|
|
997
|
+
try {
|
|
998
|
+
const u = new URL(cfg.url);
|
|
999
|
+
if (u.password) u.password = "";
|
|
1000
|
+
return { url: u.toString() };
|
|
1001
|
+
} catch {
|
|
1002
|
+
return cfg;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
const { password: _password, ...rest } = cfg;
|
|
1006
|
+
return rest;
|
|
1007
|
+
}
|
|
1008
|
+
function redactOutboundProxy(proxy) {
|
|
1009
|
+
const out = {};
|
|
1010
|
+
if (proxy.global) out.global = redactProxyConfig(proxy.global);
|
|
1011
|
+
if (proxy.byProvider) {
|
|
1012
|
+
const byProvider = {};
|
|
1013
|
+
for (const [key, value] of Object.entries(proxy.byProvider)) {
|
|
1014
|
+
byProvider[key] = redactProxyConfig(value);
|
|
1015
|
+
}
|
|
1016
|
+
out.byProvider = byProvider;
|
|
1017
|
+
}
|
|
1018
|
+
return out;
|
|
1019
|
+
}
|
|
1020
|
+
function preserveProxyConfigSecret(incoming, current) {
|
|
1021
|
+
if (!current) return incoming;
|
|
1022
|
+
if ("url" in incoming) {
|
|
1023
|
+
if ("url" in current) {
|
|
1024
|
+
try {
|
|
1025
|
+
const inU = new URL(incoming.url);
|
|
1026
|
+
const curU = new URL(current.url);
|
|
1027
|
+
if (!inU.password && curU.password) {
|
|
1028
|
+
inU.password = curU.password;
|
|
1029
|
+
return { url: inU.toString() };
|
|
1030
|
+
}
|
|
1031
|
+
} catch {
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return incoming;
|
|
1035
|
+
}
|
|
1036
|
+
if ("url" in current) return incoming;
|
|
1037
|
+
const blank = incoming.password === void 0 || incoming.password === "";
|
|
1038
|
+
if (blank && typeof current.password === "string" && current.password.length > 0) {
|
|
1039
|
+
return { ...incoming, password: current.password };
|
|
1040
|
+
}
|
|
1041
|
+
return incoming;
|
|
1042
|
+
}
|
|
1043
|
+
function preserveOutboundProxySecrets(incoming, current) {
|
|
1044
|
+
const out = {};
|
|
1045
|
+
if (incoming.global) out.global = preserveProxyConfigSecret(incoming.global, current?.global);
|
|
1046
|
+
if (incoming.byProvider) {
|
|
1047
|
+
const byProvider = {};
|
|
1048
|
+
for (const [key, value] of Object.entries(incoming.byProvider)) {
|
|
1049
|
+
byProvider[key] = preserveProxyConfigSecret(value, current?.byProvider?.[key]);
|
|
1050
|
+
}
|
|
1051
|
+
out.byProvider = byProvider;
|
|
1052
|
+
}
|
|
1053
|
+
return out;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// src/proxy/upstreamProxyResolver.ts
|
|
1057
|
+
var import_upstreamFetch = require("@omnicross/core/pipeline/upstreamFetch");
|
|
1058
|
+
var serverProxy;
|
|
1059
|
+
function setServerProxyConfig(proxy) {
|
|
1060
|
+
serverProxy = proxy;
|
|
1061
|
+
(0, import_upstreamFetch.bumpUpstreamProxyGeneration)();
|
|
1062
|
+
}
|
|
1063
|
+
function getServerProxyConfig() {
|
|
1064
|
+
return serverProxy;
|
|
1065
|
+
}
|
|
1066
|
+
var envProxyLoggedFor;
|
|
1067
|
+
function maskProxyUrl(url) {
|
|
1068
|
+
return url.replace(/\/\/[^/@]*@/, "//***@");
|
|
1069
|
+
}
|
|
1070
|
+
function hostFromCtx(ctx) {
|
|
1071
|
+
if (!ctx.url) return void 0;
|
|
1072
|
+
try {
|
|
1073
|
+
return new URL(ctx.url).hostname.toLowerCase();
|
|
1074
|
+
} catch {
|
|
1075
|
+
return void 0;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
function isLoopbackHost(host) {
|
|
1079
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]" || host.startsWith("127.") || host.endsWith(".localhost");
|
|
1080
|
+
}
|
|
1081
|
+
function noProxyMatches(noProxy, host) {
|
|
1082
|
+
if (!noProxy) return false;
|
|
1083
|
+
for (const raw of noProxy.split(",")) {
|
|
1084
|
+
const entry = raw.trim().toLowerCase();
|
|
1085
|
+
if (!entry) continue;
|
|
1086
|
+
if (entry === "*") return true;
|
|
1087
|
+
const bare = entry.startsWith(".") ? entry.slice(1) : entry;
|
|
1088
|
+
if (host === bare || host.endsWith(`.${bare}`)) return true;
|
|
1089
|
+
}
|
|
1090
|
+
return false;
|
|
1091
|
+
}
|
|
1092
|
+
function resolveEnvProxy(ctx, env = process.env) {
|
|
1093
|
+
const raw = env.HTTPS_PROXY ?? env.https_proxy ?? env.ALL_PROXY ?? env.all_proxy ?? env.HTTP_PROXY ?? env.http_proxy;
|
|
1094
|
+
if (!raw || !raw.trim()) return void 0;
|
|
1095
|
+
const host = hostFromCtx(ctx);
|
|
1096
|
+
if (host && (isLoopbackHost(host) || noProxyMatches(env.NO_PROXY ?? env.no_proxy, host))) {
|
|
1097
|
+
return void 0;
|
|
1098
|
+
}
|
|
1099
|
+
const url = raw.trim();
|
|
1100
|
+
if (envProxyLoggedFor !== url) {
|
|
1101
|
+
envProxyLoggedFor = url;
|
|
1102
|
+
console.info(`[upstream-proxy] routing upstream egress through the environment proxy: ${maskProxyUrl(url)}`);
|
|
1103
|
+
}
|
|
1104
|
+
return { url };
|
|
1105
|
+
}
|
|
1106
|
+
function createUpstreamProxyResolver(src = {}) {
|
|
1107
|
+
const readServer = src.getServerProxy ?? getServerProxyConfig;
|
|
1108
|
+
return (ctx) => {
|
|
1109
|
+
const host = hostFromCtx(ctx);
|
|
1110
|
+
if (host) {
|
|
1111
|
+
if (isLoopbackHost(host)) return void 0;
|
|
1112
|
+
const env = src.env ?? process.env;
|
|
1113
|
+
if (noProxyMatches(env.NO_PROXY ?? env.no_proxy, host)) return void 0;
|
|
1114
|
+
}
|
|
1115
|
+
if (src.getAccountProxy && ctx.providerId && ctx.accountId) {
|
|
1116
|
+
const account = src.getAccountProxy(ctx.providerId, ctx.accountId);
|
|
1117
|
+
if (account) return account;
|
|
1118
|
+
}
|
|
1119
|
+
const server = readServer();
|
|
1120
|
+
if (ctx.providerId && server?.byProvider?.[ctx.providerId]) {
|
|
1121
|
+
return server.byProvider[ctx.providerId];
|
|
1122
|
+
}
|
|
1123
|
+
if (server?.global) return server.global;
|
|
1124
|
+
return resolveEnvProxy(ctx, src.env);
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
|
|
732
1128
|
// src/admin/accountsOAuth.ts
|
|
733
1129
|
var import_subscriptions2 = require("@omnicross/subscriptions");
|
|
734
1130
|
|
|
@@ -851,6 +1247,24 @@ function validateTokenBody(providerId, body) {
|
|
|
851
1247
|
return null;
|
|
852
1248
|
}
|
|
853
1249
|
}
|
|
1250
|
+
function validateSupportedModelsBody(raw) {
|
|
1251
|
+
if (raw === null || raw === void 0) return { ok: true, value: void 0 };
|
|
1252
|
+
if (Array.isArray(raw)) {
|
|
1253
|
+
if (raw.length === 0) return { ok: false };
|
|
1254
|
+
if (!raw.every((x) => typeof x === "string" && x.trim().length > 0)) return { ok: false };
|
|
1255
|
+
return { ok: true, value: raw };
|
|
1256
|
+
}
|
|
1257
|
+
if (typeof raw === "object") {
|
|
1258
|
+
const entries = Object.entries(raw);
|
|
1259
|
+
if (entries.length === 0) return { ok: false };
|
|
1260
|
+
const valid = entries.every(
|
|
1261
|
+
([k, v]) => k.trim().length > 0 && typeof v === "string" && v.trim().length > 0
|
|
1262
|
+
);
|
|
1263
|
+
if (!valid) return { ok: false };
|
|
1264
|
+
return { ok: true, value: Object.fromEntries(entries) };
|
|
1265
|
+
}
|
|
1266
|
+
return { ok: false };
|
|
1267
|
+
}
|
|
854
1268
|
async function statusEntryFor(reader, providerId) {
|
|
855
1269
|
const all = await reader.listAll();
|
|
856
1270
|
return all.find((a) => a.providerId === providerId) ?? null;
|
|
@@ -1127,6 +1541,438 @@ async function handleCliLaunch(cli, body, ctx) {
|
|
|
1127
1541
|
return { status: 200, body: { sessionId: id, providerId: target.providerId, model: target.model } };
|
|
1128
1542
|
}
|
|
1129
1543
|
|
|
1544
|
+
// src/admin/auditConfigBody.ts
|
|
1545
|
+
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1546
|
+
function validateAuditSegment(patch) {
|
|
1547
|
+
const errors = [];
|
|
1548
|
+
const audit = patch.audit;
|
|
1549
|
+
if (audit === void 0) return errors;
|
|
1550
|
+
if (!isPlainObject(audit)) {
|
|
1551
|
+
errors.push("audit must be an object");
|
|
1552
|
+
return errors;
|
|
1553
|
+
}
|
|
1554
|
+
for (const flag of ["enabled", "captureBodies", "trustForwardedFor"]) {
|
|
1555
|
+
if (audit[flag] !== void 0 && typeof audit[flag] !== "boolean") {
|
|
1556
|
+
errors.push(`audit.${flag} must be a boolean`);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
const maxBodyBytes = audit["maxBodyBytes"];
|
|
1560
|
+
if (maxBodyBytes !== void 0 && (typeof maxBodyBytes !== "number" || !Number.isFinite(maxBodyBytes) || maxBodyBytes < 0)) {
|
|
1561
|
+
errors.push("audit.maxBodyBytes must be a non-negative number");
|
|
1562
|
+
}
|
|
1563
|
+
const retentionDays = audit["retentionDays"];
|
|
1564
|
+
if (retentionDays !== void 0 && (typeof retentionDays !== "number" || !Number.isFinite(retentionDays) || retentionDays < 0)) {
|
|
1565
|
+
errors.push("audit.retentionDays must be a non-negative number");
|
|
1566
|
+
}
|
|
1567
|
+
return errors;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// src/admin/billingConfigBody.ts
|
|
1571
|
+
var BILLING_SECRET_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
1572
|
+
var isPlainObject2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1573
|
+
function validateBillingSegment(patch) {
|
|
1574
|
+
const errors = [];
|
|
1575
|
+
const billing = patch.billing;
|
|
1576
|
+
if (billing === void 0) return errors;
|
|
1577
|
+
if (!isPlainObject2(billing)) {
|
|
1578
|
+
errors.push("billing must be an object");
|
|
1579
|
+
return errors;
|
|
1580
|
+
}
|
|
1581
|
+
if (billing["enabled"] !== void 0 && typeof billing["enabled"] !== "boolean") {
|
|
1582
|
+
errors.push("billing.enabled must be a boolean");
|
|
1583
|
+
}
|
|
1584
|
+
if (billing["endpoint"] !== void 0 && typeof billing["endpoint"] !== "string") {
|
|
1585
|
+
errors.push("billing.endpoint must be a string");
|
|
1586
|
+
}
|
|
1587
|
+
if (billing["secret"] !== void 0 && typeof billing["secret"] !== "string") {
|
|
1588
|
+
errors.push("billing.secret must be a string");
|
|
1589
|
+
}
|
|
1590
|
+
const maxRetryAgeMs = billing["maxRetryAgeMs"];
|
|
1591
|
+
if (maxRetryAgeMs !== void 0 && (typeof maxRetryAgeMs !== "number" || !Number.isFinite(maxRetryAgeMs) || maxRetryAgeMs < 0)) {
|
|
1592
|
+
errors.push("billing.maxRetryAgeMs must be a non-negative number");
|
|
1593
|
+
}
|
|
1594
|
+
return errors;
|
|
1595
|
+
}
|
|
1596
|
+
function redactBillingConfig(billing) {
|
|
1597
|
+
if (typeof billing.secret === "string" && billing.secret.length > 0) {
|
|
1598
|
+
return { ...billing, secret: BILLING_SECRET_MASK };
|
|
1599
|
+
}
|
|
1600
|
+
return billing;
|
|
1601
|
+
}
|
|
1602
|
+
function preserveBillingSecret(incoming, current) {
|
|
1603
|
+
const isMaskedOrBlank = incoming.secret === void 0 || incoming.secret === "" || incoming.secret === BILLING_SECRET_MASK;
|
|
1604
|
+
if (isMaskedOrBlank) {
|
|
1605
|
+
if (current?.secret) return { ...incoming, secret: current.secret };
|
|
1606
|
+
const { secret: _secret, ...rest } = incoming;
|
|
1607
|
+
return rest;
|
|
1608
|
+
}
|
|
1609
|
+
return incoming;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// src/admin/dashboard.ts
|
|
1613
|
+
function startOfLocalDayMs(ts) {
|
|
1614
|
+
const d = new Date(ts);
|
|
1615
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
1616
|
+
}
|
|
1617
|
+
function accountProviderId(entry) {
|
|
1618
|
+
if (!entry || typeof entry !== "object") return null;
|
|
1619
|
+
const e = entry;
|
|
1620
|
+
if (typeof e["providerId"] === "string" && e["providerId"]) return e["providerId"];
|
|
1621
|
+
if (typeof e["provider"] === "string" && e["provider"]) return e["provider"];
|
|
1622
|
+
return null;
|
|
1623
|
+
}
|
|
1624
|
+
async function handleDashboard(deps) {
|
|
1625
|
+
const now = Date.now();
|
|
1626
|
+
const today = await deps.usageRecorder.getTotals({ startTs: startOfLocalDayMs(now), endTs: now });
|
|
1627
|
+
const total = await deps.usageRecorder.getTotals({ startTs: 0, endTs: now });
|
|
1628
|
+
const providerList = loadConfig(deps.configPath).providers;
|
|
1629
|
+
const providers = {
|
|
1630
|
+
total: providerList.length,
|
|
1631
|
+
enabled: providerList.filter((p) => p.enabled !== false).length
|
|
1632
|
+
};
|
|
1633
|
+
const keys = await deps.keyDb.outboundApiKeysList();
|
|
1634
|
+
const outboundKeys = {
|
|
1635
|
+
total: keys.length,
|
|
1636
|
+
active: keys.filter((k) => k.enabled && k.revokedAt === null).length
|
|
1637
|
+
};
|
|
1638
|
+
const accountsList = await deps.subscriptionAccounts.listAll();
|
|
1639
|
+
const byProvider = {};
|
|
1640
|
+
for (const entry of accountsList) {
|
|
1641
|
+
const providerId = accountProviderId(entry);
|
|
1642
|
+
if (providerId) byProvider[providerId] = (byProvider[providerId] ?? 0) + 1;
|
|
1643
|
+
}
|
|
1644
|
+
const accounts = { total: accountsList.length, byProvider };
|
|
1645
|
+
const status = deps.outboundApiServer.getStatus();
|
|
1646
|
+
const server = {
|
|
1647
|
+
running: status.running,
|
|
1648
|
+
port: status.port,
|
|
1649
|
+
uptimeMs: Math.round(process.uptime() * 1e3)
|
|
1650
|
+
};
|
|
1651
|
+
const summary = { today, total, providers, outboundKeys, accounts, server, generatedAt: now };
|
|
1652
|
+
return { status: 200, body: summary };
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// src/admin/keyPolicyBody.ts
|
|
1656
|
+
function parseKeyPolicyBody(body) {
|
|
1657
|
+
const policy = {};
|
|
1658
|
+
if ("activationMode" in body) {
|
|
1659
|
+
const m = body["activationMode"];
|
|
1660
|
+
if (m === null) policy.activationMode = null;
|
|
1661
|
+
else if (m === "fixed" || m === "activation") policy.activationMode = m;
|
|
1662
|
+
else return { ok: false, message: "activationMode must be 'fixed', 'activation', or null" };
|
|
1663
|
+
}
|
|
1664
|
+
const numericFields = [
|
|
1665
|
+
{ key: "expiresAt", min: 0 },
|
|
1666
|
+
{ key: "activationDays", min: 1, integer: true },
|
|
1667
|
+
{ key: "dailyCostLimitUsd", min: 0 },
|
|
1668
|
+
{ key: "totalCostLimitUsd", min: 0 },
|
|
1669
|
+
{ key: "weeklyCostLimitUsd", min: 0 },
|
|
1670
|
+
{ key: "rateLimitMaxRequests", min: 0, integer: true },
|
|
1671
|
+
{ key: "rateLimitWindowMs", min: 1 }
|
|
1672
|
+
];
|
|
1673
|
+
for (const { key, min, integer } of numericFields) {
|
|
1674
|
+
if (!(key in body)) continue;
|
|
1675
|
+
const v = body[key];
|
|
1676
|
+
if (v === null) {
|
|
1677
|
+
policy[key] = null;
|
|
1678
|
+
continue;
|
|
1679
|
+
}
|
|
1680
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < min || integer && !Number.isInteger(v)) {
|
|
1681
|
+
return {
|
|
1682
|
+
ok: false,
|
|
1683
|
+
message: `${key} must be ${integer ? "an integer" : "a number"} >= ${min} or null`
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
policy[key] = v;
|
|
1687
|
+
}
|
|
1688
|
+
if ("enableModelRestriction" in body) {
|
|
1689
|
+
const v = body["enableModelRestriction"];
|
|
1690
|
+
if (v === null) policy.enableModelRestriction = null;
|
|
1691
|
+
else if (typeof v === "boolean") policy.enableModelRestriction = v;
|
|
1692
|
+
else return { ok: false, message: "enableModelRestriction must be a boolean or null" };
|
|
1693
|
+
}
|
|
1694
|
+
if ("restrictionMode" in body) {
|
|
1695
|
+
const v = body["restrictionMode"];
|
|
1696
|
+
if (v === null) policy.restrictionMode = null;
|
|
1697
|
+
else if (v === "blacklist" || v === "allowlist") policy.restrictionMode = v;
|
|
1698
|
+
else return { ok: false, message: "restrictionMode must be 'blacklist', 'allowlist', or null" };
|
|
1699
|
+
}
|
|
1700
|
+
if ("restrictedModels" in body) {
|
|
1701
|
+
const v = body["restrictedModels"];
|
|
1702
|
+
if (v === null) {
|
|
1703
|
+
policy.restrictedModels = null;
|
|
1704
|
+
} else if (Array.isArray(v) && v.every((e) => typeof e === "string")) {
|
|
1705
|
+
policy.restrictedModels = v.map((e) => e.trim()).filter((e) => e !== "");
|
|
1706
|
+
} else {
|
|
1707
|
+
return { ok: false, message: "restrictedModels must be an array of strings or null" };
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
return { ok: true, policy };
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// src/admin/voucherAdmin.ts
|
|
1714
|
+
var import_outbound_api = require("@omnicross/core/outbound-api");
|
|
1715
|
+
function writeJson(res, status, body) {
|
|
1716
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1717
|
+
res.end(JSON.stringify(body));
|
|
1718
|
+
}
|
|
1719
|
+
function writeErr(res, status, message) {
|
|
1720
|
+
writeJson(res, status, { error: { type: "voucher_error", message } });
|
|
1721
|
+
}
|
|
1722
|
+
function readJsonBody2(req) {
|
|
1723
|
+
return new Promise((resolve, reject) => {
|
|
1724
|
+
const chunks = [];
|
|
1725
|
+
req.on("data", (c) => chunks.push(c));
|
|
1726
|
+
req.on("end", () => {
|
|
1727
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1728
|
+
if (!raw.trim()) return resolve({});
|
|
1729
|
+
try {
|
|
1730
|
+
const parsed = JSON.parse(raw);
|
|
1731
|
+
resolve(parsed && typeof parsed === "object" ? parsed : {});
|
|
1732
|
+
} catch {
|
|
1733
|
+
reject(new Error("invalid-json"));
|
|
1734
|
+
}
|
|
1735
|
+
});
|
|
1736
|
+
req.on("error", reject);
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
function optPositive(value, integer) {
|
|
1740
|
+
if (value === void 0 || value === null) return { ok: true, value: void 0 };
|
|
1741
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return { ok: false };
|
|
1742
|
+
if (integer && !Number.isInteger(value)) return { ok: false };
|
|
1743
|
+
return { ok: true, value };
|
|
1744
|
+
}
|
|
1745
|
+
function parseVoucherCreateBody(body) {
|
|
1746
|
+
const type = body["type"];
|
|
1747
|
+
if (type !== "credit" && type !== "renewal") {
|
|
1748
|
+
return { ok: false, message: "type must be 'credit' or 'renewal'" };
|
|
1749
|
+
}
|
|
1750
|
+
const maxTotal = optPositive(body["maxTotalCostLimitUsd"], false);
|
|
1751
|
+
if (!maxTotal.ok) return { ok: false, message: "maxTotalCostLimitUsd must be a positive number" };
|
|
1752
|
+
const maxDays = optPositive(body["maxExpiryDays"], true);
|
|
1753
|
+
if (!maxDays.ok) return { ok: false, message: "maxExpiryDays must be a positive integer" };
|
|
1754
|
+
const input = { type };
|
|
1755
|
+
if (maxTotal.value !== void 0) input.maxTotalCostLimitUsd = maxTotal.value;
|
|
1756
|
+
if (maxDays.value !== void 0) input.maxExpiryDays = maxDays.value;
|
|
1757
|
+
if (type === "credit") {
|
|
1758
|
+
const credit = optPositive(body["creditUsd"], false);
|
|
1759
|
+
if (!credit.ok || credit.value === void 0) {
|
|
1760
|
+
return { ok: false, message: "creditUsd must be a positive number for a credit card" };
|
|
1761
|
+
}
|
|
1762
|
+
input.creditUsd = credit.value;
|
|
1763
|
+
} else {
|
|
1764
|
+
const days = optPositive(body["renewalDays"], true);
|
|
1765
|
+
if (!days.ok || days.value === void 0) {
|
|
1766
|
+
return { ok: false, message: "renewalDays must be a positive integer for a renewal card" };
|
|
1767
|
+
}
|
|
1768
|
+
input.renewalDays = days.value;
|
|
1769
|
+
}
|
|
1770
|
+
return { ok: true, input };
|
|
1771
|
+
}
|
|
1772
|
+
async function voucherEnabled(deps) {
|
|
1773
|
+
const config = await (0, import_outbound_api.loadServerConfig)(deps.settingsStore);
|
|
1774
|
+
return config.voucher?.enabled === true;
|
|
1775
|
+
}
|
|
1776
|
+
async function handleVoucher(req, res, method, rest, deps) {
|
|
1777
|
+
const voucherDb = deps.voucherDb;
|
|
1778
|
+
if (!voucherDb) return writeErr(res, 501, "Voucher feature is not available");
|
|
1779
|
+
if (method === "GET" && rest.length === 0) {
|
|
1780
|
+
const rows = await voucherDb.voucherList();
|
|
1781
|
+
return writeJson(res, 200, { vouchers: rows.map(import_outbound_api.toVoucherInfo) });
|
|
1782
|
+
}
|
|
1783
|
+
if (method === "POST" && rest.length === 0) {
|
|
1784
|
+
if (!await voucherEnabled(deps)) return writeErr(res, 403, "Voucher feature is disabled");
|
|
1785
|
+
let body;
|
|
1786
|
+
try {
|
|
1787
|
+
body = await readJsonBody2(req);
|
|
1788
|
+
} catch {
|
|
1789
|
+
return writeErr(res, 400, "Invalid JSON in request body");
|
|
1790
|
+
}
|
|
1791
|
+
const parsed = parseVoucherCreateBody(body);
|
|
1792
|
+
if (!parsed.ok) return writeErr(res, 400, parsed.message);
|
|
1793
|
+
const code = (0, import_outbound_api.generateVoucherCode)();
|
|
1794
|
+
const created = await voucherDb.voucherCreate({
|
|
1795
|
+
id: (0, import_outbound_api.newVoucherId)(),
|
|
1796
|
+
codeHash: (0, import_outbound_api.hashVoucherCode)(code),
|
|
1797
|
+
codePrefix: (0, import_outbound_api.voucherCodePrefix)(code),
|
|
1798
|
+
...parsed.input
|
|
1799
|
+
});
|
|
1800
|
+
return writeJson(res, 201, {
|
|
1801
|
+
id: created.id,
|
|
1802
|
+
codePrefix: created.codePrefix,
|
|
1803
|
+
type: created.type,
|
|
1804
|
+
createdAt: created.createdAt,
|
|
1805
|
+
// `plaintextOnce` is the ONLY place the full code crosses the wire (D3).
|
|
1806
|
+
plaintextOnce: code
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
const id = rest[0];
|
|
1810
|
+
if (method === "POST" && id && rest[1] === "revoke") {
|
|
1811
|
+
if (!await voucherEnabled(deps)) return writeErr(res, 403, "Voucher feature is disabled");
|
|
1812
|
+
const ok = await voucherDb.voucherRevokeCas(id, Date.now());
|
|
1813
|
+
return writeJson(res, ok ? 200 : 409, { ok });
|
|
1814
|
+
}
|
|
1815
|
+
return writeErr(res, 405, `method ${method} not allowed on voucher`);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// src/admin/webhookConfigBody.ts
|
|
1819
|
+
var import_webhook_types = require("@omnicross/contracts/webhook-types");
|
|
1820
|
+
var WEBHOOK_SECRET_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
1821
|
+
var isPlainObject3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1822
|
+
function validateWebhookSegment(patch) {
|
|
1823
|
+
const errors = [];
|
|
1824
|
+
const webhook = patch.webhook;
|
|
1825
|
+
if (webhook === void 0) return errors;
|
|
1826
|
+
if (!isPlainObject3(webhook)) {
|
|
1827
|
+
errors.push("webhook must be an object");
|
|
1828
|
+
return errors;
|
|
1829
|
+
}
|
|
1830
|
+
if (typeof webhook["enabled"] !== "boolean") {
|
|
1831
|
+
errors.push("webhook.enabled must be a boolean");
|
|
1832
|
+
}
|
|
1833
|
+
const destinations = webhook["destinations"];
|
|
1834
|
+
if (destinations !== void 0 && !Array.isArray(destinations)) {
|
|
1835
|
+
errors.push("webhook.destinations must be an array");
|
|
1836
|
+
return errors;
|
|
1837
|
+
}
|
|
1838
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
1839
|
+
for (const [i, raw] of (Array.isArray(destinations) ? destinations : []).entries()) {
|
|
1840
|
+
if (!isPlainObject3(raw)) {
|
|
1841
|
+
errors.push(`webhook.destinations[${i}] must be an object`);
|
|
1842
|
+
continue;
|
|
1843
|
+
}
|
|
1844
|
+
const id = raw["id"];
|
|
1845
|
+
if (typeof id !== "string" || !id.trim()) {
|
|
1846
|
+
errors.push(`webhook.destinations[${i}].id must be a non-empty string`);
|
|
1847
|
+
} else if (seenIds.has(id.trim())) {
|
|
1848
|
+
errors.push(`webhook.destinations[${i}].id '${id.trim()}' is duplicated`);
|
|
1849
|
+
} else {
|
|
1850
|
+
seenIds.add(id.trim());
|
|
1851
|
+
}
|
|
1852
|
+
if (typeof raw["type"] !== "string" || !import_webhook_types.WEBHOOK_DESTINATION_TYPES.includes(raw["type"])) {
|
|
1853
|
+
errors.push(`webhook.destinations[${i}].type must be one of ${import_webhook_types.WEBHOOK_DESTINATION_TYPES.join(", ")}`);
|
|
1854
|
+
}
|
|
1855
|
+
if (typeof raw["url"] !== "string" || !raw["url"].trim()) {
|
|
1856
|
+
errors.push(`webhook.destinations[${i}].url must be a non-empty string`);
|
|
1857
|
+
}
|
|
1858
|
+
if (raw["secret"] !== void 0 && typeof raw["secret"] !== "string") {
|
|
1859
|
+
errors.push(`webhook.destinations[${i}].secret must be a string`);
|
|
1860
|
+
}
|
|
1861
|
+
if (raw["enabled"] !== void 0 && typeof raw["enabled"] !== "boolean") {
|
|
1862
|
+
errors.push(`webhook.destinations[${i}].enabled must be a boolean`);
|
|
1863
|
+
}
|
|
1864
|
+
const events = raw["events"];
|
|
1865
|
+
if (events !== void 0) {
|
|
1866
|
+
if (!Array.isArray(events)) {
|
|
1867
|
+
errors.push(`webhook.destinations[${i}].events must be an array`);
|
|
1868
|
+
} else {
|
|
1869
|
+
for (const e of events) {
|
|
1870
|
+
if (typeof e !== "string" || !import_webhook_types.WEBHOOK_EVENT_KINDS.includes(e)) {
|
|
1871
|
+
errors.push(`webhook.destinations[${i}].events contains an unknown kind '${String(e)}'`);
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
return errors;
|
|
1878
|
+
}
|
|
1879
|
+
function redactWebhookConfig(webhook) {
|
|
1880
|
+
return {
|
|
1881
|
+
...webhook,
|
|
1882
|
+
destinations: webhook.destinations.map(
|
|
1883
|
+
(d) => typeof d.secret === "string" && d.secret.length > 0 ? { ...d, secret: WEBHOOK_SECRET_MASK } : d
|
|
1884
|
+
)
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
function preserveWebhookSecrets(incoming, current) {
|
|
1888
|
+
const currentById = /* @__PURE__ */ new Map();
|
|
1889
|
+
for (const d of current?.destinations ?? []) currentById.set(d.id, d);
|
|
1890
|
+
return {
|
|
1891
|
+
...incoming,
|
|
1892
|
+
destinations: incoming.destinations.map((d) => {
|
|
1893
|
+
const isMaskedOrBlank = d.secret === void 0 || d.secret === "" || d.secret === WEBHOOK_SECRET_MASK;
|
|
1894
|
+
if (isMaskedOrBlank) {
|
|
1895
|
+
const prev = currentById.get(d.id);
|
|
1896
|
+
if (prev?.secret) return { ...d, secret: prev.secret };
|
|
1897
|
+
const { secret: _secret, ...rest } = d;
|
|
1898
|
+
return rest;
|
|
1899
|
+
}
|
|
1900
|
+
return d;
|
|
1901
|
+
})
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// src/audit/auditRuntime.ts
|
|
1906
|
+
var import_auditSink = require("@omnicross/core/pipeline/auditSink");
|
|
1907
|
+
var writer = null;
|
|
1908
|
+
var sweeper = null;
|
|
1909
|
+
function setAuditRuntime(w, s) {
|
|
1910
|
+
writer = w;
|
|
1911
|
+
sweeper = s;
|
|
1912
|
+
}
|
|
1913
|
+
function applyAuditConfig(config) {
|
|
1914
|
+
const enabled = config?.enabled === true && writer !== null;
|
|
1915
|
+
if (enabled && config) {
|
|
1916
|
+
(0, import_auditSink.setAuditCaptureConfig)(config);
|
|
1917
|
+
const activeWriter = writer;
|
|
1918
|
+
(0, import_auditSink.setAuditSink)((record) => activeWriter.record(record));
|
|
1919
|
+
if (sweeper) {
|
|
1920
|
+
sweeper.configure(config);
|
|
1921
|
+
sweeper.start();
|
|
1922
|
+
}
|
|
1923
|
+
} else {
|
|
1924
|
+
(0, import_auditSink.setAuditCaptureConfig)(null);
|
|
1925
|
+
(0, import_auditSink.setAuditSink)(null);
|
|
1926
|
+
if (sweeper) {
|
|
1927
|
+
if (config) sweeper.configure(config);
|
|
1928
|
+
sweeper.dispose();
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
function resetAuditRuntimeForTests() {
|
|
1933
|
+
(0, import_auditSink.setAuditCaptureConfig)(null);
|
|
1934
|
+
(0, import_auditSink.setAuditSink)(null);
|
|
1935
|
+
if (sweeper) sweeper.dispose();
|
|
1936
|
+
writer = null;
|
|
1937
|
+
sweeper = null;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
// src/billing/billingRuntime.ts
|
|
1941
|
+
var import_billingEmit = require("@omnicross/core/pipeline/billingEmit");
|
|
1942
|
+
var publisher = null;
|
|
1943
|
+
var sweeper2 = null;
|
|
1944
|
+
function setBillingRuntime(p, s) {
|
|
1945
|
+
publisher = p;
|
|
1946
|
+
sweeper2 = s;
|
|
1947
|
+
}
|
|
1948
|
+
function applyBillingConfig(config) {
|
|
1949
|
+
const enabled = config?.enabled === true && publisher !== null;
|
|
1950
|
+
if (enabled && config) {
|
|
1951
|
+
const activePublisher = publisher;
|
|
1952
|
+
activePublisher.setConfig(config);
|
|
1953
|
+
(0, import_billingEmit.setBillingCaptureConfig)(config);
|
|
1954
|
+
(0, import_billingEmit.setBillingSink)((event) => activePublisher.record(event));
|
|
1955
|
+
if (sweeper2) {
|
|
1956
|
+
sweeper2.configure(config);
|
|
1957
|
+
sweeper2.start();
|
|
1958
|
+
}
|
|
1959
|
+
} else {
|
|
1960
|
+
(0, import_billingEmit.setBillingCaptureConfig)(null);
|
|
1961
|
+
(0, import_billingEmit.setBillingSink)(null);
|
|
1962
|
+
if (sweeper2) {
|
|
1963
|
+
if (config) sweeper2.configure(config);
|
|
1964
|
+
sweeper2.dispose();
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
function resetBillingRuntimeForTests() {
|
|
1969
|
+
(0, import_billingEmit.setBillingCaptureConfig)(null);
|
|
1970
|
+
(0, import_billingEmit.setBillingSink)(null);
|
|
1971
|
+
if (sweeper2) sweeper2.dispose();
|
|
1972
|
+
publisher = null;
|
|
1973
|
+
sweeper2 = null;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1130
1976
|
// src/ports/account-multi.ts
|
|
1131
1977
|
var import_node_crypto5 = require("crypto");
|
|
1132
1978
|
var PROVIDER_KEYS = {
|
|
@@ -1238,6 +2084,9 @@ function getAccountById(config, p, id) {
|
|
|
1238
2084
|
const account = getAccounts(config, p).find((a) => a.id === id);
|
|
1239
2085
|
return account ? { id: account.id, tokens: account.tokens } : void 0;
|
|
1240
2086
|
}
|
|
2087
|
+
function getAccountProxy(config, p, id) {
|
|
2088
|
+
return getAccounts(config, p).find((a) => a.id === id)?.proxy;
|
|
2089
|
+
}
|
|
1241
2090
|
function getActiveAccount(config, p) {
|
|
1242
2091
|
const active = getAccounts(config, p).find((a) => a.id === getActiveId(config, p));
|
|
1243
2092
|
return active ? { id: active.id, tokens: active.tokens } : void 0;
|
|
@@ -1278,7 +2127,17 @@ function sanitizeAccounts(config, p) {
|
|
|
1278
2127
|
isSetupToken: t.isSetupToken,
|
|
1279
2128
|
hasAccessToken: !!(t.accessToken || t.apiKey),
|
|
1280
2129
|
isActive: a.id === activeId,
|
|
1281
|
-
|
|
2130
|
+
// Scheduling metadata (subscription-account-scheduling): editable priority
|
|
2131
|
+
// (default 50 shown when unset) + display-only lastUsedAt. Secret-free.
|
|
2132
|
+
priority: a.priority,
|
|
2133
|
+
lastUsedAt: a.lastUsedAt,
|
|
2134
|
+
syncWarning: t.syncWarning,
|
|
2135
|
+
// Per-account proxy (upstream-proxy): masked view — password → hasPassword,
|
|
2136
|
+
// userinfo stripped. The plaintext password is NEVER projected.
|
|
2137
|
+
proxy: a.proxy ? sanitizeProxyConfig(a.proxy) : void 0,
|
|
2138
|
+
// Per-account model support / remap (subscription-account-model-map): model
|
|
2139
|
+
// ids are not token material → carried through verbatim for the editor.
|
|
2140
|
+
supportedModels: a.supportedModels
|
|
1282
2141
|
};
|
|
1283
2142
|
});
|
|
1284
2143
|
}
|
|
@@ -1292,22 +2151,93 @@ function renameAccount(config, p, id, label) {
|
|
|
1292
2151
|
);
|
|
1293
2152
|
return { ok: true };
|
|
1294
2153
|
}
|
|
1295
|
-
function
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
2154
|
+
function setAccountPriority(config, p, id, priority) {
|
|
2155
|
+
const accounts = getAccounts(config, p);
|
|
2156
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2157
|
+
setAccounts(
|
|
2158
|
+
config,
|
|
2159
|
+
p,
|
|
2160
|
+
accounts.map((a) => a.id === id ? { ...a, priority } : a)
|
|
2161
|
+
);
|
|
2162
|
+
return { ok: true };
|
|
1299
2163
|
}
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
2164
|
+
function setAccountProxy(config, p, id, proxy) {
|
|
2165
|
+
const accounts = getAccounts(config, p);
|
|
2166
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2167
|
+
setAccounts(
|
|
2168
|
+
config,
|
|
2169
|
+
p,
|
|
2170
|
+
accounts.map((a) => {
|
|
2171
|
+
if (a.id !== id) return a;
|
|
2172
|
+
if (!proxy) {
|
|
2173
|
+
const { proxy: _drop, ...rest } = a;
|
|
2174
|
+
return rest;
|
|
2175
|
+
}
|
|
2176
|
+
return { ...a, proxy };
|
|
2177
|
+
})
|
|
2178
|
+
);
|
|
2179
|
+
return { ok: true };
|
|
2180
|
+
}
|
|
2181
|
+
function setAccountSupportedModels(config, p, id, supportedModels) {
|
|
2182
|
+
const accounts = getAccounts(config, p);
|
|
2183
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2184
|
+
setAccounts(
|
|
2185
|
+
config,
|
|
2186
|
+
p,
|
|
2187
|
+
accounts.map((a) => {
|
|
2188
|
+
if (a.id !== id) return a;
|
|
2189
|
+
if (supportedModels === void 0) {
|
|
2190
|
+
const { supportedModels: _drop, ...rest } = a;
|
|
2191
|
+
return rest;
|
|
2192
|
+
}
|
|
2193
|
+
return { ...a, supportedModels };
|
|
2194
|
+
})
|
|
2195
|
+
);
|
|
2196
|
+
return { ok: true };
|
|
2197
|
+
}
|
|
2198
|
+
function setAccountLastUsed(config, p, id, iso) {
|
|
2199
|
+
const accounts = getAccounts(config, p);
|
|
2200
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2201
|
+
setAccounts(
|
|
2202
|
+
config,
|
|
2203
|
+
p,
|
|
2204
|
+
accounts.map((a) => a.id === id ? { ...a, lastUsedAt: iso } : a)
|
|
2205
|
+
);
|
|
2206
|
+
return { ok: true };
|
|
2207
|
+
}
|
|
2208
|
+
function setAccountIdentity(config, p, id, identity) {
|
|
2209
|
+
const accounts = getAccounts(config, p);
|
|
2210
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2211
|
+
setAccounts(
|
|
2212
|
+
config,
|
|
2213
|
+
p,
|
|
2214
|
+
accounts.map((a) => {
|
|
2215
|
+
if (a.id !== id) return a;
|
|
2216
|
+
if (identity === void 0) {
|
|
2217
|
+
const { identity: _drop, ...rest } = a;
|
|
2218
|
+
return rest;
|
|
2219
|
+
}
|
|
2220
|
+
return { ...a, identity };
|
|
2221
|
+
})
|
|
2222
|
+
);
|
|
2223
|
+
return { ok: true };
|
|
2224
|
+
}
|
|
2225
|
+
function clearProvider(config, p) {
|
|
2226
|
+
setBlock(config, p, void 0);
|
|
2227
|
+
setAccounts(config, p, void 0);
|
|
2228
|
+
setActiveId(config, p, void 0);
|
|
2229
|
+
}
|
|
2230
|
+
var DAEMON_PROVIDER_KEYS = PROVIDER_KEYS;
|
|
2231
|
+
|
|
2232
|
+
// src/migration/packCodec.ts
|
|
2233
|
+
var import_node_crypto6 = require("crypto");
|
|
2234
|
+
var PACK_MAGIC = "OMCXPACK";
|
|
2235
|
+
var PACK_VERSION = 1;
|
|
2236
|
+
var KDF_ALGORITHM = "scrypt";
|
|
2237
|
+
var PACK_PREFIX = `${PACK_MAGIC}${PACK_VERSION}.`;
|
|
2238
|
+
var KEY_BYTES3 = 32;
|
|
2239
|
+
var IV_BYTES2 = 12;
|
|
2240
|
+
var TAG_BYTES2 = 16;
|
|
1311
2241
|
var SCRYPT_N = 1 << 15;
|
|
1312
2242
|
var SCRYPT_R = 8;
|
|
1313
2243
|
var SCRYPT_P = 1;
|
|
@@ -1557,6 +2487,12 @@ function parseRange(query) {
|
|
|
1557
2487
|
return { startTs, endTs };
|
|
1558
2488
|
}
|
|
1559
2489
|
var isRange = (v) => v.startTs !== void 0 && !("status" in v);
|
|
2490
|
+
var BUCKET_SPAN_MS = {
|
|
2491
|
+
hour: 36e5,
|
|
2492
|
+
day: 864e5,
|
|
2493
|
+
month: 28 * 864e5
|
|
2494
|
+
};
|
|
2495
|
+
var MAX_TIMESERIES_BUCKETS = 2e3;
|
|
1560
2496
|
async function handleUsageGet(view, query, deps) {
|
|
1561
2497
|
const range = parseRange(query);
|
|
1562
2498
|
if (!isRange(range)) return range;
|
|
@@ -1565,6 +2501,24 @@ async function handleUsageGet(view, query, deps) {
|
|
|
1565
2501
|
return { status: 200, body: await deps.usageRecorder.getTotals(range) };
|
|
1566
2502
|
case "by-model":
|
|
1567
2503
|
return { status: 200, body: await deps.usageRecorder.getByModel(range) };
|
|
2504
|
+
case "timeseries": {
|
|
2505
|
+
const bucket = query.get("bucket");
|
|
2506
|
+
if (bucket !== "hour" && bucket !== "day" && bucket !== "month") {
|
|
2507
|
+
return err4(400, "bucket must be one of 'hour', 'day', 'month'");
|
|
2508
|
+
}
|
|
2509
|
+
const now = Date.now();
|
|
2510
|
+
const clamped = { startTs: range.startTs, endTs: Math.min(range.endTs, now) };
|
|
2511
|
+
if (clamped.startTs < clamped.endTs) {
|
|
2512
|
+
const projected = Math.ceil((clamped.endTs - clamped.startTs) / BUCKET_SPAN_MS[bucket]) + 1;
|
|
2513
|
+
if (projected > MAX_TIMESERIES_BUCKETS) {
|
|
2514
|
+
return err4(
|
|
2515
|
+
400,
|
|
2516
|
+
`requested range projects ~${projected} '${bucket}' buckets (max ${MAX_TIMESERIES_BUCKETS}); narrow the range or use a coarser bucket`
|
|
2517
|
+
);
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
return { status: 200, body: await deps.usageRecorder.getTimeSeries(clamped, bucket) };
|
|
2521
|
+
}
|
|
1568
2522
|
case "by-api-key": {
|
|
1569
2523
|
const rows = await deps.usageRecorder.getByApiKey(range);
|
|
1570
2524
|
const labels = poolKeyLabels(loadConfig(deps.configPath));
|
|
@@ -1710,7 +2664,7 @@ function readBody(req) {
|
|
|
1710
2664
|
req.on("error", reject);
|
|
1711
2665
|
});
|
|
1712
2666
|
}
|
|
1713
|
-
async function
|
|
2667
|
+
async function readJsonBody3(req) {
|
|
1714
2668
|
const raw = await readBody(req);
|
|
1715
2669
|
if (!raw.trim()) return {};
|
|
1716
2670
|
try {
|
|
@@ -1720,12 +2674,12 @@ async function readJsonBody(req) {
|
|
|
1720
2674
|
return {};
|
|
1721
2675
|
}
|
|
1722
2676
|
}
|
|
1723
|
-
function
|
|
2677
|
+
function writeJson2(res, status, body) {
|
|
1724
2678
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1725
2679
|
res.end(JSON.stringify(body));
|
|
1726
2680
|
}
|
|
1727
2681
|
function writeJsonError(res, status, message) {
|
|
1728
|
-
|
|
2682
|
+
writeJson2(res, status, { error: { type: "admin_api_error", message } });
|
|
1729
2683
|
}
|
|
1730
2684
|
function maskProviderApiKey(apiKey) {
|
|
1731
2685
|
if (!apiKey) return "";
|
|
@@ -1741,7 +2695,23 @@ function toKeyInfo(row) {
|
|
|
1741
2695
|
enabled: row.enabled,
|
|
1742
2696
|
createdAt: row.createdAt,
|
|
1743
2697
|
lastUsedAt: row.lastUsedAt,
|
|
1744
|
-
revoked: row.revokedAt !== null
|
|
2698
|
+
revoked: row.revokedAt !== null,
|
|
2699
|
+
maxConcurrency: row.maxConcurrency,
|
|
2700
|
+
// Key-policy envelope (outbound-key-policy) — all secret-free scalar fields;
|
|
2701
|
+
// the UI reads them to render + pre-fill the policy editor.
|
|
2702
|
+
expiresAt: row.expiresAt,
|
|
2703
|
+
activationMode: row.activationMode,
|
|
2704
|
+
activationDays: row.activationDays,
|
|
2705
|
+
activatedAt: row.activatedAt,
|
|
2706
|
+
dailyCostLimitUsd: row.dailyCostLimitUsd,
|
|
2707
|
+
totalCostLimitUsd: row.totalCostLimitUsd,
|
|
2708
|
+
weeklyCostLimitUsd: row.weeklyCostLimitUsd,
|
|
2709
|
+
rateLimitMaxRequests: row.rateLimitMaxRequests,
|
|
2710
|
+
rateLimitWindowMs: row.rateLimitWindowMs,
|
|
2711
|
+
// Per-key model restriction (#6) — secret-free scalars the UI pre-fills.
|
|
2712
|
+
enableModelRestriction: row.enableModelRestriction,
|
|
2713
|
+
restrictionMode: row.restrictionMode,
|
|
2714
|
+
restrictedModels: row.restrictedModels
|
|
1745
2715
|
};
|
|
1746
2716
|
}
|
|
1747
2717
|
function toProviderView(row) {
|
|
@@ -1803,6 +2773,8 @@ async function handleAdminApi(req, res, path2, deps) {
|
|
|
1803
2773
|
return handlePresets(res, method);
|
|
1804
2774
|
case "keys":
|
|
1805
2775
|
return await handleKeys(req, res, method, rest, deps);
|
|
2776
|
+
case "voucher":
|
|
2777
|
+
return await handleVoucher(req, res, method, rest, deps);
|
|
1806
2778
|
case "server":
|
|
1807
2779
|
return await handleServer(req, res, method, deps);
|
|
1808
2780
|
case "accounts":
|
|
@@ -1819,6 +2791,8 @@ async function handleAdminApi(req, res, path2, deps) {
|
|
|
1819
2791
|
return await handleMigrationImport(req, res, method, deps);
|
|
1820
2792
|
case "usage":
|
|
1821
2793
|
return await handleUsage(req, res, method, rest, deps);
|
|
2794
|
+
case "dashboard":
|
|
2795
|
+
return await handleDashboardRoute(res, method, deps);
|
|
1822
2796
|
case "pricing":
|
|
1823
2797
|
return await handlePricing(req, res, method, rest, deps);
|
|
1824
2798
|
default:
|
|
@@ -1834,17 +2808,22 @@ function requestQuery(req) {
|
|
|
1834
2808
|
return new URLSearchParams(qIdx >= 0 ? raw.slice(qIdx + 1) : "");
|
|
1835
2809
|
}
|
|
1836
2810
|
function writeResult(res, result) {
|
|
1837
|
-
|
|
2811
|
+
writeJson2(res, result.status, result.body);
|
|
1838
2812
|
}
|
|
1839
2813
|
async function handleUsage(req, res, method, rest, deps) {
|
|
1840
2814
|
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on usage`);
|
|
1841
2815
|
return writeResult(res, await handleUsageGet(rest[0], requestQuery(req), deps));
|
|
1842
2816
|
}
|
|
2817
|
+
async function handleDashboardRoute(res, method, deps) {
|
|
2818
|
+
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on dashboard`);
|
|
2819
|
+
const result = await handleDashboard(deps);
|
|
2820
|
+
return writeJson2(res, result.status, result.body);
|
|
2821
|
+
}
|
|
1843
2822
|
async function handlePricing(req, res, method, rest, deps) {
|
|
1844
2823
|
if (rest.length === 0) {
|
|
1845
2824
|
if (method === "GET") return writeResult(res, await handlePricingList(deps));
|
|
1846
2825
|
if (method === "PUT") {
|
|
1847
|
-
return writeResult(res, await handlePricingUpsert(await
|
|
2826
|
+
return writeResult(res, await handlePricingUpsert(await readJsonBody3(req), deps));
|
|
1848
2827
|
}
|
|
1849
2828
|
if (method === "DELETE") {
|
|
1850
2829
|
return writeResult(res, await handlePricingDelete(requestQuery(req), deps));
|
|
@@ -1855,7 +2834,7 @@ async function handlePricing(req, res, method, rest, deps) {
|
|
|
1855
2834
|
return writeResult(res, await handlePricingFetchLatest(deps));
|
|
1856
2835
|
}
|
|
1857
2836
|
if (method === "POST" && rest.length === 1 && rest[0] === "resolve-conflicts") {
|
|
1858
|
-
return writeResult(res, await handlePricingResolveConflicts(await
|
|
2837
|
+
return writeResult(res, await handlePricingResolveConflicts(await readJsonBody3(req), deps));
|
|
1859
2838
|
}
|
|
1860
2839
|
return writeJsonError(res, 404, `unknown pricing route '${rest.join("/")}'`);
|
|
1861
2840
|
}
|
|
@@ -1869,15 +2848,15 @@ function migrationDeps(deps) {
|
|
|
1869
2848
|
}
|
|
1870
2849
|
async function handleMigrationExport(req, res, method, deps) {
|
|
1871
2850
|
if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on export`);
|
|
1872
|
-
const body = await
|
|
2851
|
+
const body = await readJsonBody3(req);
|
|
1873
2852
|
const result = await handleExport(body, migrationDeps(deps));
|
|
1874
|
-
return
|
|
2853
|
+
return writeJson2(res, result.status, result.body);
|
|
1875
2854
|
}
|
|
1876
2855
|
async function handleMigrationImport(req, res, method, deps) {
|
|
1877
2856
|
if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on import`);
|
|
1878
|
-
const body = await
|
|
2857
|
+
const body = await readJsonBody3(req);
|
|
1879
2858
|
const result = await handleImport(body, migrationDeps(deps));
|
|
1880
|
-
return
|
|
2859
|
+
return writeJson2(res, result.status, result.body);
|
|
1881
2860
|
}
|
|
1882
2861
|
async function handleProviders(req, res, method, rest, deps) {
|
|
1883
2862
|
const cfg = loadConfig(deps.configPath);
|
|
@@ -1908,13 +2887,13 @@ async function handleProviders(req, res, method, rest, deps) {
|
|
|
1908
2887
|
if (method === "GET" && rest.length === 2 && rest[1] === "reveal-key") {
|
|
1909
2888
|
const row = cfg.providers.find((p) => p.id === rest[0]);
|
|
1910
2889
|
if (!row) return writeJsonError(res, 404, `provider '${rest[0]}' not found`);
|
|
1911
|
-
return
|
|
2890
|
+
return writeJson2(res, 200, { apiKey: row.apiKey ?? "" });
|
|
1912
2891
|
}
|
|
1913
2892
|
if (method === "GET") {
|
|
1914
|
-
return
|
|
2893
|
+
return writeJson2(res, 200, { providers: cfg.providers.map(toProviderView) });
|
|
1915
2894
|
}
|
|
1916
2895
|
if (method === "POST") {
|
|
1917
|
-
const body = await
|
|
2896
|
+
const body = await readJsonBody3(req);
|
|
1918
2897
|
const provider = parseProviderInput(body, void 0);
|
|
1919
2898
|
if (!provider) return writeJsonError(res, 400, "invalid provider (id, apiFormat, baseUrl required)");
|
|
1920
2899
|
if (cfg.providers.some((p) => p.id === provider.id)) {
|
|
@@ -1922,25 +2901,25 @@ async function handleProviders(req, res, method, rest, deps) {
|
|
|
1922
2901
|
}
|
|
1923
2902
|
cfg.providers.push(provider);
|
|
1924
2903
|
persistProviders(cfg, deps);
|
|
1925
|
-
return
|
|
2904
|
+
return writeJson2(res, 201, { provider: toProviderView(provider) });
|
|
1926
2905
|
}
|
|
1927
2906
|
const id = rest[0];
|
|
1928
2907
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
1929
2908
|
const idx = cfg.providers.findIndex((p) => p.id === id);
|
|
1930
2909
|
if (idx < 0) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
1931
2910
|
if (method === "PUT") {
|
|
1932
|
-
const body = await
|
|
2911
|
+
const body = await readJsonBody3(req);
|
|
1933
2912
|
const existing = cfg.providers[idx];
|
|
1934
2913
|
const updated = parseProviderInput(body, existing);
|
|
1935
2914
|
if (!updated) return writeJsonError(res, 400, "invalid provider (apiFormat, baseUrl required)");
|
|
1936
2915
|
cfg.providers[idx] = updated;
|
|
1937
2916
|
persistProviders(cfg, deps);
|
|
1938
|
-
return
|
|
2917
|
+
return writeJson2(res, 200, { provider: toProviderView(updated) });
|
|
1939
2918
|
}
|
|
1940
2919
|
if (method === "DELETE") {
|
|
1941
2920
|
cfg.providers.splice(idx, 1);
|
|
1942
2921
|
persistProviders(cfg, deps);
|
|
1943
|
-
return
|
|
2922
|
+
return writeJson2(res, 200, { ok: true });
|
|
1944
2923
|
}
|
|
1945
2924
|
return writeJsonError(res, 405, `method ${method} not allowed on providers`);
|
|
1946
2925
|
}
|
|
@@ -1949,7 +2928,7 @@ function persistProviders(cfg, deps) {
|
|
|
1949
2928
|
deps.llmConfig.reload(cfg);
|
|
1950
2929
|
}
|
|
1951
2930
|
async function handleProviderReorder(req, res, cfg, deps) {
|
|
1952
|
-
const body = await
|
|
2931
|
+
const body = await readJsonBody3(req);
|
|
1953
2932
|
const rawOrder = body["order"];
|
|
1954
2933
|
if (!Array.isArray(rawOrder)) {
|
|
1955
2934
|
return writeJsonError(res, 400, "reorder requires { order: string[] }");
|
|
@@ -1973,14 +2952,14 @@ async function handleProviderReorder(req, res, cfg, deps) {
|
|
|
1973
2952
|
}
|
|
1974
2953
|
cfg.providers = reordered;
|
|
1975
2954
|
persistProviders(cfg, deps);
|
|
1976
|
-
return
|
|
2955
|
+
return writeJson2(res, 200, { ok: true, providers: cfg.providers.map(toProviderView) });
|
|
1977
2956
|
}
|
|
1978
2957
|
async function handleDiscoverModels(res, id, cfg) {
|
|
1979
2958
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
1980
2959
|
const row = cfg.providers.find((p) => p.id === id);
|
|
1981
2960
|
if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
1982
2961
|
if (row.apiFormat !== "openai") {
|
|
1983
|
-
return
|
|
2962
|
+
return writeJson2(res, 200, { models: [], unsupportedFormat: true });
|
|
1984
2963
|
}
|
|
1985
2964
|
const resolvedKey = resolveEnvKey(row.apiKey);
|
|
1986
2965
|
const base = row.baseUrl.replace(/\/+$/, "");
|
|
@@ -1988,7 +2967,7 @@ async function handleDiscoverModels(res, id, cfg) {
|
|
|
1988
2967
|
try {
|
|
1989
2968
|
const headers = { Accept: "application/json" };
|
|
1990
2969
|
if (resolvedKey) headers["Authorization"] = `Bearer ${resolvedKey}`;
|
|
1991
|
-
const response = await
|
|
2970
|
+
const response = await (0, import_upstreamFetch2.fetchUpstream)(url, { method: "GET", headers }, { providerId: "byo" });
|
|
1992
2971
|
if (!response.ok) {
|
|
1993
2972
|
const text = await response.text().catch(() => "");
|
|
1994
2973
|
let message = text.slice(0, 300);
|
|
@@ -1997,32 +2976,32 @@ async function handleDiscoverModels(res, id, cfg) {
|
|
|
1997
2976
|
message = parsed?.error?.message || parsed?.message || message;
|
|
1998
2977
|
} catch {
|
|
1999
2978
|
}
|
|
2000
|
-
return
|
|
2979
|
+
return writeJson2(res, 200, {
|
|
2001
2980
|
models: [],
|
|
2002
2981
|
error: `discovery failed (${response.status})${message ? `: ${message}` : ""}`
|
|
2003
2982
|
});
|
|
2004
2983
|
}
|
|
2005
2984
|
const data = await response.json();
|
|
2006
2985
|
const models = Array.isArray(data?.data) ? data.data.map((m) => typeof m?.id === "string" ? m.id : "").filter((m) => m.length > 0) : [];
|
|
2007
|
-
return
|
|
2986
|
+
return writeJson2(res, 200, { models });
|
|
2008
2987
|
} catch (err5) {
|
|
2009
2988
|
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
2010
|
-
return
|
|
2989
|
+
return writeJson2(res, 200, { models: [], error: `discovery failed: ${message}` });
|
|
2011
2990
|
}
|
|
2012
2991
|
}
|
|
2013
2992
|
async function handleTestModel(req, res, id, cfg) {
|
|
2014
2993
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
2015
2994
|
const row = cfg.providers.find((p) => p.id === id);
|
|
2016
2995
|
if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
2017
|
-
const body = await
|
|
2996
|
+
const body = await readJsonBody3(req);
|
|
2018
2997
|
const model = typeof body["model"] === "string" ? body["model"].trim() : "";
|
|
2019
2998
|
if (!model) return writeJsonError(res, 400, "test requires a { model } string");
|
|
2020
2999
|
if (row.apiFormat === "gemini") {
|
|
2021
|
-
return
|
|
3000
|
+
return writeJson2(res, 200, { ok: false, unsupportedFormat: true });
|
|
2022
3001
|
}
|
|
2023
3002
|
const resolvedKey = resolveEnvKey(row.apiKey);
|
|
2024
3003
|
if (!resolvedKey) {
|
|
2025
|
-
return
|
|
3004
|
+
return writeJson2(res, 200, { ok: false, message: "no API key configured for this provider" });
|
|
2026
3005
|
}
|
|
2027
3006
|
const url = row.baseUrl.replace(/\/+$/, "");
|
|
2028
3007
|
const prompt = "Reply with the single word: OK.";
|
|
@@ -2043,11 +3022,11 @@ async function handleTestModel(req, res, id, cfg) {
|
|
|
2043
3022
|
}
|
|
2044
3023
|
const startedAt = Date.now();
|
|
2045
3024
|
try {
|
|
2046
|
-
const response = await
|
|
2047
|
-
|
|
2048
|
-
headers,
|
|
2049
|
-
|
|
2050
|
-
|
|
3025
|
+
const response = await (0, import_upstreamFetch2.fetchUpstream)(
|
|
3026
|
+
url,
|
|
3027
|
+
{ method: "POST", headers, body: JSON.stringify(payload) },
|
|
3028
|
+
{ providerId: "byo" }
|
|
3029
|
+
);
|
|
2051
3030
|
const latencyMs = Date.now() - startedAt;
|
|
2052
3031
|
const text = await response.text().catch(() => "");
|
|
2053
3032
|
if (!response.ok) {
|
|
@@ -2057,9 +3036,9 @@ async function handleTestModel(req, res, id, cfg) {
|
|
|
2057
3036
|
message = parsed?.error?.message || parsed?.message || message;
|
|
2058
3037
|
} catch {
|
|
2059
3038
|
}
|
|
2060
|
-
return
|
|
3039
|
+
return writeJson2(res, 200, { ok: false, status: response.status, latencyMs, message });
|
|
2061
3040
|
}
|
|
2062
|
-
return
|
|
3041
|
+
return writeJson2(res, 200, {
|
|
2063
3042
|
ok: true,
|
|
2064
3043
|
status: response.status,
|
|
2065
3044
|
latencyMs,
|
|
@@ -2067,7 +3046,7 @@ async function handleTestModel(req, res, id, cfg) {
|
|
|
2067
3046
|
});
|
|
2068
3047
|
} catch (err5) {
|
|
2069
3048
|
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
2070
|
-
return
|
|
3049
|
+
return writeJson2(res, 200, { ok: false, latencyMs: Date.now() - startedAt, message });
|
|
2071
3050
|
}
|
|
2072
3051
|
}
|
|
2073
3052
|
function extractSampleText(text, apiFormat) {
|
|
@@ -2089,9 +3068,9 @@ function toPoolKeyView(row, cooldown, deps) {
|
|
|
2089
3068
|
return entries.map((e) => {
|
|
2090
3069
|
const auto = deps.autoDisableStore.get(e.id);
|
|
2091
3070
|
const cd = cooldown[e.id];
|
|
2092
|
-
const
|
|
2093
|
-
if (cd)
|
|
2094
|
-
if (auto)
|
|
3071
|
+
const health2 = {};
|
|
3072
|
+
if (cd) health2.cooldown = cd;
|
|
3073
|
+
if (auto) health2.autoDisabled = { status: auto.status, at: auto.at, reason: auto.reason };
|
|
2095
3074
|
return {
|
|
2096
3075
|
id: e.id,
|
|
2097
3076
|
label: e.label && e.label.length > 0 ? e.label : e.id,
|
|
@@ -2099,7 +3078,7 @@ function toPoolKeyView(row, cooldown, deps) {
|
|
|
2099
3078
|
enabled: e.enabled !== false && !deps.autoDisableStore.isDisabled(e.id),
|
|
2100
3079
|
weight: typeof e.weight === "number" && Number.isFinite(e.weight) ? e.weight : 1,
|
|
2101
3080
|
apiKeyMasked: maskProviderApiKey(e.apiKey),
|
|
2102
|
-
...Object.keys(
|
|
3081
|
+
...Object.keys(health2).length > 0 ? { health: health2 } : {}
|
|
2103
3082
|
};
|
|
2104
3083
|
});
|
|
2105
3084
|
}
|
|
@@ -2108,7 +3087,7 @@ async function handleProviderKeys(res, id, cfg, deps) {
|
|
|
2108
3087
|
const row = cfg.providers.find((p) => p.id === id);
|
|
2109
3088
|
if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
2110
3089
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2111
|
-
return
|
|
3090
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2112
3091
|
}
|
|
2113
3092
|
function parsePoolKeyInput(body, existing) {
|
|
2114
3093
|
const out = {};
|
|
@@ -2127,7 +3106,7 @@ async function handleAddProviderKey(req, res, id, cfg, deps) {
|
|
|
2127
3106
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
2128
3107
|
const idx = cfg.providers.findIndex((p) => p.id === id);
|
|
2129
3108
|
if (idx < 0) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
2130
|
-
const body = await
|
|
3109
|
+
const body = await readJsonBody3(req);
|
|
2131
3110
|
const parsed = parsePoolKeyInput(body);
|
|
2132
3111
|
if (!parsed.apiKey) return writeJsonError(res, 400, "add requires a non-empty apiKey");
|
|
2133
3112
|
const keyId = `key-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -2139,7 +3118,7 @@ async function handleAddProviderKey(req, res, id, cfg, deps) {
|
|
|
2139
3118
|
row.apiKeys = [...row.apiKeys ?? [], entry];
|
|
2140
3119
|
persistProviders(cfg, deps);
|
|
2141
3120
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2142
|
-
return
|
|
3121
|
+
return writeJson2(res, 201, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2143
3122
|
}
|
|
2144
3123
|
async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
|
|
2145
3124
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
@@ -2149,7 +3128,7 @@ async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
|
|
|
2149
3128
|
const row = cfg.providers[idx];
|
|
2150
3129
|
const keyIdx = (row.apiKeys ?? []).findIndex((k) => k.id === keyId);
|
|
2151
3130
|
if (keyIdx < 0) return writeJsonError(res, 404, `pool key '${keyId}' not found`);
|
|
2152
|
-
const body = await
|
|
3131
|
+
const body = await readJsonBody3(req);
|
|
2153
3132
|
const existing = row.apiKeys[keyIdx];
|
|
2154
3133
|
const parsed = parsePoolKeyInput(body, existing);
|
|
2155
3134
|
const entry = { id: keyId, apiKey: parsed.apiKey || existing.apiKey };
|
|
@@ -2159,7 +3138,7 @@ async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
|
|
|
2159
3138
|
row.apiKeys[keyIdx] = entry;
|
|
2160
3139
|
persistProviders(cfg, deps);
|
|
2161
3140
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2162
|
-
return
|
|
3141
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2163
3142
|
}
|
|
2164
3143
|
async function handleDeleteProviderKey(res, id, keyId, cfg, deps) {
|
|
2165
3144
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
@@ -2173,7 +3152,7 @@ async function handleDeleteProviderKey(res, id, keyId, cfg, deps) {
|
|
|
2173
3152
|
if (row.apiKeys.length === 0) row.apiKeys = void 0;
|
|
2174
3153
|
persistProviders(cfg, deps);
|
|
2175
3154
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2176
|
-
return
|
|
3155
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2177
3156
|
}
|
|
2178
3157
|
async function handleToggleProviderKey(req, res, id, keyId, cfg, deps) {
|
|
2179
3158
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
@@ -2183,11 +3162,11 @@ async function handleToggleProviderKey(req, res, id, keyId, cfg, deps) {
|
|
|
2183
3162
|
const row = cfg.providers[idx];
|
|
2184
3163
|
const keyIdx = (row.apiKeys ?? []).findIndex((k) => k.id === keyId);
|
|
2185
3164
|
if (keyIdx < 0) return writeJsonError(res, 404, `pool key '${keyId}' not found`);
|
|
2186
|
-
const body = await
|
|
3165
|
+
const body = await readJsonBody3(req);
|
|
2187
3166
|
row.apiKeys[keyIdx].enabled = Boolean(body["enabled"]);
|
|
2188
3167
|
persistProviders(cfg, deps);
|
|
2189
3168
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2190
|
-
return
|
|
3169
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2191
3170
|
}
|
|
2192
3171
|
function parseApiKeysInput(raw, existing) {
|
|
2193
3172
|
if (!Array.isArray(raw)) return existing;
|
|
@@ -2358,18 +3337,31 @@ function handlePresets(res, method) {
|
|
|
2358
3337
|
baseUrl: p.baseUrl,
|
|
2359
3338
|
models: p.models
|
|
2360
3339
|
}));
|
|
2361
|
-
return
|
|
3340
|
+
return writeJson2(res, 200, { presets, excluded });
|
|
2362
3341
|
}
|
|
2363
3342
|
async function handleKeys(req, res, method, rest, deps) {
|
|
2364
3343
|
if (method === "GET" && rest.length === 0) {
|
|
2365
3344
|
const rows = await deps.keyDb.outboundApiKeysList();
|
|
2366
|
-
|
|
3345
|
+
const reader = deps.keySpendReader;
|
|
3346
|
+
if (!reader) return writeJson2(res, 200, { keys: rows.map(toKeyInfo) });
|
|
3347
|
+
const now = Date.now();
|
|
3348
|
+
const keys = await Promise.all(
|
|
3349
|
+
rows.map(async (row) => {
|
|
3350
|
+
const info = toKeyInfo(row);
|
|
3351
|
+
if (row.revokedAt === null) {
|
|
3352
|
+
const s = await reader.getSpend(row.id, now);
|
|
3353
|
+
info.spend = { dailyUsd: s.dailyUsd, weeklyUsd: s.weeklyUsd, totalUsd: s.totalUsd };
|
|
3354
|
+
}
|
|
3355
|
+
return info;
|
|
3356
|
+
})
|
|
3357
|
+
);
|
|
3358
|
+
return writeJson2(res, 200, { keys });
|
|
2367
3359
|
}
|
|
2368
3360
|
if (method === "POST" && rest.length === 0) {
|
|
2369
|
-
const body = await
|
|
3361
|
+
const body = await readJsonBody3(req);
|
|
2370
3362
|
const name = typeof body["name"] === "string" && body["name"].trim() ? body["name"].trim() : "key";
|
|
2371
|
-
const created = await (0,
|
|
2372
|
-
return
|
|
3363
|
+
const created = await (0, import_outbound_api2.createNamedKey)(deps.keyDb, name);
|
|
3364
|
+
return writeJson2(res, 201, {
|
|
2373
3365
|
id: created.id,
|
|
2374
3366
|
name: created.name,
|
|
2375
3367
|
keyPrefix: created.keyPrefix,
|
|
@@ -2381,46 +3373,185 @@ async function handleKeys(req, res, method, rest, deps) {
|
|
|
2381
3373
|
const action = rest[1];
|
|
2382
3374
|
if (method === "POST" && id && action === "revoke") {
|
|
2383
3375
|
const ok = await deps.keyDb.outboundApiKeysRevoke(id);
|
|
2384
|
-
return
|
|
3376
|
+
return writeJson2(res, ok ? 200 : 404, { ok });
|
|
2385
3377
|
}
|
|
2386
3378
|
if (method === "POST" && id && action === "enabled") {
|
|
2387
|
-
const body = await
|
|
3379
|
+
const body = await readJsonBody3(req);
|
|
2388
3380
|
const enabled = body["enabled"] === true;
|
|
2389
3381
|
const ok = await deps.keyDb.outboundApiKeysSetEnabled(id, enabled);
|
|
2390
|
-
return
|
|
3382
|
+
return writeJson2(res, ok ? 200 : 404, { ok, enabled });
|
|
3383
|
+
}
|
|
3384
|
+
if (method === "POST" && id && action === "max-concurrency") {
|
|
3385
|
+
const body = await readJsonBody3(req);
|
|
3386
|
+
const raw = body["maxConcurrency"];
|
|
3387
|
+
let value;
|
|
3388
|
+
if (raw === null) {
|
|
3389
|
+
value = null;
|
|
3390
|
+
} else if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 1e3) {
|
|
3391
|
+
value = raw;
|
|
3392
|
+
} else {
|
|
3393
|
+
return writeJsonError(
|
|
3394
|
+
res,
|
|
3395
|
+
400,
|
|
3396
|
+
"maxConcurrency must be an integer 1..1000 or null"
|
|
3397
|
+
);
|
|
3398
|
+
}
|
|
3399
|
+
const ok = await deps.keyDb.outboundApiKeysSetMaxConcurrency(id, value);
|
|
3400
|
+
return writeJson2(res, ok ? 200 : 404, { ok, maxConcurrency: value });
|
|
3401
|
+
}
|
|
3402
|
+
if (method === "POST" && id && action === "policy") {
|
|
3403
|
+
const body = await readJsonBody3(req);
|
|
3404
|
+
const parsed = parseKeyPolicyBody(body);
|
|
3405
|
+
if (!parsed.ok) return writeJsonError(res, 400, parsed.message);
|
|
3406
|
+
const ok = await deps.keyDb.outboundApiKeysSetPolicy(id, parsed.policy);
|
|
3407
|
+
return writeJson2(res, ok ? 200 : 404, { ok });
|
|
2391
3408
|
}
|
|
2392
3409
|
return writeJsonError(res, 405, `method ${method} not allowed on keys`);
|
|
2393
3410
|
}
|
|
3411
|
+
function validateQueueSegments(patch) {
|
|
3412
|
+
const errors = [];
|
|
3413
|
+
const checkNum = (label, value, min, max) => {
|
|
3414
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
|
|
3415
|
+
errors.push(`${label} must be a number ${min}..${max}`);
|
|
3416
|
+
}
|
|
3417
|
+
};
|
|
3418
|
+
const isPlainObject4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3419
|
+
const umq = patch.userMessageQueue;
|
|
3420
|
+
if (umq !== void 0) {
|
|
3421
|
+
if (!isPlainObject4(umq)) {
|
|
3422
|
+
errors.push("userMessageQueue must be an object");
|
|
3423
|
+
} else {
|
|
3424
|
+
if (typeof umq.enabled !== "boolean") {
|
|
3425
|
+
errors.push("userMessageQueue.enabled must be a boolean");
|
|
3426
|
+
}
|
|
3427
|
+
checkNum("userMessageQueue.delayMs", umq.delayMs, 0, 1e4);
|
|
3428
|
+
checkNum("userMessageQueue.waitTimeoutMs", umq.waitTimeoutMs, 1e3, 3e5);
|
|
3429
|
+
}
|
|
3430
|
+
}
|
|
3431
|
+
const cq = patch.concurrencyQueue;
|
|
3432
|
+
if (cq !== void 0) {
|
|
3433
|
+
if (!isPlainObject4(cq)) {
|
|
3434
|
+
errors.push("concurrencyQueue must be an object");
|
|
3435
|
+
} else {
|
|
3436
|
+
checkNum("concurrencyQueue.maxQueueSizeFactor", cq.maxQueueSizeFactor, 1, 10);
|
|
3437
|
+
checkNum("concurrencyQueue.minQueueSize", cq.minQueueSize, 1, 100);
|
|
3438
|
+
checkNum("concurrencyQueue.waitTimeoutMs", cq.waitTimeoutMs, 1e3, 3e5);
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
const ah = patch.accountHealth;
|
|
3442
|
+
if (ah !== void 0) {
|
|
3443
|
+
if (!isPlainObject4(ah)) {
|
|
3444
|
+
errors.push("accountHealth must be an object");
|
|
3445
|
+
} else {
|
|
3446
|
+
if (typeof ah.overloadCooldownEnabled !== "boolean") {
|
|
3447
|
+
errors.push("accountHealth.overloadCooldownEnabled must be a boolean");
|
|
3448
|
+
}
|
|
3449
|
+
checkNum("accountHealth.overloadCooldownMs", ah.overloadCooldownMs, 6e4, 36e5);
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
return errors;
|
|
3453
|
+
}
|
|
2394
3454
|
async function handleServer(req, res, method, deps) {
|
|
2395
3455
|
if (method === "GET") {
|
|
2396
|
-
const config = await (0,
|
|
2397
|
-
|
|
3456
|
+
const config = await (0, import_outbound_api2.loadServerConfig)(deps.settingsStore);
|
|
3457
|
+
let server = config;
|
|
3458
|
+
if (config.proxy) server = { ...server, proxy: redactOutboundProxy(config.proxy) };
|
|
3459
|
+
if (config.webhook) server = { ...server, webhook: redactWebhookConfig(config.webhook) };
|
|
3460
|
+
if (config.billing) server = { ...server, billing: redactBillingConfig(config.billing) };
|
|
3461
|
+
return writeJson2(res, 200, { server });
|
|
2398
3462
|
}
|
|
2399
3463
|
if (method === "PUT") {
|
|
2400
|
-
const patch = await
|
|
2401
|
-
const
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
3464
|
+
const patch = await readJsonBody3(req);
|
|
3465
|
+
const queueErrors = validateQueueSegments(patch);
|
|
3466
|
+
if (queueErrors.length > 0) {
|
|
3467
|
+
return writeJsonError(res, 400, `invalid queue config: ${queueErrors.join("; ")}`);
|
|
3468
|
+
}
|
|
3469
|
+
const webhookErrors = validateWebhookSegment(patch);
|
|
3470
|
+
if (webhookErrors.length > 0) {
|
|
3471
|
+
return writeJsonError(res, 400, `invalid webhook config: ${webhookErrors.join("; ")}`);
|
|
3472
|
+
}
|
|
3473
|
+
const auditErrors = validateAuditSegment(patch);
|
|
3474
|
+
if (auditErrors.length > 0) {
|
|
3475
|
+
return writeJsonError(res, 400, `invalid audit config: ${auditErrors.join("; ")}`);
|
|
3476
|
+
}
|
|
3477
|
+
const billingErrors = validateBillingSegment(patch);
|
|
3478
|
+
if (billingErrors.length > 0) {
|
|
3479
|
+
return writeJsonError(res, 400, `invalid billing config: ${billingErrors.join("; ")}`);
|
|
3480
|
+
}
|
|
3481
|
+
const current = await (0, import_outbound_api2.loadServerConfig)(deps.settingsStore);
|
|
3482
|
+
let effectivePatch = patch;
|
|
3483
|
+
if (patch.proxy) {
|
|
3484
|
+
effectivePatch = { ...effectivePatch, proxy: preserveOutboundProxySecrets(patch.proxy, current.proxy) };
|
|
3485
|
+
}
|
|
3486
|
+
if (patch.webhook) {
|
|
3487
|
+
effectivePatch = { ...effectivePatch, webhook: preserveWebhookSecrets(patch.webhook, current.webhook) };
|
|
3488
|
+
}
|
|
3489
|
+
if (patch.billing) {
|
|
3490
|
+
effectivePatch = { ...effectivePatch, billing: preserveBillingSecret(patch.billing, current.billing) };
|
|
3491
|
+
}
|
|
3492
|
+
const merged = (0, import_outbound_api2.mergeServerConfig)(current, effectivePatch);
|
|
3493
|
+
await (0, import_outbound_api2.saveServerConfig)(deps.settingsStore, merged);
|
|
3494
|
+
setServerProxyConfig(merged.proxy);
|
|
3495
|
+
applyWebhookConfig(merged.webhook);
|
|
3496
|
+
applyAuditConfig(merged.audit);
|
|
3497
|
+
applyBillingConfig(merged.billing);
|
|
3498
|
+
if (merged.enabled) {
|
|
3499
|
+
const missing = (0, import_outbound_api2.validateServerModelConfig)(merged);
|
|
3500
|
+
if (missing.length > 0) {
|
|
3501
|
+
if (deps.outboundApiServer.getStatus().running) {
|
|
3502
|
+
await deps.outboundApiServer.stop();
|
|
3503
|
+
}
|
|
3504
|
+
return writeJson2(res, 200, {
|
|
3505
|
+
server: merged,
|
|
3506
|
+
error: { code: "incomplete-model-config", missing }
|
|
3507
|
+
});
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
try {
|
|
3511
|
+
await deps.outboundApiServer.applyConfig({
|
|
3512
|
+
enabled: merged.enabled,
|
|
3513
|
+
networkBinding: merged.networkBinding,
|
|
3514
|
+
endpoints: merged.endpoints,
|
|
3515
|
+
port: merged.port,
|
|
3516
|
+
userMessageQueue: merged.userMessageQueue,
|
|
3517
|
+
concurrencyQueue: merged.concurrencyQueue,
|
|
3518
|
+
// voucher-redemption #9: hot-apply the voucher flag so enabling the product
|
|
3519
|
+
// takes effect without a restart.
|
|
3520
|
+
voucher: merged.voucher
|
|
3521
|
+
});
|
|
3522
|
+
} catch (err5) {
|
|
3523
|
+
const missing = incompleteConfigMissing(err5);
|
|
3524
|
+
if (missing) {
|
|
3525
|
+
return writeJson2(res, 200, {
|
|
3526
|
+
server: merged,
|
|
3527
|
+
error: { code: "incomplete-model-config", missing }
|
|
3528
|
+
});
|
|
3529
|
+
}
|
|
3530
|
+
throw err5;
|
|
3531
|
+
}
|
|
3532
|
+
return writeJson2(res, 200, { server: merged });
|
|
2411
3533
|
}
|
|
2412
3534
|
return writeJsonError(res, 405, `method ${method} not allowed on server`);
|
|
2413
3535
|
}
|
|
3536
|
+
function incompleteConfigMissing(err5) {
|
|
3537
|
+
if (typeof err5 !== "object" || err5 === null) return null;
|
|
3538
|
+
const missing = err5.missing;
|
|
3539
|
+
return Array.isArray(missing) ? missing : null;
|
|
3540
|
+
}
|
|
2414
3541
|
async function handleAccounts(req, res, method, rest, deps) {
|
|
2415
3542
|
if (method === "GET" && rest.length === 0) {
|
|
2416
3543
|
const accounts = await deps.subscriptionAccounts.listAll();
|
|
2417
3544
|
const providerAccounts = await deps.subscriptionTokenWriter.listSanitizedAccounts();
|
|
2418
3545
|
const externalCli = await deps.subscriptionTokenWriter.listExternalCliAvailability();
|
|
2419
|
-
return
|
|
3546
|
+
return writeJson2(res, 200, { accounts, providerAccounts, externalCli });
|
|
2420
3547
|
}
|
|
2421
3548
|
if (method === "GET" && rest[0] === "codex" && rest[1] === "oauth" && rest[3] === "status") {
|
|
2422
3549
|
const result = handleCodexOAuthStatus(rest[2], deps);
|
|
2423
|
-
return
|
|
3550
|
+
return writeJson2(res, result.status, result.body);
|
|
3551
|
+
}
|
|
3552
|
+
if (method === "DELETE" && rest[0] === "codex" && rest[1] === "oauth" && rest[2]) {
|
|
3553
|
+
const result = handleCodexOAuthCancel(rest[2], deps);
|
|
3554
|
+
return writeJson2(res, result.status, result.body);
|
|
2424
3555
|
}
|
|
2425
3556
|
if (method === "PUT" || method === "POST" || method === "DELETE") {
|
|
2426
3557
|
const providerId = asSubscriptionProviderId(rest[0]);
|
|
@@ -2429,15 +3560,15 @@ async function handleAccounts(req, res, method, rest, deps) {
|
|
|
2429
3560
|
}
|
|
2430
3561
|
if (method === "POST" && rest[1] === "oauth" && rest[2] === "start") {
|
|
2431
3562
|
const result = providerId === "codex" ? handleCodexOAuthStart(deps) : handleOAuthStart(providerId, deps);
|
|
2432
|
-
return
|
|
3563
|
+
return writeJson2(res, result.status, result.body);
|
|
2433
3564
|
}
|
|
2434
3565
|
if (method === "POST" && rest[1] === "oauth" && rest[2] === "complete") {
|
|
2435
|
-
const body2 = await
|
|
3566
|
+
const body2 = await readJsonBody3(req);
|
|
2436
3567
|
const result = await handleOAuthComplete(providerId, body2, deps);
|
|
2437
|
-
return
|
|
3568
|
+
return writeJson2(res, result.status, result.body);
|
|
2438
3569
|
}
|
|
2439
3570
|
if (method === "POST" && rest[1] === "accounts") {
|
|
2440
|
-
const body2 = await
|
|
3571
|
+
const body2 = await readJsonBody3(req);
|
|
2441
3572
|
const block = validateTokenBody(providerId, body2);
|
|
2442
3573
|
if (!block) {
|
|
2443
3574
|
return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
|
|
@@ -2445,79 +3576,113 @@ async function handleAccounts(req, res, method, rest, deps) {
|
|
|
2445
3576
|
const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
|
|
2446
3577
|
await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block, label);
|
|
2447
3578
|
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2448
|
-
return
|
|
3579
|
+
return writeJson2(res, 200, status2 ? { account: status2 } : { ok: true });
|
|
2449
3580
|
}
|
|
2450
3581
|
if (method === "POST" && rest[1] === "import-external") {
|
|
2451
3582
|
if (providerId !== "claude" && providerId !== "codex") {
|
|
2452
3583
|
return writeJsonError(res, 400, `provider '${providerId}' has no external CLI store`);
|
|
2453
3584
|
}
|
|
2454
|
-
const body2 = await
|
|
3585
|
+
const body2 = await readJsonBody3(req);
|
|
2455
3586
|
const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
|
|
2456
3587
|
const result = await deps.subscriptionTokenWriter.importExternalCliAccount(providerId, label);
|
|
2457
3588
|
if (!result.ok) {
|
|
2458
3589
|
return writeJsonError(res, 409, `no usable external ${providerId} CLI credential found`);
|
|
2459
3590
|
}
|
|
2460
3591
|
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2461
|
-
return
|
|
3592
|
+
return writeJson2(res, 200, { ok: true, account: status2 ?? void 0 });
|
|
2462
3593
|
}
|
|
2463
3594
|
if (method === "POST" && rest[1] === "refresh") {
|
|
2464
3595
|
if (providerId === "opencodego") {
|
|
2465
3596
|
return writeJsonError(res, 400, "opencodego credentials are not refreshable");
|
|
2466
3597
|
}
|
|
2467
|
-
const
|
|
2468
|
-
const ok = providerId === "claude" ? await
|
|
3598
|
+
const writer2 = deps.subscriptionTokenWriter;
|
|
3599
|
+
const ok = providerId === "claude" ? await writer2.refreshClaudeToken() : providerId === "codex" ? await writer2.refreshCodexToken() : await writer2.refreshGeminiToken();
|
|
2469
3600
|
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2470
|
-
return
|
|
3601
|
+
return writeJson2(res, 200, { ok, account: status2 ?? void 0 });
|
|
2471
3602
|
}
|
|
2472
3603
|
if (method === "POST" && rest[2] === "label") {
|
|
2473
3604
|
const accountId = rest[1];
|
|
2474
|
-
const body2 = await
|
|
3605
|
+
const body2 = await readJsonBody3(req);
|
|
2475
3606
|
const label = typeof body2["label"] === "string" ? body2["label"].trim() : "";
|
|
2476
3607
|
const result = await deps.subscriptionTokenWriter.renameAccount(providerId, accountId, label);
|
|
2477
3608
|
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
2478
|
-
return
|
|
3609
|
+
return writeJson2(res, 200, { ok: true });
|
|
3610
|
+
}
|
|
3611
|
+
if (method === "POST" && rest[2] === "priority") {
|
|
3612
|
+
const accountId = rest[1];
|
|
3613
|
+
const body2 = await readJsonBody3(req);
|
|
3614
|
+
const raw = body2["priority"];
|
|
3615
|
+
const priority = typeof raw === "number" ? raw : Number(raw);
|
|
3616
|
+
if (!Number.isFinite(priority)) {
|
|
3617
|
+
return writeJsonError(res, 400, "priority must be a finite number");
|
|
3618
|
+
}
|
|
3619
|
+
const result = await deps.subscriptionTokenWriter.setAccountPriority(providerId, accountId, priority);
|
|
3620
|
+
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
3621
|
+
return writeJson2(res, 200, { ok: true });
|
|
3622
|
+
}
|
|
3623
|
+
if (method === "POST" && rest[2] === "proxy") {
|
|
3624
|
+
const accountId = rest[1];
|
|
3625
|
+
const body2 = await readJsonBody3(req);
|
|
3626
|
+
const rawProxy = body2["proxy"];
|
|
3627
|
+
let proxy;
|
|
3628
|
+
if (rawProxy !== null && rawProxy !== void 0) {
|
|
3629
|
+
proxy = (0, import_outbound_api2.normalizeProxyConfig)(rawProxy);
|
|
3630
|
+
if (!proxy) return writeJsonError(res, 400, "invalid proxy config");
|
|
3631
|
+
}
|
|
3632
|
+
const result = await deps.subscriptionTokenWriter.setAccountProxy(providerId, accountId, proxy);
|
|
3633
|
+
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
3634
|
+
return writeJson2(res, 200, { ok: true });
|
|
3635
|
+
}
|
|
3636
|
+
if (method === "POST" && rest[2] === "supported-models") {
|
|
3637
|
+
const accountId = rest[1];
|
|
3638
|
+
const body2 = await readJsonBody3(req);
|
|
3639
|
+
const parsed = validateSupportedModelsBody(body2["supportedModels"]);
|
|
3640
|
+
if (!parsed.ok) return writeJsonError(res, 400, "invalid supportedModels");
|
|
3641
|
+
const result = await deps.subscriptionTokenWriter.setAccountSupportedModels(providerId, accountId, parsed.value);
|
|
3642
|
+
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
3643
|
+
return writeJson2(res, 200, { ok: true });
|
|
2479
3644
|
}
|
|
2480
3645
|
if (method === "PUT" && rest[1] === "active") {
|
|
2481
|
-
const body2 = await
|
|
3646
|
+
const body2 = await readJsonBody3(req);
|
|
2482
3647
|
const id = typeof body2["id"] === "string" ? body2["id"] : "";
|
|
2483
3648
|
if (!id) return writeJsonError(res, 400, "active switch requires { id }");
|
|
2484
3649
|
const result = await deps.subscriptionTokenWriter.setActiveAccount(providerId, id);
|
|
2485
3650
|
if (!result.ok) return writeJsonError(res, 404, `account '${id}' not found`);
|
|
2486
|
-
return
|
|
3651
|
+
return writeJson2(res, 200, { ok: true });
|
|
2487
3652
|
}
|
|
2488
3653
|
if (method === "DELETE" && rest.length >= 2) {
|
|
2489
3654
|
const accountId = rest[1];
|
|
2490
3655
|
const result = await deps.subscriptionTokenWriter.removeAccount(providerId, accountId);
|
|
2491
3656
|
if (!result.removed) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
2492
|
-
return
|
|
3657
|
+
return writeJson2(res, 200, { ok: true });
|
|
2493
3658
|
}
|
|
2494
3659
|
if (method === "DELETE") {
|
|
2495
3660
|
await deps.subscriptionTokenWriter.clearProvider(providerId);
|
|
2496
|
-
return
|
|
3661
|
+
return writeJson2(res, 200, { ok: true });
|
|
2497
3662
|
}
|
|
2498
|
-
const body = await
|
|
3663
|
+
const body = await readJsonBody3(req);
|
|
2499
3664
|
const config = validateTokenBody(providerId, body);
|
|
2500
3665
|
if (!config) {
|
|
2501
3666
|
return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
|
|
2502
3667
|
}
|
|
2503
3668
|
await deps.subscriptionTokenWriter.writeProviderTokens(providerId, config);
|
|
2504
3669
|
const status = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2505
|
-
return
|
|
3670
|
+
return writeJson2(res, 200, status ? { account: status } : { ok: true });
|
|
2506
3671
|
}
|
|
2507
3672
|
return writeJsonError(res, 405, `method ${method} not allowed on accounts`);
|
|
2508
3673
|
}
|
|
2509
3674
|
async function handleCli(req, res, method, rest, deps) {
|
|
2510
3675
|
if (method === "GET" && rest.length === 0) {
|
|
2511
3676
|
const result = handleCliList(process.platform, deps.cliPathProbe);
|
|
2512
|
-
return
|
|
3677
|
+
return writeJson2(res, result.status, result.body);
|
|
2513
3678
|
}
|
|
2514
3679
|
if (method === "GET" && rest[0] === "sessions") {
|
|
2515
3680
|
const result = handleCliSessions();
|
|
2516
|
-
return
|
|
3681
|
+
return writeJson2(res, result.status, result.body);
|
|
2517
3682
|
}
|
|
2518
3683
|
if (method === "DELETE" && rest[0] === "sessions" && rest[1]) {
|
|
2519
3684
|
const result = handleCliStop(rest[1]);
|
|
2520
|
-
return
|
|
3685
|
+
return writeJson2(res, result.status, result.body);
|
|
2521
3686
|
}
|
|
2522
3687
|
if (method === "POST" && rest[1] === "install") {
|
|
2523
3688
|
const cli = rest[0];
|
|
@@ -2525,14 +3690,14 @@ async function handleCli(req, res, method, rest, deps) {
|
|
|
2525
3690
|
return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
|
|
2526
3691
|
}
|
|
2527
3692
|
const result = await handleCliInstall(cli, deps.cliCommandRunner);
|
|
2528
|
-
return
|
|
3693
|
+
return writeJson2(res, result.status, result.body);
|
|
2529
3694
|
}
|
|
2530
3695
|
if (method === "POST" && rest[1] === "launch") {
|
|
2531
3696
|
const cli = rest[0];
|
|
2532
3697
|
if (!isLaunchCliId(cli)) {
|
|
2533
3698
|
return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
|
|
2534
3699
|
}
|
|
2535
|
-
const body = await
|
|
3700
|
+
const body = await readJsonBody3(req);
|
|
2536
3701
|
const providers = loadConfig(deps.configPath).providers ?? [];
|
|
2537
3702
|
const result = await handleCliLaunch(cli, body, {
|
|
2538
3703
|
llmConfig: deps.llmConfig,
|
|
@@ -2540,20 +3705,28 @@ async function handleCli(req, res, method, rest, deps) {
|
|
|
2540
3705
|
opener: deps.cliTerminalOpener,
|
|
2541
3706
|
probe: deps.cliPathProbe
|
|
2542
3707
|
});
|
|
2543
|
-
return
|
|
3708
|
+
return writeJson2(res, result.status, result.body);
|
|
2544
3709
|
}
|
|
2545
3710
|
return writeJsonError(res, 405, `method ${method} not allowed on cli`);
|
|
2546
3711
|
}
|
|
2547
3712
|
async function handleStatus(res, method, deps) {
|
|
2548
3713
|
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on status`);
|
|
2549
3714
|
const status = deps.outboundApiServer.getStatus();
|
|
2550
|
-
const serverConfig = await (0,
|
|
2551
|
-
const endpoints = serverConfig.endpoints.map((e) =>
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
3715
|
+
const serverConfig = await (0, import_outbound_api2.loadServerConfig)(deps.settingsStore);
|
|
3716
|
+
const endpoints = serverConfig.endpoints.map((e) => {
|
|
3717
|
+
if ((0, import_outbound_api2.isKindMappedEndpoint)(e.endpoint)) {
|
|
3718
|
+
return { endpoint: e.endpoint, kinds: e.modelMap ?? {}, useSubscription: e.useSubscription };
|
|
3719
|
+
}
|
|
3720
|
+
if (e.endpoint === "chat") {
|
|
3721
|
+
return { endpoint: e.endpoint, models: e.models ?? [], useSubscription: e.useSubscription };
|
|
3722
|
+
}
|
|
3723
|
+
return { endpoint: e.endpoint, model: e.defaultModel ?? "", useSubscription: e.useSubscription };
|
|
3724
|
+
});
|
|
3725
|
+
if (status.running) {
|
|
3726
|
+
const queueStatus = deps.outboundApiServer.getQueueStatus();
|
|
3727
|
+
return writeJson2(res, 200, { ...status, endpoints, queueStatus });
|
|
3728
|
+
}
|
|
3729
|
+
return writeJson2(res, 200, { ...status, endpoints });
|
|
2557
3730
|
}
|
|
2558
3731
|
function resolvePlaygroundPath(endpoint, body) {
|
|
2559
3732
|
switch (endpoint) {
|
|
@@ -2573,7 +3746,7 @@ function resolvePlaygroundPath(endpoint, body) {
|
|
|
2573
3746
|
}
|
|
2574
3747
|
async function handlePlayground(req, res, method, deps) {
|
|
2575
3748
|
if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on playground`);
|
|
2576
|
-
const body = await
|
|
3749
|
+
const body = await readJsonBody3(req);
|
|
2577
3750
|
const endpoint = typeof body["endpoint"] === "string" ? body["endpoint"] : "";
|
|
2578
3751
|
const key = typeof body["key"] === "string" ? body["key"] : "";
|
|
2579
3752
|
const payload = body["body"];
|
|
@@ -2719,10 +3892,12 @@ async function handleUiStatic(req, res, urlPath, uiDist) {
|
|
|
2719
3892
|
return true;
|
|
2720
3893
|
}
|
|
2721
3894
|
|
|
3895
|
+
// src/admin/version.ts
|
|
3896
|
+
var DAEMON_VERSION = true ? "0.1.4" : "0.0.0-dev";
|
|
3897
|
+
|
|
2722
3898
|
// src/admin/AdminServer.ts
|
|
2723
3899
|
var LOOPBACK_ADDR = "127.0.0.1";
|
|
2724
3900
|
var LAN_ADDR = "0.0.0.0";
|
|
2725
|
-
var DAEMON_VERSION = true ? "0.1.2" : "0.0.0-dev";
|
|
2726
3901
|
var AdminServer = class {
|
|
2727
3902
|
constructor(deps) {
|
|
2728
3903
|
this.deps = deps;
|
|
@@ -2743,7 +3918,7 @@ var AdminServer = class {
|
|
|
2743
3918
|
const cfg = this.deps.getAdminConfig();
|
|
2744
3919
|
if (!cfg.enabled) return 0;
|
|
2745
3920
|
if (cfg.networkBinding && !cfg.token) {
|
|
2746
|
-
|
|
3921
|
+
this.deps.logger.error(
|
|
2747
3922
|
"[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)."
|
|
2748
3923
|
);
|
|
2749
3924
|
return 0;
|
|
@@ -2752,7 +3927,7 @@ var AdminServer = class {
|
|
|
2752
3927
|
const actualPort = await this.listen(bindAddr, cfg.port);
|
|
2753
3928
|
this.boundAddr = bindAddr;
|
|
2754
3929
|
this.boundPort = actualPort;
|
|
2755
|
-
|
|
3930
|
+
this.deps.logger.info(`[AdminServer] Dashboard listening on ${bindAddr}:${actualPort}`);
|
|
2756
3931
|
return actualPort;
|
|
2757
3932
|
}
|
|
2758
3933
|
/** Bind once; on EADDRINUSE retry with an ephemeral port (port 0). */
|
|
@@ -2774,7 +3949,7 @@ var AdminServer = class {
|
|
|
2774
3949
|
const addr = server.address();
|
|
2775
3950
|
if (addr && typeof addr === "object") {
|
|
2776
3951
|
server.removeListener("error", onError);
|
|
2777
|
-
server.on("error", (e) =>
|
|
3952
|
+
server.on("error", (e) => this.deps.logger.error("[AdminServer] server error", e));
|
|
2778
3953
|
this.server = server;
|
|
2779
3954
|
resolve(addr.port);
|
|
2780
3955
|
} else {
|
|
@@ -2787,7 +3962,7 @@ var AdminServer = class {
|
|
|
2787
3962
|
onRequest(req, res) {
|
|
2788
3963
|
void this.dispatch(req, res).catch((err5) => {
|
|
2789
3964
|
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
2790
|
-
|
|
3965
|
+
this.deps.logger.error("[AdminServer] unhandled error:", message);
|
|
2791
3966
|
if (!res.headersSent) {
|
|
2792
3967
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2793
3968
|
res.end(JSON.stringify({ error: { type: "admin_error", message } }));
|
|
@@ -2798,18 +3973,42 @@ var AdminServer = class {
|
|
|
2798
3973
|
const cfg = this.deps.getAdminConfig();
|
|
2799
3974
|
res.setHeader("x-omnicross-daemon", DAEMON_VERSION);
|
|
2800
3975
|
res.setHeader("x-omnicross-pid", String(process.pid));
|
|
3976
|
+
const url = req.url ?? "/";
|
|
3977
|
+
const path2 = url.split("?")[0];
|
|
3978
|
+
const healthPath = path2.replace(/\/+$/, "") || "/";
|
|
3979
|
+
if ((req.method === "GET" || req.method === "HEAD") && (healthPath === "/health" || healthPath === "/healthz")) {
|
|
3980
|
+
const report = this.deps.getHealthReport();
|
|
3981
|
+
const code = (0, import_health_logging_types.healthHttpStatus)(report.status);
|
|
3982
|
+
res.writeHead(code, { "Content-Type": "application/json" });
|
|
3983
|
+
res.end(req.method === "HEAD" ? void 0 : JSON.stringify(report));
|
|
3984
|
+
return;
|
|
3985
|
+
}
|
|
2801
3986
|
if (cfg.token && !this.isAuthorized(req, cfg.token)) {
|
|
2802
3987
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
2803
3988
|
res.end(JSON.stringify({ error: { type: "unauthorized", message: "admin token required" } }));
|
|
2804
3989
|
return;
|
|
2805
3990
|
}
|
|
2806
|
-
const url = req.url ?? "/";
|
|
2807
|
-
const path2 = url.split("?")[0];
|
|
2808
3991
|
if ((req.method === "GET" || req.method === "HEAD") && (path2 === "/" || path2 === "/admin")) {
|
|
2809
3992
|
res.writeHead(302, { Location: "/ui/" });
|
|
2810
3993
|
res.end();
|
|
2811
3994
|
return;
|
|
2812
3995
|
}
|
|
3996
|
+
if (path2 === "/admin/api/account-probes" && (req.method === "GET" || req.method === "HEAD")) {
|
|
3997
|
+
handleAccountProbes(res, this.deps.probeHistoryReader);
|
|
3998
|
+
return;
|
|
3999
|
+
}
|
|
4000
|
+
if (path2 === "/admin/api/audit" && (req.method === "GET" || req.method === "HEAD")) {
|
|
4001
|
+
handleAuditQuery(req, res, this.deps.auditReader);
|
|
4002
|
+
return;
|
|
4003
|
+
}
|
|
4004
|
+
if (path2 === "/admin/api/billing-status" && (req.method === "GET" || req.method === "HEAD")) {
|
|
4005
|
+
handleBillingStatus(res, this.deps.billingStatusReader);
|
|
4006
|
+
return;
|
|
4007
|
+
}
|
|
4008
|
+
if (path2 === "/admin/api/webhook-test" && req.method === "POST") {
|
|
4009
|
+
await handleWebhookTest(req, res);
|
|
4010
|
+
return;
|
|
4011
|
+
}
|
|
2813
4012
|
if (path2.startsWith("/admin/api/")) {
|
|
2814
4013
|
await handleAdminApi(req, res, path2, this.deps);
|
|
2815
4014
|
return;
|
|
@@ -2853,6 +4052,51 @@ function constantTimeEquals(a, b) {
|
|
|
2853
4052
|
return (0, import_node_crypto7.timingSafeEqual)(bufA, bufB);
|
|
2854
4053
|
}
|
|
2855
4054
|
|
|
4055
|
+
// src/admin/health.ts
|
|
4056
|
+
var CRITICAL_CHECKS = ["config", "credentialStore"];
|
|
4057
|
+
var READINESS_CHECKS = ["outboundServer"];
|
|
4058
|
+
function safeBool(fn) {
|
|
4059
|
+
try {
|
|
4060
|
+
return fn() === true;
|
|
4061
|
+
} catch {
|
|
4062
|
+
return false;
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
4065
|
+
function toMb(bytes) {
|
|
4066
|
+
return Math.round(bytes / (1024 * 1024) * 10) / 10;
|
|
4067
|
+
}
|
|
4068
|
+
function buildHealthReport(deps) {
|
|
4069
|
+
const checks = {
|
|
4070
|
+
config: safeBool(deps.configPresent),
|
|
4071
|
+
credentialStore: safeBool(deps.credentialStoreReadable),
|
|
4072
|
+
outboundServer: safeBool(deps.outboundServerRunning),
|
|
4073
|
+
adminServer: safeBool(deps.adminServerRunning)
|
|
4074
|
+
};
|
|
4075
|
+
if (deps.subscriptionAccountsHealthy) {
|
|
4076
|
+
let probeHealthy;
|
|
4077
|
+
try {
|
|
4078
|
+
probeHealthy = deps.subscriptionAccountsHealthy();
|
|
4079
|
+
} catch {
|
|
4080
|
+
probeHealthy = false;
|
|
4081
|
+
}
|
|
4082
|
+
if (probeHealthy !== void 0) checks.subscriptionAccountsHealthy = probeHealthy;
|
|
4083
|
+
}
|
|
4084
|
+
const criticalOk = CRITICAL_CHECKS.every((k) => checks[k]);
|
|
4085
|
+
const readinessOk = READINESS_CHECKS.every((k) => checks[k]);
|
|
4086
|
+
const status = !criticalOk ? "error" : readinessOk ? "ok" : "degraded";
|
|
4087
|
+
const mem = (deps.memoryUsage ?? process.memoryUsage)();
|
|
4088
|
+
const uptime = (deps.uptimeSeconds ?? process.uptime)();
|
|
4089
|
+
const nowMs = (deps.now ?? Date.now)();
|
|
4090
|
+
return {
|
|
4091
|
+
status,
|
|
4092
|
+
version: deps.version,
|
|
4093
|
+
uptimeSeconds: Math.floor(uptime),
|
|
4094
|
+
timestamp: new Date(nowMs).toISOString(),
|
|
4095
|
+
memory: { rssMb: toMb(mem.rss), heapUsedMb: toMb(mem.heapUsed) },
|
|
4096
|
+
checks
|
|
4097
|
+
};
|
|
4098
|
+
}
|
|
4099
|
+
|
|
2856
4100
|
// src/admin/oauthSessions.ts
|
|
2857
4101
|
var import_node_crypto8 = __toESM(require("crypto"), 1);
|
|
2858
4102
|
var DEFAULT_OAUTH_SESSION_TTL_MS = 10 * 60 * 1e3;
|
|
@@ -2904,7 +4148,7 @@ var DEFAULT_TIMEOUT_MS = 5 * 6e4;
|
|
|
2904
4148
|
function pageHtml(message) {
|
|
2905
4149
|
return `<!doctype html><meta charset="utf-8"><title>omnicross login</title><body style="font-family:sans-serif;padding:2rem"><h2>${message}</h2><p>You can close this window and return to the terminal.</p></body>`;
|
|
2906
4150
|
}
|
|
2907
|
-
function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
4151
|
+
function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS, signal) {
|
|
2908
4152
|
return new Promise((resolve, reject) => {
|
|
2909
4153
|
let settled = false;
|
|
2910
4154
|
const finish = (server2, fn) => {
|
|
@@ -2938,6 +4182,12 @@ function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
2938
4182
|
res.end(pageHtml("Login complete."));
|
|
2939
4183
|
finish(server, () => resolve(code));
|
|
2940
4184
|
});
|
|
4185
|
+
const abort = () => finish(server, () => reject(new Error("login: cancelled")));
|
|
4186
|
+
if (signal?.aborted) {
|
|
4187
|
+
abort();
|
|
4188
|
+
return;
|
|
4189
|
+
}
|
|
4190
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
2941
4191
|
server.on("error", (err5) => {
|
|
2942
4192
|
if (settled) return;
|
|
2943
4193
|
settled = true;
|
|
@@ -3026,12 +4276,21 @@ function createPoolKeysLoader(getProviderRow, autoDisabled) {
|
|
|
3026
4276
|
|
|
3027
4277
|
// src/commands/paths.ts
|
|
3028
4278
|
var import_node_path4 = require("path");
|
|
4279
|
+
function defaultVouchersPath(configPath) {
|
|
4280
|
+
return (0, import_node_path4.join)((0, import_node_path4.dirname)(configPath), "vouchers.json");
|
|
4281
|
+
}
|
|
3029
4282
|
function defaultPricingPath(configPath) {
|
|
3030
4283
|
return (0, import_node_path4.join)((0, import_node_path4.dirname)(configPath), "pricing.json");
|
|
3031
4284
|
}
|
|
3032
4285
|
function defaultUsageEventsPath(configPath) {
|
|
3033
4286
|
return (0, import_node_path4.join)((0, import_node_path4.dirname)(configPath), "usage-events.jsonl");
|
|
3034
4287
|
}
|
|
4288
|
+
function defaultAuditDir(configPath) {
|
|
4289
|
+
return (0, import_node_path4.join)((0, import_node_path4.dirname)(configPath), "audit");
|
|
4290
|
+
}
|
|
4291
|
+
function defaultBillingDir(configPath) {
|
|
4292
|
+
return (0, import_node_path4.join)((0, import_node_path4.dirname)(configPath), "billing");
|
|
4293
|
+
}
|
|
3035
4294
|
|
|
3036
4295
|
// src/ports/ConfigFileProviderConfigSource.ts
|
|
3037
4296
|
var import_core = require("@omnicross/core");
|
|
@@ -3190,50 +4449,203 @@ function toLLMProvider(row) {
|
|
|
3190
4449
|
};
|
|
3191
4450
|
}
|
|
3192
4451
|
|
|
3193
|
-
// src/ports/
|
|
3194
|
-
var
|
|
4452
|
+
// src/ports/ConfigurableLogger.ts
|
|
4453
|
+
var import_node_fs5 = require("fs");
|
|
4454
|
+
var LEVEL_ORDER = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
4455
|
+
var RESERVED_JSON_KEYS = /* @__PURE__ */ new Set(["ts", "level", "msg", "error"]);
|
|
4456
|
+
var ConfigurableLogger = class {
|
|
4457
|
+
threshold;
|
|
4458
|
+
format;
|
|
4459
|
+
filePath;
|
|
4460
|
+
fileStream = null;
|
|
4461
|
+
fileDisabled = false;
|
|
4462
|
+
constructor(cfg) {
|
|
4463
|
+
this.threshold = LEVEL_ORDER[cfg?.level ?? "debug"];
|
|
4464
|
+
this.format = cfg?.format ?? "text";
|
|
4465
|
+
this.filePath = cfg?.file && cfg.file.length > 0 ? cfg.file : void 0;
|
|
4466
|
+
}
|
|
3195
4467
|
info(message, meta) {
|
|
3196
|
-
|
|
3197
|
-
else console.info(message, meta);
|
|
4468
|
+
this.emit("info", message, void 0, meta);
|
|
3198
4469
|
}
|
|
3199
4470
|
warn(message, meta) {
|
|
3200
|
-
|
|
3201
|
-
else console.warn(message, meta);
|
|
4471
|
+
this.emit("warn", message, void 0, meta);
|
|
3202
4472
|
}
|
|
3203
4473
|
error(message, error, meta) {
|
|
3204
|
-
|
|
3205
|
-
else if (meta === void 0) console.error(message, error);
|
|
3206
|
-
else console.error(message, error, meta);
|
|
4474
|
+
this.emit("error", message, error, meta);
|
|
3207
4475
|
}
|
|
3208
4476
|
debug(message, meta) {
|
|
3209
|
-
|
|
3210
|
-
|
|
4477
|
+
this.emit("debug", message, void 0, meta);
|
|
4478
|
+
}
|
|
4479
|
+
/**
|
|
4480
|
+
* Flush + close the file sink (tests / graceful shutdown). Resolves once the
|
|
4481
|
+
* append stream has finished flushing to disk. No-op when no file sink is open.
|
|
4482
|
+
*/
|
|
4483
|
+
close() {
|
|
4484
|
+
const stream = this.fileStream;
|
|
4485
|
+
this.fileStream = null;
|
|
4486
|
+
if (!stream) return Promise.resolve();
|
|
4487
|
+
return new Promise((resolve) => stream.end(() => resolve()));
|
|
4488
|
+
}
|
|
4489
|
+
emit(level, message, error, meta) {
|
|
4490
|
+
if (LEVEL_ORDER[level] > this.threshold) return;
|
|
4491
|
+
this.writeConsole(level, message, error, meta);
|
|
4492
|
+
if (this.filePath) this.writeFile(level, message, error, meta);
|
|
4493
|
+
}
|
|
4494
|
+
/**
|
|
4495
|
+
* Console sink. In `text` format this reproduces the legacy `ConsoleLogger`
|
|
4496
|
+
* EXACTLY (same method + arg shape) so the unconfigured default is a byte-for-
|
|
4497
|
+
* byte drop-in; in `json` format it prints the structured line.
|
|
4498
|
+
*/
|
|
4499
|
+
writeConsole(level, message, error, meta) {
|
|
4500
|
+
if (this.format === "json") {
|
|
4501
|
+
this.consoleFn(level)(this.jsonLine(level, message, error, meta));
|
|
4502
|
+
return;
|
|
4503
|
+
}
|
|
4504
|
+
if (level === "error") {
|
|
4505
|
+
if (error === void 0 && meta === void 0) console.error(message);
|
|
4506
|
+
else if (meta === void 0) console.error(message, error);
|
|
4507
|
+
else console.error(message, error, meta);
|
|
4508
|
+
return;
|
|
4509
|
+
}
|
|
4510
|
+
const fn = this.consoleFn(level);
|
|
4511
|
+
if (meta === void 0) fn(message);
|
|
4512
|
+
else fn(message, meta);
|
|
4513
|
+
}
|
|
4514
|
+
/** Append one line to the file sink; a failure disables the sink (swallowed). */
|
|
4515
|
+
writeFile(level, message, error, meta) {
|
|
4516
|
+
const stream = this.getFileStream();
|
|
4517
|
+
if (!stream) return;
|
|
4518
|
+
try {
|
|
4519
|
+
const line = this.format === "json" ? this.jsonLine(level, message, error, meta) : this.textLine(level, message, error, meta);
|
|
4520
|
+
stream.write(line + "\n");
|
|
4521
|
+
} catch {
|
|
4522
|
+
}
|
|
4523
|
+
}
|
|
4524
|
+
/** Lazily open the append-only file stream; disable the sink on any error. */
|
|
4525
|
+
getFileStream() {
|
|
4526
|
+
if (this.fileDisabled || !this.filePath) return null;
|
|
4527
|
+
if (this.fileStream) return this.fileStream;
|
|
4528
|
+
try {
|
|
4529
|
+
const stream = (0, import_node_fs5.createWriteStream)(this.filePath, { flags: "a" });
|
|
4530
|
+
stream.on("error", () => {
|
|
4531
|
+
this.fileDisabled = true;
|
|
4532
|
+
this.fileStream = null;
|
|
4533
|
+
});
|
|
4534
|
+
this.fileStream = stream;
|
|
4535
|
+
return stream;
|
|
4536
|
+
} catch {
|
|
4537
|
+
this.fileDisabled = true;
|
|
4538
|
+
return null;
|
|
4539
|
+
}
|
|
4540
|
+
}
|
|
4541
|
+
consoleFn(level) {
|
|
4542
|
+
switch (level) {
|
|
4543
|
+
case "error":
|
|
4544
|
+
return console.error;
|
|
4545
|
+
case "warn":
|
|
4546
|
+
return console.warn;
|
|
4547
|
+
case "info":
|
|
4548
|
+
return console.info;
|
|
4549
|
+
case "debug":
|
|
4550
|
+
return console.debug;
|
|
4551
|
+
}
|
|
4552
|
+
}
|
|
4553
|
+
/** `{ ts, level, msg, ...meta }` (+ `error` when present) as a single line. */
|
|
4554
|
+
jsonLine(level, message, error, meta) {
|
|
4555
|
+
const obj = {
|
|
4556
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4557
|
+
level,
|
|
4558
|
+
msg: message
|
|
4559
|
+
};
|
|
4560
|
+
if (error !== void 0) obj["error"] = reduceError(error);
|
|
4561
|
+
if (meta !== void 0) {
|
|
4562
|
+
if (meta instanceof Error) obj["meta"] = reduceError(meta);
|
|
4563
|
+
else if (meta && typeof meta === "object") {
|
|
4564
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
4565
|
+
if (!RESERVED_JSON_KEYS.has(k)) obj[k] = v;
|
|
4566
|
+
}
|
|
4567
|
+
} else obj["meta"] = meta;
|
|
4568
|
+
}
|
|
4569
|
+
try {
|
|
4570
|
+
return JSON.stringify(obj);
|
|
4571
|
+
} catch {
|
|
4572
|
+
return JSON.stringify({ ts: obj["ts"], level, msg: message });
|
|
4573
|
+
}
|
|
4574
|
+
}
|
|
4575
|
+
/** Human-readable file line: `ISO [level] message {metaJson}`. */
|
|
4576
|
+
textLine(level, message, error, meta) {
|
|
4577
|
+
const parts = [`${(/* @__PURE__ */ new Date()).toISOString()} [${level}] ${message}`];
|
|
4578
|
+
if (error !== void 0) parts.push(safeStringify(reduceError(error)));
|
|
4579
|
+
if (meta !== void 0) parts.push(safeStringify(meta instanceof Error ? reduceError(meta) : meta));
|
|
4580
|
+
return parts.join(" ");
|
|
3211
4581
|
}
|
|
3212
4582
|
};
|
|
4583
|
+
function reduceError(error) {
|
|
4584
|
+
if (error instanceof Error) {
|
|
4585
|
+
return error.stack ? { message: error.message, stack: error.stack } : { message: error.message };
|
|
4586
|
+
}
|
|
4587
|
+
return { value: String(error) };
|
|
4588
|
+
}
|
|
4589
|
+
function safeStringify(value) {
|
|
4590
|
+
try {
|
|
4591
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
4592
|
+
} catch {
|
|
4593
|
+
return "[unserializable]";
|
|
4594
|
+
}
|
|
4595
|
+
}
|
|
3213
4596
|
|
|
3214
4597
|
// src/ports/JsonApiServerSettingsStore.ts
|
|
3215
|
-
var
|
|
3216
|
-
var
|
|
4598
|
+
var import_node_fs6 = require("fs");
|
|
4599
|
+
var import_outbound_api3 = require("@omnicross/core/outbound-api");
|
|
3217
4600
|
var JsonApiServerSettingsStore = class {
|
|
3218
|
-
|
|
4601
|
+
/**
|
|
4602
|
+
* @param configPath the daemon config.json whose `server` field is backed.
|
|
4603
|
+
* @param box OPTIONAL at-rest `SecretBox` (upstream-proxy). When set, the
|
|
4604
|
+
* `server.proxy.*` passwords are encrypted-on-`set` /
|
|
4605
|
+
* decrypted-on-`get` (the settings-store path is otherwise not
|
|
4606
|
+
* secret-aware — every OTHER server field is non-secret). Null
|
|
4607
|
+
* ⇒ passthrough (legacy/pure tests unchanged).
|
|
4608
|
+
*/
|
|
4609
|
+
constructor(configPath, box = null) {
|
|
3219
4610
|
this.configPath = configPath;
|
|
4611
|
+
this.box = box;
|
|
3220
4612
|
}
|
|
3221
4613
|
configPath;
|
|
4614
|
+
box;
|
|
3222
4615
|
async get(key) {
|
|
3223
|
-
if (key !==
|
|
4616
|
+
if (key !== import_outbound_api3.OUTBOUND_API_SERVER_CONFIG_KEY) return void 0;
|
|
3224
4617
|
const file = this.readFile();
|
|
3225
|
-
|
|
4618
|
+
if (file.server === void 0) return void 0;
|
|
4619
|
+
return this.decryptSecrets(file.server);
|
|
3226
4620
|
}
|
|
3227
4621
|
async set(key, value) {
|
|
3228
|
-
if (key !==
|
|
4622
|
+
if (key !== import_outbound_api3.OUTBOUND_API_SERVER_CONFIG_KEY) return;
|
|
3229
4623
|
const file = this.readFile();
|
|
3230
|
-
file.server = value;
|
|
3231
|
-
(0,
|
|
4624
|
+
file.server = this.encryptSecrets(value);
|
|
4625
|
+
(0, import_node_fs6.writeFileSync)(this.configPath, JSON.stringify(file, null, 2) + "\n", "utf8");
|
|
4626
|
+
}
|
|
4627
|
+
/** Encrypt the proxy passwords + webhook + billing secrets before persisting (no-op without a box). */
|
|
4628
|
+
encryptSecrets(config) {
|
|
4629
|
+
if (!this.box) return config;
|
|
4630
|
+
let out = config;
|
|
4631
|
+
if (out?.proxy) out = { ...out, proxy: encryptProxySegment(out.proxy, this.box) };
|
|
4632
|
+
if (out?.webhook) out = { ...out, webhook: encryptWebhookSegment(out.webhook, this.box) };
|
|
4633
|
+
if (out?.billing) out = { ...out, billing: encryptBillingSegment(out.billing, this.box) };
|
|
4634
|
+
return out;
|
|
4635
|
+
}
|
|
4636
|
+
/** Decrypt the proxy passwords + webhook + billing secrets on read (no-op without a box). */
|
|
4637
|
+
decryptSecrets(config) {
|
|
4638
|
+
if (!this.box) return config;
|
|
4639
|
+
let out = config;
|
|
4640
|
+
if (out?.proxy) out = { ...out, proxy: decryptProxySegment(out.proxy, this.box) };
|
|
4641
|
+
if (out?.webhook) out = { ...out, webhook: decryptWebhookSegment(out.webhook, this.box) };
|
|
4642
|
+
if (out?.billing) out = { ...out, billing: decryptBillingSegment(out.billing, this.box) };
|
|
4643
|
+
return out;
|
|
3232
4644
|
}
|
|
3233
4645
|
/** Read the config.json, tolerating a missing/corrupt file (→ empty shape). */
|
|
3234
4646
|
readFile() {
|
|
3235
4647
|
try {
|
|
3236
|
-
const raw = (0,
|
|
4648
|
+
const raw = (0, import_node_fs6.readFileSync)(this.configPath, "utf8");
|
|
3237
4649
|
const parsed = JSON.parse(raw);
|
|
3238
4650
|
if (parsed && typeof parsed === "object") return parsed;
|
|
3239
4651
|
} catch {
|
|
@@ -3244,7 +4656,7 @@ var JsonApiServerSettingsStore = class {
|
|
|
3244
4656
|
|
|
3245
4657
|
// src/ports/JsonlUsageEventStore.ts
|
|
3246
4658
|
var import_node_crypto9 = require("crypto");
|
|
3247
|
-
var
|
|
4659
|
+
var import_node_fs7 = require("fs");
|
|
3248
4660
|
var JsonlUsageEventStore = class {
|
|
3249
4661
|
constructor(eventsPath, isPriced) {
|
|
3250
4662
|
this.eventsPath = eventsPath;
|
|
@@ -3259,7 +4671,7 @@ var JsonlUsageEventStore = class {
|
|
|
3259
4671
|
id: (0, import_node_crypto9.randomUUID)(),
|
|
3260
4672
|
ts: input.ts ?? Date.now()
|
|
3261
4673
|
};
|
|
3262
|
-
(0,
|
|
4674
|
+
(0, import_node_fs7.appendFileSync)(this.eventsPath, JSON.stringify(row) + "\n", "utf8");
|
|
3263
4675
|
return row.id;
|
|
3264
4676
|
}
|
|
3265
4677
|
async getTotals(range) {
|
|
@@ -3348,6 +4760,57 @@ var JsonlUsageEventStore = class {
|
|
|
3348
4760
|
}
|
|
3349
4761
|
return Array.from(groups.values());
|
|
3350
4762
|
}
|
|
4763
|
+
/**
|
|
4764
|
+
* ONE pass over a single key's events (`ts < endTs`) summing its `costUsd` into
|
|
4765
|
+
* `totalUsd` / `dailyUsd` (`ts >= dayStartTs`) / `weeklyUsd` (`ts >= weekStartTs`).
|
|
4766
|
+
* Used to lazily seed the outbound key-policy spend tracker (once per key). A
|
|
4767
|
+
* key with no attributed events yields all zeros.
|
|
4768
|
+
*/
|
|
4769
|
+
async getSpendByKey(query) {
|
|
4770
|
+
let totalUsd = 0;
|
|
4771
|
+
let dailyUsd = 0;
|
|
4772
|
+
let weeklyUsd = 0;
|
|
4773
|
+
for (const row of this.readRows({ startTs: 0, endTs: query.endTs })) {
|
|
4774
|
+
if (row.apiKeyId !== query.apiKeyId) continue;
|
|
4775
|
+
totalUsd += row.costUsd;
|
|
4776
|
+
if (row.ts >= query.dayStartTs) dailyUsd += row.costUsd;
|
|
4777
|
+
if (row.ts >= query.weekStartTs) weeklyUsd += row.costUsd;
|
|
4778
|
+
}
|
|
4779
|
+
return { totalUsd, dailyUsd, weeklyUsd };
|
|
4780
|
+
}
|
|
4781
|
+
/**
|
|
4782
|
+
* Time-series aggregation over LOCAL-time bucket boundaries. Every bucket in
|
|
4783
|
+
* `[floor(startTs), endTs)` is present (empty ones zero-filled), ascending by
|
|
4784
|
+
* `bucketStartTs`; an empty range (`startTs >= endTs`) returns `[]`. Reuses
|
|
4785
|
+
* `readRows` so malformed lines are skipped and only in-range rows contribute.
|
|
4786
|
+
*/
|
|
4787
|
+
async getTimeSeries(range, bucket) {
|
|
4788
|
+
if (range.startTs >= range.endTs) return [];
|
|
4789
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
4790
|
+
for (let b = floorToBucket(range.startTs, bucket); b < range.endTs; b = nextBoundary(b, bucket)) {
|
|
4791
|
+
buckets.set(b, {
|
|
4792
|
+
bucketStartTs: b,
|
|
4793
|
+
label: bucketLabel(b, bucket),
|
|
4794
|
+
requests: 0,
|
|
4795
|
+
inputTokens: 0,
|
|
4796
|
+
outputTokens: 0,
|
|
4797
|
+
cacheReadTokens: 0,
|
|
4798
|
+
cacheCreationTokens: 0,
|
|
4799
|
+
costUsd: 0
|
|
4800
|
+
});
|
|
4801
|
+
}
|
|
4802
|
+
for (const row of this.readRows(range)) {
|
|
4803
|
+
const g = buckets.get(floorToBucket(row.ts, bucket));
|
|
4804
|
+
if (!g) continue;
|
|
4805
|
+
g.requests += 1;
|
|
4806
|
+
g.inputTokens += row.inputTokens;
|
|
4807
|
+
g.outputTokens += row.outputTokens;
|
|
4808
|
+
g.cacheReadTokens += row.cacheReadTokens;
|
|
4809
|
+
g.cacheCreationTokens += row.cacheCreationTokens;
|
|
4810
|
+
g.costUsd += row.costUsd;
|
|
4811
|
+
}
|
|
4812
|
+
return Array.from(buckets.values());
|
|
4813
|
+
}
|
|
3351
4814
|
async getMessagesForSession(sessionId) {
|
|
3352
4815
|
return this.readAllRows().filter((r) => r.sessionId === sessionId).sort((a, b) => a.ts - b.ts).map((r) => ({
|
|
3353
4816
|
id: r.id,
|
|
@@ -3396,10 +4859,10 @@ var JsonlUsageEventStore = class {
|
|
|
3396
4859
|
}
|
|
3397
4860
|
/** Parse every line, skipping malformed/torn lines defensively. */
|
|
3398
4861
|
readAllRows() {
|
|
3399
|
-
if (!(0,
|
|
4862
|
+
if (!(0, import_node_fs7.existsSync)(this.eventsPath)) return [];
|
|
3400
4863
|
let raw;
|
|
3401
4864
|
try {
|
|
3402
|
-
raw = (0,
|
|
4865
|
+
raw = (0, import_node_fs7.readFileSync)(this.eventsPath, "utf8");
|
|
3403
4866
|
} catch {
|
|
3404
4867
|
return [];
|
|
3405
4868
|
}
|
|
@@ -3416,6 +4879,43 @@ var JsonlUsageEventStore = class {
|
|
|
3416
4879
|
return rows;
|
|
3417
4880
|
}
|
|
3418
4881
|
};
|
|
4882
|
+
function floorToBucket(ts, bucket) {
|
|
4883
|
+
const d = new Date(ts);
|
|
4884
|
+
switch (bucket) {
|
|
4885
|
+
case "hour":
|
|
4886
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()).getTime();
|
|
4887
|
+
case "day":
|
|
4888
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
4889
|
+
case "month":
|
|
4890
|
+
return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
|
|
4891
|
+
}
|
|
4892
|
+
}
|
|
4893
|
+
function nextBoundary(ts, bucket) {
|
|
4894
|
+
const d = new Date(ts);
|
|
4895
|
+
switch (bucket) {
|
|
4896
|
+
case "hour":
|
|
4897
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours() + 1).getTime();
|
|
4898
|
+
case "day":
|
|
4899
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime();
|
|
4900
|
+
case "month":
|
|
4901
|
+
return new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
|
|
4902
|
+
}
|
|
4903
|
+
}
|
|
4904
|
+
var pad2 = (n) => String(n).padStart(2, "0");
|
|
4905
|
+
function bucketLabel(bucketStartTs, bucket) {
|
|
4906
|
+
const d = new Date(bucketStartTs);
|
|
4907
|
+
const y = d.getFullYear();
|
|
4908
|
+
const mo = pad2(d.getMonth() + 1);
|
|
4909
|
+
const day = pad2(d.getDate());
|
|
4910
|
+
switch (bucket) {
|
|
4911
|
+
case "hour":
|
|
4912
|
+
return `${mo}-${day} ${pad2(d.getHours())}:00`;
|
|
4913
|
+
case "day":
|
|
4914
|
+
return `${y}-${mo}-${day}`;
|
|
4915
|
+
case "month":
|
|
4916
|
+
return `${y}-${mo}`;
|
|
4917
|
+
}
|
|
4918
|
+
}
|
|
3419
4919
|
var NUMERIC_FIELDS = [
|
|
3420
4920
|
"ts",
|
|
3421
4921
|
"inputTokens",
|
|
@@ -3446,7 +4946,7 @@ function isUsageEventRecord(parsed) {
|
|
|
3446
4946
|
}
|
|
3447
4947
|
|
|
3448
4948
|
// src/ports/JsonOutboundKeyDb.ts
|
|
3449
|
-
var
|
|
4949
|
+
var import_node_fs8 = require("fs");
|
|
3450
4950
|
var JsonOutboundKeyDb = class {
|
|
3451
4951
|
constructor(keysPath) {
|
|
3452
4952
|
this.keysPath = keysPath;
|
|
@@ -3499,32 +4999,76 @@ var JsonOutboundKeyDb = class {
|
|
|
3499
4999
|
return true;
|
|
3500
5000
|
});
|
|
3501
5001
|
}
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
return changed;
|
|
5002
|
+
async outboundApiKeysSetMaxConcurrency(id, maxConcurrency) {
|
|
5003
|
+
return this.mutateRow(id, (row) => {
|
|
5004
|
+
if (row.revokedAt !== null) return false;
|
|
5005
|
+
if (maxConcurrency === null) delete row.maxConcurrency;
|
|
5006
|
+
else row.maxConcurrency = maxConcurrency;
|
|
5007
|
+
return true;
|
|
5008
|
+
});
|
|
3510
5009
|
}
|
|
3511
|
-
|
|
5010
|
+
async outboundApiKeysSetPolicy(id, policy) {
|
|
5011
|
+
return this.mutateRow(id, (row) => {
|
|
5012
|
+
if (row.revokedAt !== null) return false;
|
|
5013
|
+
applyPolicyField(row, "expiresAt", policy.expiresAt);
|
|
5014
|
+
applyPolicyField(row, "activationDays", policy.activationDays);
|
|
5015
|
+
applyPolicyField(row, "dailyCostLimitUsd", policy.dailyCostLimitUsd);
|
|
5016
|
+
applyPolicyField(row, "totalCostLimitUsd", policy.totalCostLimitUsd);
|
|
5017
|
+
applyPolicyField(row, "weeklyCostLimitUsd", policy.weeklyCostLimitUsd);
|
|
5018
|
+
applyPolicyField(row, "rateLimitMaxRequests", policy.rateLimitMaxRequests);
|
|
5019
|
+
applyPolicyField(row, "rateLimitWindowMs", policy.rateLimitWindowMs);
|
|
5020
|
+
if (policy.activationMode === null) delete row.activationMode;
|
|
5021
|
+
else if (policy.activationMode !== void 0) row.activationMode = policy.activationMode;
|
|
5022
|
+
if (policy.enableModelRestriction === null) delete row.enableModelRestriction;
|
|
5023
|
+
else if (policy.enableModelRestriction !== void 0) {
|
|
5024
|
+
row.enableModelRestriction = policy.enableModelRestriction;
|
|
5025
|
+
}
|
|
5026
|
+
if (policy.restrictionMode === null) delete row.restrictionMode;
|
|
5027
|
+
else if (policy.restrictionMode !== void 0) row.restrictionMode = policy.restrictionMode;
|
|
5028
|
+
if (policy.restrictedModels === null) delete row.restrictedModels;
|
|
5029
|
+
else if (policy.restrictedModels !== void 0) row.restrictedModels = policy.restrictedModels;
|
|
5030
|
+
return true;
|
|
5031
|
+
});
|
|
5032
|
+
}
|
|
5033
|
+
async outboundApiKeysMarkActivated(id, activatedAt) {
|
|
5034
|
+
return this.mutateRow(id, (row) => {
|
|
5035
|
+
if (row.revokedAt !== null) return false;
|
|
5036
|
+
if (row.activatedAt != null) return false;
|
|
5037
|
+
row.activatedAt = activatedAt;
|
|
5038
|
+
return true;
|
|
5039
|
+
});
|
|
5040
|
+
}
|
|
5041
|
+
/** Apply `fn` to the row with `id`, persisting when it returns true. */
|
|
5042
|
+
mutateRow(id, fn) {
|
|
5043
|
+
const rows = this.readRows();
|
|
5044
|
+
const row = rows.find((r) => r.id === id);
|
|
5045
|
+
if (!row) return false;
|
|
5046
|
+
const changed = fn(row);
|
|
5047
|
+
if (changed) this.writeRows(rows);
|
|
5048
|
+
return changed;
|
|
5049
|
+
}
|
|
5050
|
+
/** Read the key rows, tolerating a missing/corrupt file (→ empty list). */
|
|
3512
5051
|
readRows() {
|
|
3513
|
-
if (!(0,
|
|
5052
|
+
if (!(0, import_node_fs8.existsSync)(this.keysPath)) return [];
|
|
3514
5053
|
try {
|
|
3515
|
-
const parsed = JSON.parse((0,
|
|
5054
|
+
const parsed = JSON.parse((0, import_node_fs8.readFileSync)(this.keysPath, "utf8"));
|
|
3516
5055
|
return Array.isArray(parsed) ? parsed : [];
|
|
3517
5056
|
} catch {
|
|
3518
5057
|
return [];
|
|
3519
5058
|
}
|
|
3520
5059
|
}
|
|
3521
5060
|
writeRows(rows) {
|
|
3522
|
-
(0,
|
|
5061
|
+
(0, import_node_fs8.writeFileSync)(this.keysPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
|
|
3523
5062
|
}
|
|
3524
5063
|
};
|
|
5064
|
+
function applyPolicyField(row, field, value) {
|
|
5065
|
+
if (value === void 0) return;
|
|
5066
|
+
if (value === null) delete row[field];
|
|
5067
|
+
else row[field] = value;
|
|
5068
|
+
}
|
|
3525
5069
|
|
|
3526
5070
|
// src/ports/JsonPricingStore.ts
|
|
3527
|
-
var
|
|
5071
|
+
var import_node_fs9 = require("fs");
|
|
3528
5072
|
var JsonPricingStore = class {
|
|
3529
5073
|
constructor(pricingPath) {
|
|
3530
5074
|
this.pricingPath = pricingPath;
|
|
@@ -3635,22 +5179,117 @@ var JsonPricingStore = class {
|
|
|
3635
5179
|
}
|
|
3636
5180
|
/** Read the pricing rows, tolerating a missing/corrupt file (→ empty list). */
|
|
3637
5181
|
readRows() {
|
|
3638
|
-
if (!(0,
|
|
5182
|
+
if (!(0, import_node_fs9.existsSync)(this.pricingPath)) return [];
|
|
5183
|
+
try {
|
|
5184
|
+
const parsed = JSON.parse((0, import_node_fs9.readFileSync)(this.pricingPath, "utf8"));
|
|
5185
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
5186
|
+
} catch {
|
|
5187
|
+
return [];
|
|
5188
|
+
}
|
|
5189
|
+
}
|
|
5190
|
+
writeRows(rows) {
|
|
5191
|
+
(0, import_node_fs9.writeFileSync)(this.pricingPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
|
|
5192
|
+
}
|
|
5193
|
+
};
|
|
5194
|
+
|
|
5195
|
+
// src/ports/JsonVoucherDb.ts
|
|
5196
|
+
var import_node_fs10 = require("fs");
|
|
5197
|
+
var JsonVoucherDb = class {
|
|
5198
|
+
constructor(vouchersPath) {
|
|
5199
|
+
this.vouchersPath = vouchersPath;
|
|
5200
|
+
}
|
|
5201
|
+
vouchersPath;
|
|
5202
|
+
async voucherCreate(input) {
|
|
5203
|
+
const rows = this.readRows();
|
|
5204
|
+
const row = {
|
|
5205
|
+
id: input.id,
|
|
5206
|
+
codeHash: input.codeHash,
|
|
5207
|
+
codePrefix: input.codePrefix,
|
|
5208
|
+
type: input.type,
|
|
5209
|
+
status: "unredeemed",
|
|
5210
|
+
createdAt: input.createdAt ?? Date.now()
|
|
5211
|
+
};
|
|
5212
|
+
if (input.creditUsd != null) row.creditUsd = input.creditUsd;
|
|
5213
|
+
if (input.renewalDays != null) row.renewalDays = input.renewalDays;
|
|
5214
|
+
if (input.maxTotalCostLimitUsd != null) row.maxTotalCostLimitUsd = input.maxTotalCostLimitUsd;
|
|
5215
|
+
if (input.maxExpiryDays != null) row.maxExpiryDays = input.maxExpiryDays;
|
|
5216
|
+
rows.push(row);
|
|
5217
|
+
this.writeRows(rows);
|
|
5218
|
+
return row;
|
|
5219
|
+
}
|
|
5220
|
+
async voucherGetByHash(codeHash) {
|
|
5221
|
+
const rows = this.readRows();
|
|
5222
|
+
return rows.find((r) => r.codeHash === codeHash) ?? null;
|
|
5223
|
+
}
|
|
5224
|
+
async voucherRedeemCas(id, keyId, granted, now) {
|
|
5225
|
+
const rows = this.readRows();
|
|
5226
|
+
const row = rows.find((r) => r.id === id);
|
|
5227
|
+
if (!row || row.status !== "unredeemed") return false;
|
|
5228
|
+
row.status = "redeemed";
|
|
5229
|
+
row.redeemedAt = now;
|
|
5230
|
+
row.redeemedByKeyId = keyId;
|
|
5231
|
+
row.grantApplied = false;
|
|
5232
|
+
if (granted.totalCostLimitUsd != null) row.grantedTotalCostLimitUsd = granted.totalCostLimitUsd;
|
|
5233
|
+
if (granted.expiresAt != null) row.grantedExpiresAt = granted.expiresAt;
|
|
5234
|
+
this.writeRows(rows);
|
|
5235
|
+
return true;
|
|
5236
|
+
}
|
|
5237
|
+
async voucherMarkGrantApplied(id) {
|
|
5238
|
+
const rows = this.readRows();
|
|
5239
|
+
const row = rows.find((r) => r.id === id);
|
|
5240
|
+
if (!row || row.status !== "redeemed") return false;
|
|
5241
|
+
if (row.grantApplied === true) return true;
|
|
5242
|
+
row.grantApplied = true;
|
|
5243
|
+
this.writeRows(rows);
|
|
5244
|
+
return true;
|
|
5245
|
+
}
|
|
5246
|
+
async voucherRevertRedeem(id, keyId) {
|
|
5247
|
+
const rows = this.readRows();
|
|
5248
|
+
const row = rows.find((r) => r.id === id);
|
|
5249
|
+
if (!row || row.status !== "redeemed" || row.grantApplied === true) return false;
|
|
5250
|
+
if (row.redeemedByKeyId !== keyId) return false;
|
|
5251
|
+
row.status = "unredeemed";
|
|
5252
|
+
delete row.redeemedAt;
|
|
5253
|
+
delete row.redeemedByKeyId;
|
|
5254
|
+
delete row.grantApplied;
|
|
5255
|
+
delete row.grantedTotalCostLimitUsd;
|
|
5256
|
+
delete row.grantedExpiresAt;
|
|
5257
|
+
this.writeRows(rows);
|
|
5258
|
+
return true;
|
|
5259
|
+
}
|
|
5260
|
+
async voucherRevokeCas(id, now) {
|
|
5261
|
+
const rows = this.readRows();
|
|
5262
|
+
const row = rows.find((r) => r.id === id);
|
|
5263
|
+
if (!row || row.status !== "unredeemed") return false;
|
|
5264
|
+
row.status = "revoked";
|
|
5265
|
+
row.revokedAt = now;
|
|
5266
|
+
this.writeRows(rows);
|
|
5267
|
+
return true;
|
|
5268
|
+
}
|
|
5269
|
+
async voucherList() {
|
|
5270
|
+
return this.readRows();
|
|
5271
|
+
}
|
|
5272
|
+
/** Read the voucher rows, tolerating a missing/corrupt file (→ empty list). */
|
|
5273
|
+
readRows() {
|
|
5274
|
+
if (!(0, import_node_fs10.existsSync)(this.vouchersPath)) return [];
|
|
3639
5275
|
try {
|
|
3640
|
-
const parsed = JSON.parse((0,
|
|
5276
|
+
const parsed = JSON.parse((0, import_node_fs10.readFileSync)(this.vouchersPath, "utf8"));
|
|
3641
5277
|
return Array.isArray(parsed) ? parsed : [];
|
|
3642
5278
|
} catch {
|
|
3643
5279
|
return [];
|
|
3644
5280
|
}
|
|
3645
5281
|
}
|
|
3646
5282
|
writeRows(rows) {
|
|
3647
|
-
(0,
|
|
5283
|
+
(0, import_node_fs10.writeFileSync)(this.vouchersPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
|
|
3648
5284
|
}
|
|
3649
5285
|
};
|
|
3650
5286
|
|
|
3651
5287
|
// src/ports/JsonSubscriptionCredentialStore.ts
|
|
3652
|
-
var
|
|
5288
|
+
var import_node_fs13 = require("fs");
|
|
3653
5289
|
var import_node_path7 = require("path");
|
|
5290
|
+
var import_SubscriptionAccountHealth = require("@omnicross/core/pipeline/SubscriptionAccountHealth");
|
|
5291
|
+
var import_upstreamFetch3 = require("@omnicross/core/pipeline/upstreamFetch");
|
|
5292
|
+
var import_SubscriptionIdentityStore = require("@omnicross/core/provider-proxy/identity/SubscriptionIdentityStore");
|
|
3654
5293
|
var import_subscriptions3 = require("@omnicross/subscriptions");
|
|
3655
5294
|
|
|
3656
5295
|
// src/ports/account-sync.ts
|
|
@@ -3727,7 +5366,7 @@ function findDuplicateCredentialIds(accounts) {
|
|
|
3727
5366
|
}
|
|
3728
5367
|
|
|
3729
5368
|
// src/ports/external-cli-credentials.ts
|
|
3730
|
-
var
|
|
5369
|
+
var import_node_fs11 = require("fs");
|
|
3731
5370
|
var import_node_os2 = require("os");
|
|
3732
5371
|
var import_node_path5 = require("path");
|
|
3733
5372
|
function externalStorePath(provider, home = (0, import_node_os2.homedir)()) {
|
|
@@ -3780,10 +5419,10 @@ function parseCodexTokensEnvelope(raw) {
|
|
|
3780
5419
|
}
|
|
3781
5420
|
function readExternalCliCredentials(provider, home = (0, import_node_os2.homedir)()) {
|
|
3782
5421
|
const path2 = externalStorePath(provider, home);
|
|
3783
|
-
if (!(0,
|
|
5422
|
+
if (!(0, import_node_fs11.existsSync)(path2)) return null;
|
|
3784
5423
|
let raw;
|
|
3785
5424
|
try {
|
|
3786
|
-
const parsed = JSON.parse((0,
|
|
5425
|
+
const parsed = JSON.parse((0, import_node_fs11.readFileSync)(path2, "utf8"));
|
|
3787
5426
|
raw = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
|
|
3788
5427
|
} catch {
|
|
3789
5428
|
return null;
|
|
@@ -3792,7 +5431,7 @@ function readExternalCliCredentials(provider, home = (0, import_node_os2.homedir
|
|
|
3792
5431
|
}
|
|
3793
5432
|
|
|
3794
5433
|
// src/ports/external-cli-store.ts
|
|
3795
|
-
var
|
|
5434
|
+
var import_node_fs12 = require("fs");
|
|
3796
5435
|
var import_node_os3 = require("os");
|
|
3797
5436
|
var import_node_path6 = require("path");
|
|
3798
5437
|
function markerPath(provider, home) {
|
|
@@ -3820,27 +5459,27 @@ function buildCodexTokensEnvelope(tokens) {
|
|
|
3820
5459
|
return envelope;
|
|
3821
5460
|
}
|
|
3822
5461
|
function readExistingObject(path2) {
|
|
3823
|
-
if (!(0,
|
|
5462
|
+
if (!(0, import_node_fs12.existsSync)(path2)) return {};
|
|
3824
5463
|
try {
|
|
3825
|
-
const parsed = JSON.parse((0,
|
|
5464
|
+
const parsed = JSON.parse((0, import_node_fs12.readFileSync)(path2, "utf8"));
|
|
3826
5465
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
3827
5466
|
} catch {
|
|
3828
5467
|
return {};
|
|
3829
5468
|
}
|
|
3830
5469
|
}
|
|
3831
5470
|
function writeAtomic(path2, content) {
|
|
3832
|
-
(0,
|
|
5471
|
+
(0, import_node_fs12.mkdirSync)((0, import_node_path6.dirname)(path2), { recursive: true });
|
|
3833
5472
|
const temp = `${path2}.omnicross-tmp`;
|
|
3834
|
-
(0,
|
|
3835
|
-
(0,
|
|
5473
|
+
(0, import_node_fs12.writeFileSync)(temp, content, "utf8");
|
|
5474
|
+
(0, import_node_fs12.renameSync)(temp, path2);
|
|
3836
5475
|
}
|
|
3837
5476
|
function createExternalCliStore(home = (0, import_node_os3.homedir)()) {
|
|
3838
5477
|
return {
|
|
3839
5478
|
readMarkerAccountId(provider) {
|
|
3840
5479
|
const path2 = markerPath(provider, home);
|
|
3841
|
-
if (!(0,
|
|
5480
|
+
if (!(0, import_node_fs12.existsSync)(path2)) return void 0;
|
|
3842
5481
|
try {
|
|
3843
|
-
const parsed = JSON.parse((0,
|
|
5482
|
+
const parsed = JSON.parse((0, import_node_fs12.readFileSync)(path2, "utf8"));
|
|
3844
5483
|
return typeof parsed.accountId === "string" && parsed.accountId ? parsed.accountId : void 0;
|
|
3845
5484
|
} catch {
|
|
3846
5485
|
return void 0;
|
|
@@ -3858,8 +5497,8 @@ function createExternalCliStore(home = (0, import_node_os3.homedir)()) {
|
|
|
3858
5497
|
const envelope = provider === "claude" ? buildClaudeOAuthEnvelope(tokens) : buildCodexTokensEnvelope(tokens);
|
|
3859
5498
|
if (!envelope) return false;
|
|
3860
5499
|
const storePath = externalStorePath(provider, home);
|
|
3861
|
-
if ((0,
|
|
3862
|
-
(0,
|
|
5500
|
+
if ((0, import_node_fs12.existsSync)(storePath) && !(0, import_node_fs12.existsSync)(backupPath(provider, home))) {
|
|
5501
|
+
(0, import_node_fs12.copyFileSync)(storePath, backupPath(provider, home));
|
|
3863
5502
|
}
|
|
3864
5503
|
const existing = readExistingObject(storePath);
|
|
3865
5504
|
const merged = provider === "claude" ? { ...existing, claudeAiOauth: envelope } : { ...existing, tokens: envelope };
|
|
@@ -3870,16 +5509,21 @@ function createExternalCliStore(home = (0, import_node_os3.homedir)()) {
|
|
|
3870
5509
|
}
|
|
3871
5510
|
|
|
3872
5511
|
// src/ports/JsonSubscriptionCredentialStore.ts
|
|
5512
|
+
var ACCOUNT_REFRESH_LEAD_MS = 5 * 6e4;
|
|
3873
5513
|
var JsonSubscriptionCredentialStore = class {
|
|
3874
5514
|
/**
|
|
3875
5515
|
* @param tokensPath on-disk `tokens.json` location.
|
|
3876
5516
|
* @param box at-rest `SecretBox` (encrypt-on-write / decrypt-on-read).
|
|
3877
|
-
* @param fetchImpl injectable HTTP port for the OAuth refresh
|
|
3878
|
-
* (oauth design D4).
|
|
3879
|
-
*
|
|
3880
|
-
*
|
|
5517
|
+
* @param fetchImpl OPTIONAL injectable HTTP port for the OAuth refresh
|
|
5518
|
+
* round-trips (oauth design D4). A TEST-injected transport is
|
|
5519
|
+
* used verbatim. When ABSENT (production), each refresh uses a
|
|
5520
|
+
* proxy-aware {@link fetchUpstream} that threads the
|
|
5521
|
+
* `{ providerId, accountId }` ctx (upstream-proxy M1) so a
|
|
5522
|
+
* per-account/per-provider proxy is honored on refresh exactly
|
|
5523
|
+
* as on relay — refresh egresses from the SAME proxy IP as the
|
|
5524
|
+
* account's traffic. NOT used by any read/write path.
|
|
3881
5525
|
*/
|
|
3882
|
-
constructor(tokensPath, box, fetchImpl =
|
|
5526
|
+
constructor(tokensPath, box, fetchImpl = void 0, externalCliReader = readExternalCliCredentials, externalCliStore = createExternalCliStore()) {
|
|
3883
5527
|
this.tokensPath = tokensPath;
|
|
3884
5528
|
this.box = box;
|
|
3885
5529
|
this.fetchImpl = fetchImpl;
|
|
@@ -3891,6 +5535,15 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3891
5535
|
fetchImpl;
|
|
3892
5536
|
externalCliReader;
|
|
3893
5537
|
externalCliStore;
|
|
5538
|
+
/**
|
|
5539
|
+
* The proxy-aware `FetchLike` for one refresh round-trip (upstream-proxy M1). A
|
|
5540
|
+
* TEST-injected `fetchImpl` is returned verbatim; otherwise the refresh routes
|
|
5541
|
+
* through {@link fetchUpstream} with the account's `{ providerId, accountId }`
|
|
5542
|
+
* ctx so the per-account/provider proxy applies. `@internal` — also a test seam.
|
|
5543
|
+
*/
|
|
5544
|
+
buildRefreshFetch(providerId, accountId) {
|
|
5545
|
+
return this.fetchImpl ?? ((url, init) => (0, import_upstreamFetch3.fetchUpstream)(url, init, { providerId, accountId }));
|
|
5546
|
+
}
|
|
3894
5547
|
/**
|
|
3895
5548
|
* In-flight refresh coalescing (external-cli-sync). OAuth refresh tokens are
|
|
3896
5549
|
* SINGLE-USE: two concurrent refreshes of one account each spend the same
|
|
@@ -3922,6 +5575,19 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3922
5575
|
async getValidOpenCodeGoApiKey() {
|
|
3923
5576
|
return this.readConfig().opencodego?.apiKey ?? null;
|
|
3924
5577
|
}
|
|
5578
|
+
/**
|
|
5579
|
+
* DAEMON-ONLY per-account proxy lookup by id (upstream-proxy). Returns the
|
|
5580
|
+
* DECRYPTED `ProxyConfig` for the account (`readConfig` decrypts on read), or
|
|
5581
|
+
* `undefined` for an unknown provider/account or no per-account proxy. Feeds the
|
|
5582
|
+
* winning per-account layer of the upstream-proxy resolver. Synchronous like the
|
|
5583
|
+
* other hot reads. Never returns token material.
|
|
5584
|
+
*/
|
|
5585
|
+
getAccountProxy(providerId, accountId) {
|
|
5586
|
+
if (providerId !== "claude" && providerId !== "codex" && providerId !== "gemini" && providerId !== "opencodego") {
|
|
5587
|
+
return void 0;
|
|
5588
|
+
}
|
|
5589
|
+
return getAccountProxy(this.readConfig(), providerId, accountId);
|
|
5590
|
+
}
|
|
3925
5591
|
/**
|
|
3926
5592
|
* DAEMON-ONLY sanitized accounts list (design D8, NOT on the port). Projects
|
|
3927
5593
|
* each provider's accounts to the secret-free `SubscriptionAccountSanitized`
|
|
@@ -3930,10 +5596,25 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3930
5596
|
*/
|
|
3931
5597
|
async listSanitizedAccounts() {
|
|
3932
5598
|
const config = this.readConfig();
|
|
5599
|
+
const health2 = (0, import_SubscriptionAccountHealth.getSharedAccountHealth)();
|
|
5600
|
+
const identityStore = (0, import_SubscriptionIdentityStore.getSharedIdentityStore)();
|
|
5601
|
+
const fingerprintOn = identityStore.isEnabled();
|
|
5602
|
+
const now = Date.now();
|
|
3933
5603
|
const out = {};
|
|
3934
5604
|
for (const provider of ["claude", "codex", "gemini", "opencodego"]) {
|
|
3935
5605
|
const sanitized = sanitizeAccounts(config, provider);
|
|
3936
|
-
if (sanitized.length
|
|
5606
|
+
if (sanitized.length === 0) continue;
|
|
5607
|
+
for (const account of sanitized) {
|
|
5608
|
+
const status = health2.getStatus(provider, account.id, now);
|
|
5609
|
+
account.health = status.state;
|
|
5610
|
+
account.cooldownUntil = status.cooldownUntil !== void 0 ? new Date(status.cooldownUntil).toISOString() : void 0;
|
|
5611
|
+
if (fingerprintOn && provider === "claude") {
|
|
5612
|
+
account.identityCaptured = identityStore.hasIdentity(provider, account.id);
|
|
5613
|
+
const capturedAt = identityStore.capturedAt(provider, account.id);
|
|
5614
|
+
account.identityCapturedAt = capturedAt !== void 0 ? new Date(capturedAt).toISOString() : void 0;
|
|
5615
|
+
}
|
|
5616
|
+
}
|
|
5617
|
+
out[provider] = this.attachSyncWarnings(config, provider, sanitized);
|
|
3937
5618
|
}
|
|
3938
5619
|
return out;
|
|
3939
5620
|
}
|
|
@@ -3984,8 +5665,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3984
5665
|
if (!active || !claude?.refreshToken) return false;
|
|
3985
5666
|
const capturedId = active.id;
|
|
3986
5667
|
this.materializeMigration(config);
|
|
5668
|
+
const refreshFetch = this.buildRefreshFetch("claude", capturedId);
|
|
3987
5669
|
try {
|
|
3988
|
-
const result = await import_subscriptions3.claudeOAuth.refreshAccessToken(claude.refreshToken,
|
|
5670
|
+
const result = await import_subscriptions3.claudeOAuth.refreshAccessToken(claude.refreshToken, refreshFetch);
|
|
3989
5671
|
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
3990
5672
|
const next = {
|
|
3991
5673
|
...claude,
|
|
@@ -4002,7 +5684,7 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4002
5684
|
return true;
|
|
4003
5685
|
} catch (error) {
|
|
4004
5686
|
if (await this.tryExternalImport("claude", capturedId, claude, async (rt) => {
|
|
4005
|
-
const r = await import_subscriptions3.claudeOAuth.refreshAccessToken(rt,
|
|
5687
|
+
const r = await import_subscriptions3.claudeOAuth.refreshAccessToken(rt, refreshFetch);
|
|
4006
5688
|
return {
|
|
4007
5689
|
accessToken: r.accessToken,
|
|
4008
5690
|
refreshToken: r.refreshToken,
|
|
@@ -4029,8 +5711,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4029
5711
|
if (!active || !codex?.refreshToken) return false;
|
|
4030
5712
|
const capturedId = active.id;
|
|
4031
5713
|
this.materializeMigration(config);
|
|
5714
|
+
const refreshFetch = this.buildRefreshFetch("codex", capturedId);
|
|
4032
5715
|
try {
|
|
4033
|
-
const result = await import_subscriptions3.codexOAuth.refreshAccessToken(codex.refreshToken,
|
|
5716
|
+
const result = await import_subscriptions3.codexOAuth.refreshAccessToken(codex.refreshToken, refreshFetch);
|
|
4034
5717
|
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
4035
5718
|
const next = {
|
|
4036
5719
|
...codex,
|
|
@@ -4048,7 +5731,7 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4048
5731
|
return true;
|
|
4049
5732
|
} catch (error) {
|
|
4050
5733
|
if (await this.tryExternalImport("codex", capturedId, codex, async (rt) => {
|
|
4051
|
-
const r = await import_subscriptions3.codexOAuth.refreshAccessToken(rt,
|
|
5734
|
+
const r = await import_subscriptions3.codexOAuth.refreshAccessToken(rt, refreshFetch);
|
|
4052
5735
|
return {
|
|
4053
5736
|
accessToken: r.accessToken,
|
|
4054
5737
|
refreshToken: r.refreshToken,
|
|
@@ -4078,8 +5761,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4078
5761
|
if (!active || !gemini?.refreshToken) return false;
|
|
4079
5762
|
const capturedId = active.id;
|
|
4080
5763
|
this.materializeMigration(config);
|
|
5764
|
+
const refreshFetch = this.buildRefreshFetch("gemini", capturedId);
|
|
4081
5765
|
try {
|
|
4082
|
-
const result = await import_subscriptions3.geminiOAuth.refreshAccessToken(gemini.refreshToken,
|
|
5766
|
+
const result = await import_subscriptions3.geminiOAuth.refreshAccessToken(gemini.refreshToken, refreshFetch);
|
|
4083
5767
|
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
4084
5768
|
const next = {
|
|
4085
5769
|
...gemini,
|
|
@@ -4113,7 +5797,7 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4113
5797
|
if (!account || !captured?.refreshToken) return false;
|
|
4114
5798
|
this.materializeMigration(config);
|
|
4115
5799
|
try {
|
|
4116
|
-
const refreshed = await this.refreshUpstream(provider, captured.refreshToken);
|
|
5800
|
+
const refreshed = await this.refreshUpstream(provider, captured.refreshToken, id);
|
|
4117
5801
|
const next = {
|
|
4118
5802
|
...captured,
|
|
4119
5803
|
accessToken: refreshed.accessToken,
|
|
@@ -4135,10 +5819,114 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4135
5819
|
}
|
|
4136
5820
|
});
|
|
4137
5821
|
}
|
|
5822
|
+
// ── By-id account-pool surface (subscription-account-scheduling, design D6) ──
|
|
5823
|
+
/**
|
|
5824
|
+
* Resolve a SPECIFIC account's access token by id (design D6). Mirrors each
|
|
5825
|
+
* provider's ACTIVE-getter policy, keyed by id: claude returns the stored token
|
|
5826
|
+
* (refresh is 401-driven, like `getValidClaudeAccessToken`); codex/gemini refresh
|
|
5827
|
+
* a near-expiry token via `refreshAccountById` (like `resolveAccessToken`);
|
|
5828
|
+
* opencodego returns the account's static key. `null` when unknown/expired/
|
|
5829
|
+
* tokenless.
|
|
5830
|
+
*/
|
|
5831
|
+
async getAccessTokenForAccount(providerId, accountId) {
|
|
5832
|
+
const account = getAccountById(this.readConfig(), providerId, accountId);
|
|
5833
|
+
if (!account) return null;
|
|
5834
|
+
if (providerId === "opencodego") {
|
|
5835
|
+
return account.tokens.apiKey ?? null;
|
|
5836
|
+
}
|
|
5837
|
+
const oauth = account.tokens;
|
|
5838
|
+
if (!oauth.accessToken) return null;
|
|
5839
|
+
if (providerId === "codex" || providerId === "gemini") {
|
|
5840
|
+
const expiresAtMs = oauth.expiresAt ? Date.parse(oauth.expiresAt) : 0;
|
|
5841
|
+
const expiringSoon = expiresAtMs > 0 && Date.now() >= expiresAtMs - ACCOUNT_REFRESH_LEAD_MS;
|
|
5842
|
+
if (expiringSoon && oauth.refreshToken) {
|
|
5843
|
+
const ok = await this.refreshAccountById(providerId, accountId);
|
|
5844
|
+
if (!ok) return null;
|
|
5845
|
+
const fresh = getAccountById(this.readConfig(), providerId, accountId);
|
|
5846
|
+
return fresh?.tokens?.accessToken ?? null;
|
|
5847
|
+
}
|
|
5848
|
+
}
|
|
5849
|
+
if (oauth.status === "expired") return null;
|
|
5850
|
+
return oauth.accessToken;
|
|
5851
|
+
}
|
|
5852
|
+
/**
|
|
5853
|
+
* Refresh a SPECIFIC account's OAuth token by id (design D6/D7). Delegates to
|
|
5854
|
+
* `refreshAccountById` (coalesced per `provider:id`); opencodego is a static key
|
|
5855
|
+
* → `false` (no refresh affordance).
|
|
5856
|
+
*/
|
|
5857
|
+
async refreshAccountToken(providerId, accountId) {
|
|
5858
|
+
if (providerId === "opencodego") return false;
|
|
5859
|
+
return this.refreshAccountById(providerId, accountId);
|
|
5860
|
+
}
|
|
5861
|
+
/**
|
|
5862
|
+
* Best-effort record of a selection time onto the account's `lastUsedAt` by id
|
|
5863
|
+
* (design D4). Entry-metadata only (the token mirror is untouched); a no-op for
|
|
5864
|
+
* an unknown id. The selector throttles the call frequency, so this stays cheap.
|
|
5865
|
+
*/
|
|
5866
|
+
async touchAccountLastUsed(providerId, accountId, iso) {
|
|
5867
|
+
const config = this.readConfig();
|
|
5868
|
+
const result = setAccountLastUsed(config, providerId, accountId, iso);
|
|
5869
|
+
if (!result.ok) return;
|
|
5870
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5871
|
+
}
|
|
5872
|
+
/**
|
|
5873
|
+
* Best-effort write-through of a per-account client `identity`
|
|
5874
|
+
* (subscription-client-fingerprint #7, P2). Entry-metadata only (NON-secret
|
|
5875
|
+
* whitelisted fingerprint headers; the token mirror is untouched); a no-op for
|
|
5876
|
+
* an unknown id. Called by the identity store's persistence port on a first-seen
|
|
5877
|
+
* freeze / TTL refresh, so it stays infrequent. Never throws to the caller — the
|
|
5878
|
+
* store's port wrapper swallows a rejection so the relay hot path is unaffected.
|
|
5879
|
+
*/
|
|
5880
|
+
async setAccountIdentity(providerId, accountId, identity) {
|
|
5881
|
+
const config = this.readConfig();
|
|
5882
|
+
const result = setAccountIdentity(config, providerId, accountId, identity);
|
|
5883
|
+
if (!result.ok) return;
|
|
5884
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5885
|
+
}
|
|
5886
|
+
/**
|
|
5887
|
+
* DAEMON-ONLY set-priority (subscription-account-scheduling, admin write, NOT on
|
|
5888
|
+
* the port). Set one account's scheduling `priority` by id. Secret-free
|
|
5889
|
+
* (entry-metadata only; the mirror invariant is untouched). Rejects an unknown id.
|
|
5890
|
+
*/
|
|
5891
|
+
async setAccountPriority(providerId, accountId, priority) {
|
|
5892
|
+
const config = this.readConfig();
|
|
5893
|
+
const result = setAccountPriority(config, providerId, accountId, priority);
|
|
5894
|
+
if (!result.ok) return result;
|
|
5895
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5896
|
+
return result;
|
|
5897
|
+
}
|
|
5898
|
+
/**
|
|
5899
|
+
* DAEMON-ONLY set/clear per-account proxy (upstream-proxy, admin write, NOT on
|
|
5900
|
+
* the port). Passing `undefined` clears the override. Write-only password: when
|
|
5901
|
+
* the incoming structured proxy omits the password but the account already had
|
|
5902
|
+
* one, the current (decrypted) password is preserved — editing host/port never
|
|
5903
|
+
* wipes the secret. Persist re-encrypts `proxy.password` via the tokens SecretBox.
|
|
5904
|
+
*/
|
|
5905
|
+
async setAccountProxy(providerId, accountId, proxy) {
|
|
5906
|
+
const config = this.readConfig();
|
|
5907
|
+
const merged = proxy ? preserveProxyConfigSecret(proxy, getAccountProxy(config, providerId, accountId)) : void 0;
|
|
5908
|
+
const result = setAccountProxy(config, providerId, accountId, merged);
|
|
5909
|
+
if (!result.ok) return result;
|
|
5910
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5911
|
+
return result;
|
|
5912
|
+
}
|
|
5913
|
+
/**
|
|
5914
|
+
* DAEMON-ONLY set/clear per-account `supportedModels` (subscription-account-
|
|
5915
|
+
* model-map, admin write, NOT on the port). Passing `undefined` clears it.
|
|
5916
|
+
* Secret-free (model ids only; the mirror invariant is untouched). Rejects an
|
|
5917
|
+
* unknown id.
|
|
5918
|
+
*/
|
|
5919
|
+
async setAccountSupportedModels(providerId, accountId, supportedModels) {
|
|
5920
|
+
const config = this.readConfig();
|
|
5921
|
+
const result = setAccountSupportedModels(config, providerId, accountId, supportedModels);
|
|
5922
|
+
if (!result.ok) return result;
|
|
5923
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5924
|
+
return result;
|
|
5925
|
+
}
|
|
4138
5926
|
/** Dispatch one OAuth refresh round-trip to the provider's shared flow. */
|
|
4139
|
-
async refreshUpstream(provider, refreshToken) {
|
|
5927
|
+
async refreshUpstream(provider, refreshToken, accountId) {
|
|
4140
5928
|
const flow = provider === "claude" ? import_subscriptions3.claudeOAuth : provider === "codex" ? import_subscriptions3.codexOAuth : import_subscriptions3.geminiOAuth;
|
|
4141
|
-
const r = await flow.refreshAccessToken(refreshToken, this.
|
|
5929
|
+
const r = await flow.refreshAccessToken(refreshToken, this.buildRefreshFetch(provider, accountId));
|
|
4142
5930
|
return {
|
|
4143
5931
|
accessToken: r.accessToken,
|
|
4144
5932
|
refreshToken: r.refreshToken,
|
|
@@ -4351,9 +6139,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4351
6139
|
* → `enc:v1:`; already-`enc:`/`$ENV` untouched) before serializing, so any
|
|
4352
6140
|
* write — incl. child 4's future refresh writes — lands encrypted. */
|
|
4353
6141
|
persist(config) {
|
|
4354
|
-
(0,
|
|
6142
|
+
(0, import_node_fs13.mkdirSync)((0, import_node_path7.dirname)(this.tokensPath), { recursive: true });
|
|
4355
6143
|
const encrypted = encryptTokens(config, this.box);
|
|
4356
|
-
(0,
|
|
6144
|
+
(0, import_node_fs13.writeFileSync)(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
|
|
4357
6145
|
}
|
|
4358
6146
|
/**
|
|
4359
6147
|
* Read + parse `tokens.json`, tolerating a missing/corrupt file, then DECRYPT
|
|
@@ -4369,10 +6157,10 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4369
6157
|
* `config.ts loadConfig`, which decrypts outside its parse try.
|
|
4370
6158
|
*/
|
|
4371
6159
|
readConfig() {
|
|
4372
|
-
if (!(0,
|
|
6160
|
+
if (!(0, import_node_fs13.existsSync)(this.tokensPath)) return { updatedAt: "" };
|
|
4373
6161
|
let parsed;
|
|
4374
6162
|
try {
|
|
4375
|
-
const raw = JSON.parse((0,
|
|
6163
|
+
const raw = JSON.parse((0, import_node_fs13.readFileSync)(this.tokensPath, "utf8"));
|
|
4376
6164
|
parsed = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
|
|
4377
6165
|
} catch {
|
|
4378
6166
|
parsed = null;
|
|
@@ -4383,18 +6171,254 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4383
6171
|
}
|
|
4384
6172
|
};
|
|
4385
6173
|
|
|
4386
|
-
// src/
|
|
6174
|
+
// src/AccountHealthProbeScheduler.ts
|
|
6175
|
+
var import_upstreamFetch4 = require("@omnicross/core/pipeline/upstreamFetch");
|
|
6176
|
+
|
|
6177
|
+
// src/probe/ProbeStrategy.ts
|
|
6178
|
+
var PROVIDER_PROBE_PLANS = {
|
|
6179
|
+
claude: {
|
|
6180
|
+
kind: "upstream",
|
|
6181
|
+
// VERIFIED free authed list endpoint (no tokens billed). The anthropic OAuth
|
|
6182
|
+
// bearer is accepted here exactly as on the relay path.
|
|
6183
|
+
url: "https://api.anthropic.com/v1/models",
|
|
6184
|
+
buildInit: (token) => ({
|
|
6185
|
+
method: "GET",
|
|
6186
|
+
headers: {
|
|
6187
|
+
Authorization: `Bearer ${token}`,
|
|
6188
|
+
"anthropic-version": "2023-06-01"
|
|
6189
|
+
}
|
|
6190
|
+
})
|
|
6191
|
+
},
|
|
6192
|
+
// UNVERIFIED cheap authed GET — Phase 1 local-only (LEAD OQ1: do not guess a
|
|
6193
|
+
// billable/wrong endpoint). Upgrade to `{ kind:'upstream' }` once verified.
|
|
6194
|
+
codex: { kind: "local" },
|
|
6195
|
+
gemini: { kind: "local" },
|
|
6196
|
+
opencodego: { kind: "local" }
|
|
6197
|
+
};
|
|
6198
|
+
function probePlanFor(providerId) {
|
|
6199
|
+
return PROVIDER_PROBE_PLANS[providerId] ?? { kind: "local" };
|
|
6200
|
+
}
|
|
6201
|
+
|
|
6202
|
+
// src/AccountHealthProbeScheduler.ts
|
|
6203
|
+
var KEY_SEP = "\0";
|
|
6204
|
+
var MAX_BODY_SNIFF = 2048;
|
|
6205
|
+
var PROBE_PROVIDERS = [
|
|
6206
|
+
"claude",
|
|
6207
|
+
"codex",
|
|
6208
|
+
"gemini",
|
|
6209
|
+
"opencodego"
|
|
6210
|
+
];
|
|
6211
|
+
var AccountHealthProbeScheduler = class {
|
|
6212
|
+
constructor(store, health2, logger, config, opts = {}) {
|
|
6213
|
+
this.store = store;
|
|
6214
|
+
this.health = health2;
|
|
6215
|
+
this.logger = logger;
|
|
6216
|
+
this.config = config;
|
|
6217
|
+
this.now = opts.now ?? Date.now;
|
|
6218
|
+
this.fetchImpl = opts.fetchImpl ?? import_upstreamFetch4.fetchUpstream;
|
|
6219
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
6220
|
+
this.planFor = opts.planFor ?? probePlanFor;
|
|
6221
|
+
}
|
|
6222
|
+
store;
|
|
6223
|
+
health;
|
|
6224
|
+
logger;
|
|
6225
|
+
config;
|
|
6226
|
+
timer = null;
|
|
6227
|
+
sweeping = false;
|
|
6228
|
+
history = /* @__PURE__ */ new Map();
|
|
6229
|
+
now;
|
|
6230
|
+
fetchImpl;
|
|
6231
|
+
sleep;
|
|
6232
|
+
planFor;
|
|
6233
|
+
/** Whether probing is enabled by the current config. */
|
|
6234
|
+
get enabled() {
|
|
6235
|
+
return this.config.enabled;
|
|
6236
|
+
}
|
|
6237
|
+
/**
|
|
6238
|
+
* Re-apply config to the live instance (the async `start.ts` loads the persisted
|
|
6239
|
+
* `accountProbe` segment after `buildDaemon`). Call BEFORE `start()`.
|
|
6240
|
+
*/
|
|
6241
|
+
configure(config) {
|
|
6242
|
+
this.config = config;
|
|
6243
|
+
}
|
|
6244
|
+
/** Arm the probe interval. No-op when disabled (zero regression). Idempotent. */
|
|
6245
|
+
start() {
|
|
6246
|
+
if (this.timer || !this.config.enabled) return;
|
|
6247
|
+
this.timer = setInterval(() => void this.sweep(), this.config.intervalMs);
|
|
6248
|
+
this.timer.unref?.();
|
|
6249
|
+
}
|
|
6250
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6251
|
+
dispose() {
|
|
6252
|
+
if (this.timer) {
|
|
6253
|
+
clearInterval(this.timer);
|
|
6254
|
+
this.timer = null;
|
|
6255
|
+
}
|
|
6256
|
+
}
|
|
6257
|
+
/**
|
|
6258
|
+
* One sweep: probe every ELIGIBLE account SEQUENTIALLY with a `staggerMs` gap.
|
|
6259
|
+
* Disabled ⇒ no-op. `onlyMultiAccount` skips single-account providers. Exposed
|
|
6260
|
+
* for tests; never throws.
|
|
6261
|
+
*/
|
|
6262
|
+
async sweep() {
|
|
6263
|
+
if (!this.config.enabled || this.sweeping) return;
|
|
6264
|
+
this.sweeping = true;
|
|
6265
|
+
try {
|
|
6266
|
+
const config = await this.store.getFullConfig();
|
|
6267
|
+
let probed = 0;
|
|
6268
|
+
let marked = 0;
|
|
6269
|
+
for (const providerId of PROBE_PROVIDERS) {
|
|
6270
|
+
const accounts = listAccounts(config, providerId);
|
|
6271
|
+
if (this.config.onlyMultiAccount && accounts.length < 2) continue;
|
|
6272
|
+
for (const account of accounts) {
|
|
6273
|
+
if (probed > 0 && this.config.staggerMs > 0) await this.sleep(this.config.staggerMs);
|
|
6274
|
+
const outcome = await this.probeAccount(providerId, account.id);
|
|
6275
|
+
probed += 1;
|
|
6276
|
+
if (outcome.marked) marked += 1;
|
|
6277
|
+
}
|
|
6278
|
+
}
|
|
6279
|
+
this.logger.debug("account-probe sweep complete", { probed, marked });
|
|
6280
|
+
} catch (error) {
|
|
6281
|
+
this.logger.warn("account-probe sweep failed", {
|
|
6282
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6283
|
+
});
|
|
6284
|
+
} finally {
|
|
6285
|
+
this.sweeping = false;
|
|
6286
|
+
}
|
|
6287
|
+
}
|
|
6288
|
+
/**
|
|
6289
|
+
* Probe ONE account (design D1). Local tier first (dead token → synthesized 401,
|
|
6290
|
+
* no upstream); else the upstream tier when a verified endpoint exists. Records
|
|
6291
|
+
* the rolling history entry either way; returns whether the tracker was MARKED.
|
|
6292
|
+
*/
|
|
6293
|
+
async probeAccount(providerId, accountId) {
|
|
6294
|
+
const now = this.now();
|
|
6295
|
+
let token = null;
|
|
6296
|
+
let readThrew = false;
|
|
6297
|
+
try {
|
|
6298
|
+
token = await this.store.getAccessTokenForAccount(providerId, accountId);
|
|
6299
|
+
} catch {
|
|
6300
|
+
readThrew = true;
|
|
6301
|
+
}
|
|
6302
|
+
if (readThrew) {
|
|
6303
|
+
this.record(providerId, accountId, { ts: now, ok: false, status: null, tier: "local" });
|
|
6304
|
+
return { ok: false, marked: false };
|
|
6305
|
+
}
|
|
6306
|
+
if (!token) {
|
|
6307
|
+
this.health.recordUpstreamOutcome(providerId, accountId, { status: 401, now });
|
|
6308
|
+
this.record(providerId, accountId, { ts: now, ok: false, status: 401, tier: "local" });
|
|
6309
|
+
return { ok: false, marked: true };
|
|
6310
|
+
}
|
|
6311
|
+
const plan = this.planFor(providerId);
|
|
6312
|
+
if (plan.kind === "local") {
|
|
6313
|
+
this.record(providerId, accountId, { ts: now, ok: true, tier: "local" });
|
|
6314
|
+
return { ok: true, marked: false };
|
|
6315
|
+
}
|
|
6316
|
+
const start = this.now();
|
|
6317
|
+
let status = null;
|
|
6318
|
+
let bodyText;
|
|
6319
|
+
try {
|
|
6320
|
+
const res = await this.fetchImpl(
|
|
6321
|
+
plan.url,
|
|
6322
|
+
{ ...plan.buildInit(token), signal: AbortSignal.timeout(this.config.timeoutMs) },
|
|
6323
|
+
{ providerId, accountId }
|
|
6324
|
+
);
|
|
6325
|
+
status = res.status;
|
|
6326
|
+
if (status === 403) bodyText = await this.readBounded(res);
|
|
6327
|
+
} catch {
|
|
6328
|
+
status = null;
|
|
6329
|
+
}
|
|
6330
|
+
const latencyMs = this.now() - start;
|
|
6331
|
+
const marked = this.applyOutcome(providerId, accountId, status, bodyText, now);
|
|
6332
|
+
this.record(providerId, accountId, {
|
|
6333
|
+
ts: now,
|
|
6334
|
+
ok: status !== null && status >= 200 && status < 300,
|
|
6335
|
+
status,
|
|
6336
|
+
latencyMs,
|
|
6337
|
+
tier: "upstream"
|
|
6338
|
+
});
|
|
6339
|
+
return { ok: status !== null && status < 400, marked };
|
|
6340
|
+
}
|
|
6341
|
+
/** Per-account rolling history for the authed admin surface (design D5). */
|
|
6342
|
+
getAllHistory() {
|
|
6343
|
+
const out = [];
|
|
6344
|
+
for (const [key, records] of this.history) {
|
|
6345
|
+
const [providerId, accountId] = this.parseKey(key);
|
|
6346
|
+
out.push({ providerId, accountId, records: records.slice() });
|
|
6347
|
+
}
|
|
6348
|
+
return out;
|
|
6349
|
+
}
|
|
6350
|
+
/**
|
|
6351
|
+
* The coarse, account-ANONYMOUS `/health` signal (design D5): `true` when no
|
|
6352
|
+
* probed account is currently unhealthy (per #2's tracker). No ids, no counts —
|
|
6353
|
+
* safe for the unauthenticated `/health`. Vacuously `true` when nothing probed.
|
|
6354
|
+
*/
|
|
6355
|
+
probedAccountsHealthy(now = this.now()) {
|
|
6356
|
+
for (const key of this.history.keys()) {
|
|
6357
|
+
const [providerId, accountId] = this.parseKey(key);
|
|
6358
|
+
if (!this.health.isSchedulable(providerId, accountId, now)) return false;
|
|
6359
|
+
}
|
|
6360
|
+
return true;
|
|
6361
|
+
}
|
|
6362
|
+
/**
|
|
6363
|
+
* Feed ONLY the account/auth-decisive statuses to #2 (LEAD constraint):
|
|
6364
|
+
* 401/403 → mark; 2xx → clear transient; 429 / 5xx / other 4xx / null →
|
|
6365
|
+
* NOT forwarded (never a mark). Returns whether a NEGATIVE mark was applied.
|
|
6366
|
+
*/
|
|
6367
|
+
applyOutcome(providerId, accountId, status, bodyText, now) {
|
|
6368
|
+
if (status === null) return false;
|
|
6369
|
+
if (status === 401 || status === 403) {
|
|
6370
|
+
this.health.recordUpstreamOutcome(providerId, accountId, { status, bodyText, now });
|
|
6371
|
+
return true;
|
|
6372
|
+
}
|
|
6373
|
+
if (status >= 200 && status < 300) {
|
|
6374
|
+
this.health.clearTransientMark(providerId, accountId);
|
|
6375
|
+
return false;
|
|
6376
|
+
}
|
|
6377
|
+
return false;
|
|
6378
|
+
}
|
|
6379
|
+
/** Append a record, capping the ring at `historySize` (drop oldest). */
|
|
6380
|
+
record(providerId, accountId, rec) {
|
|
6381
|
+
const key = this.key(providerId, accountId);
|
|
6382
|
+
const list = this.history.get(key) ?? [];
|
|
6383
|
+
list.push(rec);
|
|
6384
|
+
const overflow = list.length - this.config.historySize;
|
|
6385
|
+
if (overflow > 0) list.splice(0, overflow);
|
|
6386
|
+
this.history.set(key, list);
|
|
6387
|
+
}
|
|
6388
|
+
/** Read a bounded slice of the response body for the 403-ban sniff (never throws). */
|
|
6389
|
+
async readBounded(res) {
|
|
6390
|
+
try {
|
|
6391
|
+
return (await res.text()).slice(0, MAX_BODY_SNIFF);
|
|
6392
|
+
} catch {
|
|
6393
|
+
return "";
|
|
6394
|
+
}
|
|
6395
|
+
}
|
|
6396
|
+
key(providerId, accountId) {
|
|
6397
|
+
return `${providerId}${KEY_SEP}${accountId}`;
|
|
6398
|
+
}
|
|
6399
|
+
parseKey(key) {
|
|
6400
|
+
const idx = key.indexOf(KEY_SEP);
|
|
6401
|
+
return [key.slice(0, idx), key.slice(idx + 1)];
|
|
6402
|
+
}
|
|
6403
|
+
};
|
|
6404
|
+
|
|
6405
|
+
// src/AccountHealthSweeper.ts
|
|
4387
6406
|
var REFRESH_LEAD_MS = 5 * 6e4;
|
|
4388
6407
|
var SWEEP_INTERVAL_MS = 6e4;
|
|
4389
6408
|
var OAUTH_PROVIDERS = ["claude", "codex", "gemini"];
|
|
4390
|
-
|
|
4391
|
-
|
|
6409
|
+
function isOAuthProvider(providerId) {
|
|
6410
|
+
return OAUTH_PROVIDERS.includes(providerId);
|
|
6411
|
+
}
|
|
6412
|
+
var AccountHealthSweeper = class {
|
|
6413
|
+
constructor(store, health2, logger, intervalMs = SWEEP_INTERVAL_MS, leadMs = REFRESH_LEAD_MS) {
|
|
4392
6414
|
this.store = store;
|
|
6415
|
+
this.health = health2;
|
|
4393
6416
|
this.logger = logger;
|
|
4394
6417
|
this.intervalMs = intervalMs;
|
|
4395
6418
|
this.leadMs = leadMs;
|
|
4396
6419
|
}
|
|
4397
6420
|
store;
|
|
6421
|
+
health;
|
|
4398
6422
|
logger;
|
|
4399
6423
|
intervalMs;
|
|
4400
6424
|
leadMs;
|
|
@@ -4413,21 +6437,26 @@ var TokenRefreshScheduler = class {
|
|
|
4413
6437
|
this.timer = null;
|
|
4414
6438
|
}
|
|
4415
6439
|
}
|
|
4416
|
-
/**
|
|
6440
|
+
/**
|
|
6441
|
+
* One sweep: surface accounts that just recovered (emits the recovery signal
|
|
6442
|
+
* through the tracker's hook) and nudge a fresh token for any recovered OAuth
|
|
6443
|
+
* account whose token is near expiry. Exposed for tests. Never throws.
|
|
6444
|
+
*/
|
|
4417
6445
|
async sweep(now = Date.now()) {
|
|
4418
6446
|
if (this.sweeping) return;
|
|
4419
6447
|
this.sweeping = true;
|
|
4420
6448
|
try {
|
|
6449
|
+
const recovered = this.health.sweepRecoveries(now);
|
|
6450
|
+
if (recovered.length === 0) return;
|
|
4421
6451
|
const config = await this.store.getFullConfig();
|
|
4422
|
-
for (const
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
}
|
|
6452
|
+
for (const event of recovered) {
|
|
6453
|
+
if (!isOAuthProvider(event.providerId)) continue;
|
|
6454
|
+
const account = getAccountById(config, event.providerId, event.accountId);
|
|
6455
|
+
if (!account || !this.needsRefresh(account.tokens, now)) continue;
|
|
6456
|
+
await this.refreshOne(event.providerId, event.accountId);
|
|
4428
6457
|
}
|
|
4429
6458
|
} catch (error) {
|
|
4430
|
-
this.logger.warn("
|
|
6459
|
+
this.logger.warn("account-health sweep failed", {
|
|
4431
6460
|
error: error instanceof Error ? error.message : String(error)
|
|
4432
6461
|
});
|
|
4433
6462
|
} finally {
|
|
@@ -4442,54 +6471,777 @@ var TokenRefreshScheduler = class {
|
|
|
4442
6471
|
const expiresAt = Date.parse(t.expiresAt);
|
|
4443
6472
|
return Number.isFinite(expiresAt) && now >= expiresAt - this.leadMs;
|
|
4444
6473
|
}
|
|
4445
|
-
/** Refresh one account; failures are logged, never thrown
|
|
4446
|
-
|
|
4447
|
-
async refreshOne(provider, id, isActive) {
|
|
6474
|
+
/** Refresh one recovered account by id; failures are logged, never thrown. */
|
|
6475
|
+
async refreshOne(provider, id) {
|
|
4448
6476
|
try {
|
|
4449
|
-
const ok =
|
|
4450
|
-
if (
|
|
4451
|
-
|
|
4452
|
-
} else {
|
|
4453
|
-
this.logger.info("background token refresh succeeded", { provider, accountId: id });
|
|
4454
|
-
}
|
|
6477
|
+
const ok = await this.store.refreshAccountById(provider, id);
|
|
6478
|
+
if (ok) this.logger.info("account-health recovery refresh succeeded", { provider, accountId: id });
|
|
6479
|
+
else this.logger.warn("account-health recovery refresh failed", { provider, accountId: id });
|
|
4455
6480
|
} catch (error) {
|
|
4456
|
-
this.logger.warn("
|
|
6481
|
+
this.logger.warn("account-health recovery refresh threw", {
|
|
4457
6482
|
provider,
|
|
4458
6483
|
accountId: id,
|
|
4459
6484
|
error: error instanceof Error ? error.message : String(error)
|
|
4460
6485
|
});
|
|
4461
6486
|
}
|
|
4462
6487
|
}
|
|
4463
|
-
refreshActive(provider) {
|
|
4464
|
-
switch (provider) {
|
|
4465
|
-
case "claude":
|
|
4466
|
-
return this.store.refreshClaudeToken();
|
|
4467
|
-
case "codex":
|
|
4468
|
-
return this.store.refreshCodexToken();
|
|
4469
|
-
case "gemini":
|
|
4470
|
-
return this.store.refreshGeminiToken();
|
|
4471
|
-
}
|
|
4472
|
-
}
|
|
4473
6488
|
};
|
|
4474
6489
|
|
|
4475
|
-
// src/
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
const
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
);
|
|
4492
|
-
(
|
|
6490
|
+
// src/audit/AuditPruneSweeper.ts
|
|
6491
|
+
var import_node_fs14 = require("fs");
|
|
6492
|
+
var import_node_path8 = require("path");
|
|
6493
|
+
|
|
6494
|
+
// src/audit/auditFiles.ts
|
|
6495
|
+
var AUDIT_FILE_RE = /^audit-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
6496
|
+
var pad22 = (n) => String(n).padStart(2, "0");
|
|
6497
|
+
function auditFileName(ts) {
|
|
6498
|
+
const d = new Date(ts);
|
|
6499
|
+
return `audit-${d.getFullYear()}-${pad22(d.getMonth() + 1)}-${pad22(d.getDate())}.jsonl`;
|
|
6500
|
+
}
|
|
6501
|
+
function auditFileDateMs(fileName) {
|
|
6502
|
+
const m = AUDIT_FILE_RE.exec(fileName);
|
|
6503
|
+
if (!m) return null;
|
|
6504
|
+
const year = Number(m[1]);
|
|
6505
|
+
const month = Number(m[2]);
|
|
6506
|
+
const day = Number(m[3]);
|
|
6507
|
+
const d = new Date(year, month - 1, day);
|
|
6508
|
+
if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) {
|
|
6509
|
+
return null;
|
|
6510
|
+
}
|
|
6511
|
+
return d.getTime();
|
|
6512
|
+
}
|
|
6513
|
+
|
|
6514
|
+
// src/audit/AuditPruneSweeper.ts
|
|
6515
|
+
var DAY_MS = 24 * 60 * 6e4;
|
|
6516
|
+
var SWEEP_INTERVAL_MS2 = 60 * 6e4;
|
|
6517
|
+
var AuditPruneSweeper = class {
|
|
6518
|
+
constructor(auditDir, logger, config, intervalMs = SWEEP_INTERVAL_MS2, now = Date.now) {
|
|
6519
|
+
this.auditDir = auditDir;
|
|
6520
|
+
this.logger = logger;
|
|
6521
|
+
this.config = config;
|
|
6522
|
+
this.intervalMs = intervalMs;
|
|
6523
|
+
this.now = now;
|
|
6524
|
+
}
|
|
6525
|
+
auditDir;
|
|
6526
|
+
logger;
|
|
6527
|
+
config;
|
|
6528
|
+
intervalMs;
|
|
6529
|
+
now;
|
|
6530
|
+
timer = null;
|
|
6531
|
+
sweeping = false;
|
|
6532
|
+
/** Whether pruning is active (audit enabled). */
|
|
6533
|
+
get enabled() {
|
|
6534
|
+
return this.config.enabled;
|
|
6535
|
+
}
|
|
6536
|
+
/** Re-apply config to the live instance (boot + admin PUT hot-reload). */
|
|
6537
|
+
configure(config) {
|
|
6538
|
+
this.config = config;
|
|
6539
|
+
}
|
|
6540
|
+
/**
|
|
6541
|
+
* Arm the prune interval AND run one prune immediately (boot cleanup). No-op
|
|
6542
|
+
* when audit is disabled (zero regression). Idempotent.
|
|
6543
|
+
*/
|
|
6544
|
+
start() {
|
|
6545
|
+
if (this.timer || !this.config.enabled) return;
|
|
6546
|
+
void this.sweep();
|
|
6547
|
+
this.timer = setInterval(() => void this.sweep(), this.intervalMs);
|
|
6548
|
+
this.timer.unref?.();
|
|
6549
|
+
}
|
|
6550
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6551
|
+
dispose() {
|
|
6552
|
+
if (this.timer) {
|
|
6553
|
+
clearInterval(this.timer);
|
|
6554
|
+
this.timer = null;
|
|
6555
|
+
}
|
|
6556
|
+
}
|
|
6557
|
+
/**
|
|
6558
|
+
* One prune: unlink every audit date file strictly OLDER than the retention
|
|
6559
|
+
* cutoff (`now - retentionDays` days, at local-midnight granularity). Exposed
|
|
6560
|
+
* for tests; never throws. Returns the number of files removed.
|
|
6561
|
+
*/
|
|
6562
|
+
async sweep() {
|
|
6563
|
+
if (!this.config.enabled || this.sweeping) return 0;
|
|
6564
|
+
this.sweeping = true;
|
|
6565
|
+
try {
|
|
6566
|
+
if (!(0, import_node_fs14.existsSync)(this.auditDir)) return 0;
|
|
6567
|
+
const today = new Date(this.now());
|
|
6568
|
+
const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
|
|
6569
|
+
const cutoff = todayMidnight - (this.config.retentionDays - 1) * DAY_MS;
|
|
6570
|
+
let removed = 0;
|
|
6571
|
+
for (const file of (0, import_node_fs14.readdirSync)(this.auditDir)) {
|
|
6572
|
+
const dateMs = auditFileDateMs(file);
|
|
6573
|
+
if (dateMs === null || dateMs >= cutoff) continue;
|
|
6574
|
+
try {
|
|
6575
|
+
(0, import_node_fs14.unlinkSync)((0, import_node_path8.join)(this.auditDir, file));
|
|
6576
|
+
removed += 1;
|
|
6577
|
+
} catch (error) {
|
|
6578
|
+
this.logger.warn("[AuditPruneSweeper] failed to unlink expired audit file", {
|
|
6579
|
+
file,
|
|
6580
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6581
|
+
});
|
|
6582
|
+
}
|
|
6583
|
+
}
|
|
6584
|
+
if (removed > 0) this.logger.debug("audit prune complete", { removed });
|
|
6585
|
+
return removed;
|
|
6586
|
+
} catch (error) {
|
|
6587
|
+
this.logger.warn("audit prune sweep failed", {
|
|
6588
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6589
|
+
});
|
|
6590
|
+
return 0;
|
|
6591
|
+
} finally {
|
|
6592
|
+
this.sweeping = false;
|
|
6593
|
+
}
|
|
6594
|
+
}
|
|
6595
|
+
};
|
|
6596
|
+
|
|
6597
|
+
// src/audit/auditReader.ts
|
|
6598
|
+
var import_node_fs15 = require("fs");
|
|
6599
|
+
var import_node_path9 = require("path");
|
|
6600
|
+
var DEFAULT_LIMIT = 200;
|
|
6601
|
+
var MAX_LIMIT = 2e3;
|
|
6602
|
+
function readAuditRecords(auditDir, query = {}) {
|
|
6603
|
+
if (!(0, import_node_fs15.existsSync)(auditDir)) return [];
|
|
6604
|
+
let files;
|
|
6605
|
+
try {
|
|
6606
|
+
files = (0, import_node_fs15.readdirSync)(auditDir).filter((f) => AUDIT_FILE_RE.test(f));
|
|
6607
|
+
} catch {
|
|
6608
|
+
return [];
|
|
6609
|
+
}
|
|
6610
|
+
const from = typeof query.from === "number" ? query.from : -Infinity;
|
|
6611
|
+
const to = typeof query.to === "number" ? query.to : Infinity;
|
|
6612
|
+
const limit = Math.min(MAX_LIMIT, Math.max(1, Math.trunc(query.limit ?? DEFAULT_LIMIT)));
|
|
6613
|
+
const matched = [];
|
|
6614
|
+
for (const file of files.sort().reverse()) {
|
|
6615
|
+
let raw;
|
|
6616
|
+
try {
|
|
6617
|
+
raw = (0, import_node_fs15.readFileSync)((0, import_node_path9.join)(auditDir, file), "utf8");
|
|
6618
|
+
} catch {
|
|
6619
|
+
continue;
|
|
6620
|
+
}
|
|
6621
|
+
for (const line of raw.split("\n")) {
|
|
6622
|
+
const trimmed = line.trim();
|
|
6623
|
+
if (!trimmed) continue;
|
|
6624
|
+
let rec;
|
|
6625
|
+
try {
|
|
6626
|
+
rec = JSON.parse(trimmed);
|
|
6627
|
+
} catch {
|
|
6628
|
+
continue;
|
|
6629
|
+
}
|
|
6630
|
+
if (!isAuditRecord(rec)) continue;
|
|
6631
|
+
if (query.keyId !== void 0 && rec.keyId !== query.keyId) continue;
|
|
6632
|
+
if (rec.ts < from || rec.ts > to) continue;
|
|
6633
|
+
matched.push(rec);
|
|
6634
|
+
}
|
|
6635
|
+
}
|
|
6636
|
+
matched.sort((a, b) => b.ts - a.ts);
|
|
6637
|
+
return matched.slice(0, limit);
|
|
6638
|
+
}
|
|
6639
|
+
function isAuditRecord(value) {
|
|
6640
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
6641
|
+
const r = value;
|
|
6642
|
+
return typeof r["id"] === "string" && typeof r["ts"] === "number" && typeof r["method"] === "string" && typeof r["path"] === "string" && typeof r["status"] === "number";
|
|
6643
|
+
}
|
|
6644
|
+
|
|
6645
|
+
// src/audit/AuditWriter.ts
|
|
6646
|
+
var import_node_fs16 = require("fs");
|
|
6647
|
+
var import_node_path10 = require("path");
|
|
6648
|
+
var AuditWriter = class {
|
|
6649
|
+
constructor(auditDir, logger, defer = (fn) => setTimeout(fn, 0)) {
|
|
6650
|
+
this.auditDir = auditDir;
|
|
6651
|
+
this.logger = logger;
|
|
6652
|
+
this.defer = defer;
|
|
6653
|
+
}
|
|
6654
|
+
auditDir;
|
|
6655
|
+
logger;
|
|
6656
|
+
defer;
|
|
6657
|
+
dirEnsured = false;
|
|
6658
|
+
/**
|
|
6659
|
+
* Enqueue one record for append. Returns IMMEDIATELY (fire-and-forget); the fs
|
|
6660
|
+
* write happens on the deferred tick. A failure is logged, never thrown.
|
|
6661
|
+
*/
|
|
6662
|
+
record(record) {
|
|
6663
|
+
this.defer(() => {
|
|
6664
|
+
try {
|
|
6665
|
+
this.appendNow(record);
|
|
6666
|
+
} catch (error) {
|
|
6667
|
+
this.logger.warn("[AuditWriter] failed to append audit record", {
|
|
6668
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6669
|
+
});
|
|
6670
|
+
}
|
|
6671
|
+
});
|
|
6672
|
+
}
|
|
6673
|
+
/**
|
|
6674
|
+
* Append synchronously — the awaitable form tests use to assert the line landed.
|
|
6675
|
+
* Ensures the `audit/` directory exists on first write (lazy, like the usage
|
|
6676
|
+
* store's lazy file creation).
|
|
6677
|
+
*/
|
|
6678
|
+
appendNow(record) {
|
|
6679
|
+
if (!this.dirEnsured) {
|
|
6680
|
+
(0, import_node_fs16.mkdirSync)(this.auditDir, { recursive: true });
|
|
6681
|
+
this.dirEnsured = true;
|
|
6682
|
+
}
|
|
6683
|
+
const file = (0, import_node_path10.join)(this.auditDir, auditFileName(record.ts));
|
|
6684
|
+
(0, import_node_fs16.appendFileSync)(file, JSON.stringify(record) + "\n", "utf8");
|
|
6685
|
+
}
|
|
6686
|
+
};
|
|
6687
|
+
|
|
6688
|
+
// src/billing/BillingPublisher.ts
|
|
6689
|
+
var import_node_fs17 = require("fs");
|
|
6690
|
+
var import_node_crypto10 = require("crypto");
|
|
6691
|
+
var import_node_path11 = require("path");
|
|
6692
|
+
var import_upstreamFetch5 = require("@omnicross/core/pipeline/upstreamFetch");
|
|
6693
|
+
|
|
6694
|
+
// src/billing/billingFiles.ts
|
|
6695
|
+
var BILLING_FILE_RE = /^billing-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
6696
|
+
var DELIVERED_FILE_RE = /^delivered-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
6697
|
+
var pad23 = (n) => String(n).padStart(2, "0");
|
|
6698
|
+
function dateStamp(ts) {
|
|
6699
|
+
const d = new Date(ts);
|
|
6700
|
+
return `${d.getFullYear()}-${pad23(d.getMonth() + 1)}-${pad23(d.getDate())}`;
|
|
6701
|
+
}
|
|
6702
|
+
function billingFileName(ts) {
|
|
6703
|
+
return `billing-${dateStamp(ts)}.jsonl`;
|
|
6704
|
+
}
|
|
6705
|
+
function deliveredFileName(ts) {
|
|
6706
|
+
return `delivered-${dateStamp(ts)}.jsonl`;
|
|
6707
|
+
}
|
|
6708
|
+
|
|
6709
|
+
// src/billing/BillingPublisher.ts
|
|
6710
|
+
var BILLING_POST_TIMEOUT_MS = 1e4;
|
|
6711
|
+
var BillingPublisher = class {
|
|
6712
|
+
constructor(billingDir, logger, opts = {}) {
|
|
6713
|
+
this.billingDir = billingDir;
|
|
6714
|
+
this.logger = logger;
|
|
6715
|
+
this.fetchImpl = opts.fetchImpl ?? ((url, init) => (0, import_upstreamFetch5.fetchUpstream)(url, init));
|
|
6716
|
+
this.defer = opts.defer ?? ((fn) => setTimeout(fn, 0));
|
|
6717
|
+
this.timeoutMs = opts.timeoutMs ?? BILLING_POST_TIMEOUT_MS;
|
|
6718
|
+
this.now = opts.now ?? Date.now;
|
|
6719
|
+
}
|
|
6720
|
+
billingDir;
|
|
6721
|
+
logger;
|
|
6722
|
+
config;
|
|
6723
|
+
dirEnsured = false;
|
|
6724
|
+
fetchImpl;
|
|
6725
|
+
defer;
|
|
6726
|
+
timeoutMs;
|
|
6727
|
+
now;
|
|
6728
|
+
/** Install/replace the live billing config (endpoint + secret + retry bound). */
|
|
6729
|
+
setConfig(config) {
|
|
6730
|
+
this.config = config;
|
|
6731
|
+
}
|
|
6732
|
+
/**
|
|
6733
|
+
* Record one billing event. DURABLE-FIRST: append synchronously (the event is
|
|
6734
|
+
* now on disk, never lost), THEN schedule a best-effort POST off the caller's
|
|
6735
|
+
* stack (non-blocking; ledger-only when no endpoint). Returns IMMEDIATELY and
|
|
6736
|
+
* NEVER throws — a failing append/POST is logged, never propagated.
|
|
6737
|
+
*/
|
|
6738
|
+
record(event) {
|
|
6739
|
+
let appended = false;
|
|
6740
|
+
try {
|
|
6741
|
+
this.appendNow(event);
|
|
6742
|
+
appended = true;
|
|
6743
|
+
} catch (error) {
|
|
6744
|
+
this.logger.warn("[BillingPublisher] failed to append billing event", {
|
|
6745
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6746
|
+
});
|
|
6747
|
+
}
|
|
6748
|
+
if (appended && this.config?.endpoint) {
|
|
6749
|
+
this.defer(() => {
|
|
6750
|
+
void this.deliverNow(event).catch(() => {
|
|
6751
|
+
});
|
|
6752
|
+
});
|
|
6753
|
+
}
|
|
6754
|
+
}
|
|
6755
|
+
/**
|
|
6756
|
+
* Append the event as one JSON line to `billing-YYYY-MM-DD.jsonl` (the event's
|
|
6757
|
+
* LOCAL date). Synchronous — the awaitable form tests use to assert the ledger
|
|
6758
|
+
* line landed BEFORE any delivery. Ensures the `billing/` directory on first write.
|
|
6759
|
+
*/
|
|
6760
|
+
appendNow(event) {
|
|
6761
|
+
this.ensureDir();
|
|
6762
|
+
const file = (0, import_node_path11.join)(this.billingDir, billingFileName(event.ts));
|
|
6763
|
+
(0, import_node_fs17.appendFileSync)(file, JSON.stringify(event) + "\n", "utf8");
|
|
6764
|
+
}
|
|
6765
|
+
/**
|
|
6766
|
+
* One best-effort delivery attempt for an event ALREADY in the ledger. POSTs the
|
|
6767
|
+
* event JSON to the configured endpoint (optionally HMAC-signed); on a 2xx ack
|
|
6768
|
+
* appends a delivery marker and returns `true`. Any non-2xx / thrown / timed-out
|
|
6769
|
+
* attempt returns `false` — the event stays UNdelivered in the ledger (never
|
|
6770
|
+
* lost). NEVER rejects. A no-op `false` when no endpoint is configured.
|
|
6771
|
+
*/
|
|
6772
|
+
async deliverNow(event) {
|
|
6773
|
+
const endpoint = this.config?.endpoint;
|
|
6774
|
+
if (!endpoint) return false;
|
|
6775
|
+
try {
|
|
6776
|
+
const body = JSON.stringify(event);
|
|
6777
|
+
const headers = { "Content-Type": "application/json" };
|
|
6778
|
+
const secret = this.config?.secret;
|
|
6779
|
+
if (secret) {
|
|
6780
|
+
const hmac = (0, import_node_crypto10.createHmac)("sha256", secret).update(body).digest("hex");
|
|
6781
|
+
headers["X-Omnicross-Billing-Signature"] = `sha256=${hmac}`;
|
|
6782
|
+
}
|
|
6783
|
+
const res = await this.fetchImpl(endpoint, {
|
|
6784
|
+
method: "POST",
|
|
6785
|
+
headers,
|
|
6786
|
+
body,
|
|
6787
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
6788
|
+
});
|
|
6789
|
+
if (!res.ok) {
|
|
6790
|
+
this.logger.debug(`[billing] delivery failed ${event.id} (HTTP ${res.status})`);
|
|
6791
|
+
return false;
|
|
6792
|
+
}
|
|
6793
|
+
this.markDelivered(event);
|
|
6794
|
+
this.logger.debug(`[billing] delivered ${event.id}`);
|
|
6795
|
+
return true;
|
|
6796
|
+
} catch (error) {
|
|
6797
|
+
this.logger.debug(
|
|
6798
|
+
`[billing] delivery error ${event.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
6799
|
+
);
|
|
6800
|
+
return false;
|
|
6801
|
+
}
|
|
6802
|
+
}
|
|
6803
|
+
/**
|
|
6804
|
+
* Append a delivery marker `{ id, deliveredAt }` to `delivered-YYYY-MM-DD.jsonl`
|
|
6805
|
+
* (keyed by the EVENT's date so the reader finds both together). Idempotent at
|
|
6806
|
+
* the reconciliation layer — the reader unions marker ids into a delivered set,
|
|
6807
|
+
* so a duplicate marker is harmless. A marker-write failure is logged, never thrown.
|
|
6808
|
+
*/
|
|
6809
|
+
markDelivered(event) {
|
|
6810
|
+
try {
|
|
6811
|
+
this.ensureDir();
|
|
6812
|
+
const file = (0, import_node_path11.join)(this.billingDir, deliveredFileName(event.ts));
|
|
6813
|
+
(0, import_node_fs17.appendFileSync)(file, JSON.stringify({ id: event.id, deliveredAt: this.now() }) + "\n", "utf8");
|
|
6814
|
+
} catch (error) {
|
|
6815
|
+
this.logger.warn("[BillingPublisher] failed to append delivery marker", {
|
|
6816
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6817
|
+
});
|
|
6818
|
+
}
|
|
6819
|
+
}
|
|
6820
|
+
ensureDir() {
|
|
6821
|
+
if (this.dirEnsured) return;
|
|
6822
|
+
(0, import_node_fs17.mkdirSync)(this.billingDir, { recursive: true });
|
|
6823
|
+
this.dirEnsured = true;
|
|
6824
|
+
}
|
|
6825
|
+
};
|
|
6826
|
+
|
|
6827
|
+
// src/billing/billingReader.ts
|
|
6828
|
+
var import_node_fs18 = require("fs");
|
|
6829
|
+
var import_node_path12 = require("path");
|
|
6830
|
+
function readBillingLedger(billingDir) {
|
|
6831
|
+
const view = { events: [], deliveredIds: /* @__PURE__ */ new Set() };
|
|
6832
|
+
if (!(0, import_node_fs18.existsSync)(billingDir)) return view;
|
|
6833
|
+
let files;
|
|
6834
|
+
try {
|
|
6835
|
+
files = (0, import_node_fs18.readdirSync)(billingDir);
|
|
6836
|
+
} catch {
|
|
6837
|
+
return view;
|
|
6838
|
+
}
|
|
6839
|
+
for (const file of files.sort()) {
|
|
6840
|
+
if (BILLING_FILE_RE.test(file)) {
|
|
6841
|
+
for (const rec of parseLines(billingDir, file)) {
|
|
6842
|
+
if (isBillingEvent(rec)) view.events.push(rec);
|
|
6843
|
+
}
|
|
6844
|
+
} else if (DELIVERED_FILE_RE.test(file)) {
|
|
6845
|
+
for (const rec of parseLines(billingDir, file)) {
|
|
6846
|
+
const id = rec.id;
|
|
6847
|
+
if (typeof id === "string") view.deliveredIds.add(id);
|
|
6848
|
+
}
|
|
6849
|
+
}
|
|
6850
|
+
}
|
|
6851
|
+
return view;
|
|
6852
|
+
}
|
|
6853
|
+
function readUndeliveredEvents(billingDir) {
|
|
6854
|
+
const { events, deliveredIds } = readBillingLedger(billingDir);
|
|
6855
|
+
return events.filter((e) => !deliveredIds.has(e.id)).sort((a, b) => a.ts - b.ts);
|
|
6856
|
+
}
|
|
6857
|
+
function readBillingStatus(billingDir) {
|
|
6858
|
+
const { events, deliveredIds } = readBillingLedger(billingDir);
|
|
6859
|
+
let delivered = 0;
|
|
6860
|
+
for (const e of events) if (deliveredIds.has(e.id)) delivered += 1;
|
|
6861
|
+
return { total: events.length, delivered, pending: events.length - delivered };
|
|
6862
|
+
}
|
|
6863
|
+
function parseLines(dir, file) {
|
|
6864
|
+
let raw;
|
|
6865
|
+
try {
|
|
6866
|
+
raw = (0, import_node_fs18.readFileSync)((0, import_node_path12.join)(dir, file), "utf8");
|
|
6867
|
+
} catch {
|
|
6868
|
+
return [];
|
|
6869
|
+
}
|
|
6870
|
+
const out = [];
|
|
6871
|
+
for (const line of raw.split("\n")) {
|
|
6872
|
+
const trimmed = line.trim();
|
|
6873
|
+
if (!trimmed) continue;
|
|
6874
|
+
try {
|
|
6875
|
+
out.push(JSON.parse(trimmed));
|
|
6876
|
+
} catch {
|
|
6877
|
+
}
|
|
6878
|
+
}
|
|
6879
|
+
return out;
|
|
6880
|
+
}
|
|
6881
|
+
function isBillingEvent(value) {
|
|
6882
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
6883
|
+
const r = value;
|
|
6884
|
+
return typeof r["id"] === "string" && typeof r["ts"] === "number" && typeof r["model"] === "string" && typeof r["status"] === "number";
|
|
6885
|
+
}
|
|
6886
|
+
|
|
6887
|
+
// src/billing/BillingRetrySweeper.ts
|
|
6888
|
+
var SWEEP_INTERVAL_MS3 = 5 * 6e4;
|
|
6889
|
+
var BillingRetrySweeper = class {
|
|
6890
|
+
constructor(billingDir, publisher2, logger, config, intervalMs = SWEEP_INTERVAL_MS3, now = Date.now) {
|
|
6891
|
+
this.billingDir = billingDir;
|
|
6892
|
+
this.publisher = publisher2;
|
|
6893
|
+
this.logger = logger;
|
|
6894
|
+
this.config = config;
|
|
6895
|
+
this.intervalMs = intervalMs;
|
|
6896
|
+
this.now = now;
|
|
6897
|
+
}
|
|
6898
|
+
billingDir;
|
|
6899
|
+
publisher;
|
|
6900
|
+
logger;
|
|
6901
|
+
config;
|
|
6902
|
+
intervalMs;
|
|
6903
|
+
now;
|
|
6904
|
+
timer = null;
|
|
6905
|
+
sweeping = false;
|
|
6906
|
+
/** Whether retrying is active: billing enabled AND an endpoint is configured. */
|
|
6907
|
+
get enabled() {
|
|
6908
|
+
return this.config.enabled && typeof this.config.endpoint === "string" && this.config.endpoint.length > 0;
|
|
6909
|
+
}
|
|
6910
|
+
/** Re-apply config to the live instance (boot + admin PUT hot-reload). */
|
|
6911
|
+
configure(config) {
|
|
6912
|
+
this.config = config;
|
|
6913
|
+
}
|
|
6914
|
+
/**
|
|
6915
|
+
* Arm the retry interval AND run one sweep immediately (boot catch-up for events
|
|
6916
|
+
* that failed to deliver while the daemon was down). No-op when disabled or in
|
|
6917
|
+
* ledger-only mode (no endpoint to POST to). Idempotent.
|
|
6918
|
+
*/
|
|
6919
|
+
start() {
|
|
6920
|
+
if (this.timer || !this.enabled) return;
|
|
6921
|
+
void this.sweep();
|
|
6922
|
+
this.timer = setInterval(() => void this.sweep(), this.intervalMs);
|
|
6923
|
+
this.timer.unref?.();
|
|
6924
|
+
}
|
|
6925
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6926
|
+
dispose() {
|
|
6927
|
+
if (this.timer) {
|
|
6928
|
+
clearInterval(this.timer);
|
|
6929
|
+
this.timer = null;
|
|
6930
|
+
}
|
|
6931
|
+
}
|
|
6932
|
+
/**
|
|
6933
|
+
* One sweep: re-POST every UNdelivered ledger event still within
|
|
6934
|
+
* `maxRetryAgeMs`; leave over-age undelivered events for reconciliation (NEVER
|
|
6935
|
+
* deleted). Exposed for tests; never throws. Returns the number of events a
|
|
6936
|
+
* re-POST was attempted for.
|
|
6937
|
+
*/
|
|
6938
|
+
async sweep() {
|
|
6939
|
+
if (!this.enabled || this.sweeping) return 0;
|
|
6940
|
+
this.sweeping = true;
|
|
6941
|
+
try {
|
|
6942
|
+
const cutoff = this.now() - this.config.maxRetryAgeMs;
|
|
6943
|
+
let attempted = 0;
|
|
6944
|
+
for (const event of readUndeliveredEvents(this.billingDir)) {
|
|
6945
|
+
if (event.ts < cutoff) continue;
|
|
6946
|
+
attempted += 1;
|
|
6947
|
+
await this.publisher.deliverNow(event);
|
|
6948
|
+
}
|
|
6949
|
+
if (attempted > 0) this.logger.debug("billing retry sweep complete", { attempted });
|
|
6950
|
+
return attempted;
|
|
6951
|
+
} catch (error) {
|
|
6952
|
+
this.logger.warn("billing retry sweep failed", {
|
|
6953
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6954
|
+
});
|
|
6955
|
+
return 0;
|
|
6956
|
+
} finally {
|
|
6957
|
+
this.sweeping = false;
|
|
6958
|
+
}
|
|
6959
|
+
}
|
|
6960
|
+
};
|
|
6961
|
+
|
|
6962
|
+
// src/TokenRefreshScheduler.ts
|
|
6963
|
+
var REFRESH_LEAD_MS2 = 5 * 6e4;
|
|
6964
|
+
var SWEEP_INTERVAL_MS4 = 6e4;
|
|
6965
|
+
var OAUTH_PROVIDERS2 = ["claude", "codex", "gemini"];
|
|
6966
|
+
var TokenRefreshScheduler = class {
|
|
6967
|
+
constructor(store, logger, intervalMs = SWEEP_INTERVAL_MS4, leadMs = REFRESH_LEAD_MS2) {
|
|
6968
|
+
this.store = store;
|
|
6969
|
+
this.logger = logger;
|
|
6970
|
+
this.intervalMs = intervalMs;
|
|
6971
|
+
this.leadMs = leadMs;
|
|
6972
|
+
}
|
|
6973
|
+
store;
|
|
6974
|
+
logger;
|
|
6975
|
+
intervalMs;
|
|
6976
|
+
leadMs;
|
|
6977
|
+
timer = null;
|
|
6978
|
+
sweeping = false;
|
|
6979
|
+
/** Arm the sweep interval. Idempotent. The timer never holds the loop open. */
|
|
6980
|
+
start() {
|
|
6981
|
+
if (this.timer) return;
|
|
6982
|
+
this.timer = setInterval(() => void this.sweep(), this.intervalMs);
|
|
6983
|
+
this.timer.unref?.();
|
|
6984
|
+
}
|
|
6985
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6986
|
+
dispose() {
|
|
6987
|
+
if (this.timer) {
|
|
6988
|
+
clearInterval(this.timer);
|
|
6989
|
+
this.timer = null;
|
|
6990
|
+
}
|
|
6991
|
+
}
|
|
6992
|
+
/** One sweep over every account of every OAuth provider. Exposed for tests. */
|
|
6993
|
+
async sweep(now = Date.now()) {
|
|
6994
|
+
if (this.sweeping) return;
|
|
6995
|
+
this.sweeping = true;
|
|
6996
|
+
try {
|
|
6997
|
+
const config = await this.store.getFullConfig();
|
|
6998
|
+
for (const provider of OAUTH_PROVIDERS2) {
|
|
6999
|
+
const activeId = getActiveAccount(config, provider)?.id;
|
|
7000
|
+
for (const account of listAccounts(config, provider)) {
|
|
7001
|
+
if (!this.needsRefresh(account.tokens, now)) continue;
|
|
7002
|
+
await this.refreshOne(provider, account.id, account.id === activeId);
|
|
7003
|
+
}
|
|
7004
|
+
}
|
|
7005
|
+
} catch (error) {
|
|
7006
|
+
this.logger.warn("token-refresh sweep failed", {
|
|
7007
|
+
error: error instanceof Error ? error.message : String(error)
|
|
7008
|
+
});
|
|
7009
|
+
} finally {
|
|
7010
|
+
this.sweeping = false;
|
|
7011
|
+
}
|
|
7012
|
+
}
|
|
7013
|
+
/** Expiring within the lead window, refreshable, and not already dead. */
|
|
7014
|
+
needsRefresh(tokens, now) {
|
|
7015
|
+
const t = tokens;
|
|
7016
|
+
if (!t.refreshToken || t.status === "expired" || t.status === "error") return false;
|
|
7017
|
+
if (!t.expiresAt) return false;
|
|
7018
|
+
const expiresAt = Date.parse(t.expiresAt);
|
|
7019
|
+
return Number.isFinite(expiresAt) && now >= expiresAt - this.leadMs;
|
|
7020
|
+
}
|
|
7021
|
+
/** Refresh one account; failures are logged, never thrown (the store has
|
|
7022
|
+
* already flagged the account `expired`). */
|
|
7023
|
+
async refreshOne(provider, id, isActive) {
|
|
7024
|
+
try {
|
|
7025
|
+
const ok = isActive ? await this.refreshActive(provider) : await this.store.refreshAccountById(provider, id);
|
|
7026
|
+
if (!ok) {
|
|
7027
|
+
this.logger.warn("background token refresh failed", { provider, accountId: id });
|
|
7028
|
+
} else {
|
|
7029
|
+
this.logger.info("background token refresh succeeded", { provider, accountId: id });
|
|
7030
|
+
}
|
|
7031
|
+
} catch (error) {
|
|
7032
|
+
this.logger.warn("background token refresh threw", {
|
|
7033
|
+
provider,
|
|
7034
|
+
accountId: id,
|
|
7035
|
+
error: error instanceof Error ? error.message : String(error)
|
|
7036
|
+
});
|
|
7037
|
+
}
|
|
7038
|
+
}
|
|
7039
|
+
refreshActive(provider) {
|
|
7040
|
+
switch (provider) {
|
|
7041
|
+
case "claude":
|
|
7042
|
+
return this.store.refreshClaudeToken();
|
|
7043
|
+
case "codex":
|
|
7044
|
+
return this.store.refreshCodexToken();
|
|
7045
|
+
case "gemini":
|
|
7046
|
+
return this.store.refreshGeminiToken();
|
|
7047
|
+
}
|
|
7048
|
+
}
|
|
7049
|
+
};
|
|
7050
|
+
|
|
7051
|
+
// src/webhook/WebhookDispatcher.ts
|
|
7052
|
+
var import_node_crypto11 = require("crypto");
|
|
7053
|
+
var import_upstreamFetch6 = require("@omnicross/core/pipeline/upstreamFetch");
|
|
7054
|
+
var WEBHOOK_MAX_ATTEMPTS = 3;
|
|
7055
|
+
var WEBHOOK_QUEUE_MAX = 1e3;
|
|
7056
|
+
var WEBHOOK_SEND_TIMEOUT_MS = 1e4;
|
|
7057
|
+
var WEBHOOK_BASE_BACKOFF_MS = 200;
|
|
7058
|
+
var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
7059
|
+
var WebhookDispatcher = class {
|
|
7060
|
+
config;
|
|
7061
|
+
queue = [];
|
|
7062
|
+
draining = false;
|
|
7063
|
+
warnedFull = false;
|
|
7064
|
+
fetchImpl;
|
|
7065
|
+
logger;
|
|
7066
|
+
maxAttempts;
|
|
7067
|
+
queueMax;
|
|
7068
|
+
timeoutMs;
|
|
7069
|
+
baseBackoffMs;
|
|
7070
|
+
sleep;
|
|
7071
|
+
now;
|
|
7072
|
+
constructor(opts = {}) {
|
|
7073
|
+
this.fetchImpl = opts.fetchImpl ?? ((url, init) => (0, import_upstreamFetch6.fetchUpstream)(url, init));
|
|
7074
|
+
this.logger = opts.logger;
|
|
7075
|
+
this.maxAttempts = opts.maxAttempts ?? WEBHOOK_MAX_ATTEMPTS;
|
|
7076
|
+
this.queueMax = opts.queueMax ?? WEBHOOK_QUEUE_MAX;
|
|
7077
|
+
this.timeoutMs = opts.timeoutMs ?? WEBHOOK_SEND_TIMEOUT_MS;
|
|
7078
|
+
this.baseBackoffMs = opts.baseBackoffMs ?? WEBHOOK_BASE_BACKOFF_MS;
|
|
7079
|
+
this.sleep = opts.sleep ?? defaultSleep;
|
|
7080
|
+
this.now = opts.now ?? Date.now;
|
|
7081
|
+
}
|
|
7082
|
+
/** Install/replace the live webhook config (destinations + master switch). */
|
|
7083
|
+
setConfig(config) {
|
|
7084
|
+
this.config = config;
|
|
7085
|
+
}
|
|
7086
|
+
/**
|
|
7087
|
+
* Enqueue an event and return IMMEDIATELY (fire-and-forget). NEVER awaits a
|
|
7088
|
+
* send, NEVER throws — the drain loop does all sending on a side channel. A
|
|
7089
|
+
* full queue drops the OLDEST event (with a one-shot warn) so a runaway source
|
|
7090
|
+
* can't OOM the process.
|
|
7091
|
+
*/
|
|
7092
|
+
emit(event) {
|
|
7093
|
+
if (this.queue.length >= this.queueMax) {
|
|
7094
|
+
this.queue.shift();
|
|
7095
|
+
if (!this.warnedFull) {
|
|
7096
|
+
this.logger?.warn("[webhook] queue full \u2014 dropping oldest events");
|
|
7097
|
+
this.warnedFull = true;
|
|
7098
|
+
}
|
|
7099
|
+
}
|
|
7100
|
+
this.queue.push(event);
|
|
7101
|
+
if (!this.draining) {
|
|
7102
|
+
this.draining = true;
|
|
7103
|
+
queueMicrotask(() => void this.drain());
|
|
7104
|
+
}
|
|
7105
|
+
}
|
|
7106
|
+
/** Drain the queue, sending each event to its matching destinations concurrently. */
|
|
7107
|
+
async drain() {
|
|
7108
|
+
try {
|
|
7109
|
+
while (this.queue.length > 0) {
|
|
7110
|
+
const event = this.queue.shift();
|
|
7111
|
+
const destinations = this.matchingDestinations(event.kind);
|
|
7112
|
+
if (destinations.length === 0) continue;
|
|
7113
|
+
await Promise.all(destinations.map((d) => this.sendWithRetry(event, d)));
|
|
7114
|
+
}
|
|
7115
|
+
} finally {
|
|
7116
|
+
this.draining = false;
|
|
7117
|
+
if (this.queue.length > 0) {
|
|
7118
|
+
this.draining = true;
|
|
7119
|
+
queueMicrotask(() => void this.drain());
|
|
7120
|
+
}
|
|
7121
|
+
}
|
|
7122
|
+
}
|
|
7123
|
+
/** The enabled destinations whose event filter admits this kind (empty ⇒ all). */
|
|
7124
|
+
matchingDestinations(kind) {
|
|
7125
|
+
const cfg = this.config;
|
|
7126
|
+
if (!cfg || !cfg.enabled) return [];
|
|
7127
|
+
return cfg.destinations.filter(
|
|
7128
|
+
(d) => d.enabled && (!d.events || d.events.length === 0 || d.events.includes(kind))
|
|
7129
|
+
);
|
|
7130
|
+
}
|
|
7131
|
+
/** Send with bounded exponential backoff; log-and-drop after `maxAttempts`. */
|
|
7132
|
+
async sendWithRetry(event, dest) {
|
|
7133
|
+
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
|
7134
|
+
const result = await this.sendOnce(event, dest);
|
|
7135
|
+
if (result.ok) {
|
|
7136
|
+
this.logger?.debug(`[webhook] delivered ${event.kind} \u2192 ${dest.id} (${result.status})`);
|
|
7137
|
+
return;
|
|
7138
|
+
}
|
|
7139
|
+
if (attempt < this.maxAttempts) {
|
|
7140
|
+
await this.sleep(this.baseBackoffMs * 2 ** (attempt - 1));
|
|
7141
|
+
} else {
|
|
7142
|
+
this.logger?.warn(
|
|
7143
|
+
`[webhook] dropped ${event.kind} \u2192 ${dest.id} after ${this.maxAttempts} attempts: ${result.error ?? `HTTP ${result.status}`}`
|
|
7144
|
+
);
|
|
7145
|
+
}
|
|
7146
|
+
}
|
|
7147
|
+
}
|
|
7148
|
+
/** One POST attempt. Returns an outcome; a thrown error becomes `{ ok:false }`. */
|
|
7149
|
+
async sendOnce(event, dest) {
|
|
7150
|
+
try {
|
|
7151
|
+
const { body, headers } = buildRequest(event, dest, this.now());
|
|
7152
|
+
const res = await this.fetchImpl(dest.url, {
|
|
7153
|
+
method: "POST",
|
|
7154
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
7155
|
+
body,
|
|
7156
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
7157
|
+
});
|
|
7158
|
+
return res.ok ? { ok: true, status: res.status } : { ok: false, status: res.status };
|
|
7159
|
+
} catch (err5) {
|
|
7160
|
+
return { ok: false, error: err5 instanceof Error ? err5.message : String(err5) };
|
|
7161
|
+
}
|
|
7162
|
+
}
|
|
7163
|
+
/**
|
|
7164
|
+
* ADMIN test path (design D8): deliver a `test` event to ONE destination and
|
|
7165
|
+
* AWAIT the single-attempt result. This is the ONLY awaited send — it runs on
|
|
7166
|
+
* the admin request path (an operator clicking "Test"), NEVER on a relay path,
|
|
7167
|
+
* so awaiting it is safe. Finds the destination regardless of its `enabled`
|
|
7168
|
+
* flag or the master switch (an explicit operator action).
|
|
7169
|
+
*/
|
|
7170
|
+
async deliverTest(destinationId) {
|
|
7171
|
+
const dest = this.config?.destinations.find((d) => d.id === destinationId);
|
|
7172
|
+
if (!dest) return { ok: false, error: "destination not found" };
|
|
7173
|
+
return this.sendOnce({ kind: "test", at: this.now() }, dest);
|
|
7174
|
+
}
|
|
7175
|
+
};
|
|
7176
|
+
function buildRequest(event, dest, nowMs) {
|
|
7177
|
+
if (dest.type === "feishu") return buildFeishu(event, dest, nowMs);
|
|
7178
|
+
return buildCustom(event, dest);
|
|
7179
|
+
}
|
|
7180
|
+
function buildCustom(event, dest) {
|
|
7181
|
+
const body = JSON.stringify(event);
|
|
7182
|
+
const headers = {};
|
|
7183
|
+
if (dest.secret) {
|
|
7184
|
+
const hmac = (0, import_node_crypto11.createHmac)("sha256", dest.secret).update(body).digest("hex");
|
|
7185
|
+
headers["X-Omnicross-Signature"] = `sha256=${hmac}`;
|
|
7186
|
+
}
|
|
7187
|
+
return { body, headers };
|
|
7188
|
+
}
|
|
7189
|
+
function buildFeishu(event, dest, nowMs) {
|
|
7190
|
+
const payload = {
|
|
7191
|
+
msg_type: "text",
|
|
7192
|
+
content: { text: feishuText(event) }
|
|
7193
|
+
};
|
|
7194
|
+
if (dest.secret) {
|
|
7195
|
+
const timestamp = Math.floor(nowMs / 1e3).toString();
|
|
7196
|
+
const stringToSign = `${timestamp}
|
|
7197
|
+
${dest.secret}`;
|
|
7198
|
+
payload["timestamp"] = timestamp;
|
|
7199
|
+
payload["sign"] = (0, import_node_crypto11.createHmac)("sha256", stringToSign).digest("base64");
|
|
7200
|
+
}
|
|
7201
|
+
return { body: JSON.stringify(payload), headers: {} };
|
|
7202
|
+
}
|
|
7203
|
+
function feishuText(event) {
|
|
7204
|
+
switch (event.kind) {
|
|
7205
|
+
case "account.recovery":
|
|
7206
|
+
return `omnicross: account recovered \u2014 ${event.providerId}/${event.accountId}`;
|
|
7207
|
+
case "account.anomaly":
|
|
7208
|
+
return `omnicross: account anomaly [${event.state}] \u2014 ${event.providerId}/${event.accountId}`;
|
|
7209
|
+
case "key.quotaWarning":
|
|
7210
|
+
return `omnicross: key quota warning (${event.scope}) \u2014 $${event.spentUsd.toFixed(2)} of $${event.limitUsd.toFixed(2)} (key ${event.keyId})`;
|
|
7211
|
+
case "key.quotaExceeded":
|
|
7212
|
+
return `omnicross: key quota EXCEEDED (${event.scope}) \u2014 $${event.spentUsd.toFixed(2)} of $${event.limitUsd.toFixed(2)} (key ${event.keyId})`;
|
|
7213
|
+
case "server.error":
|
|
7214
|
+
return `omnicross: server error \u2014 ${event.message}`;
|
|
7215
|
+
case "test":
|
|
7216
|
+
return "omnicross: webhook test";
|
|
7217
|
+
}
|
|
7218
|
+
}
|
|
7219
|
+
|
|
7220
|
+
// src/bootstrap.ts
|
|
7221
|
+
function buildDaemon(config, paths) {
|
|
7222
|
+
const logger = new ConfigurableLogger(config.logging);
|
|
7223
|
+
const secretBox3 = new SecretBox(() => resolveMasterKey({ keyFilePath: paths.masterKeyFilePath }));
|
|
7224
|
+
setSecretBox(secretBox3);
|
|
7225
|
+
setSecretBox2(secretBox3);
|
|
7226
|
+
const decryptedConfig = decryptConfigSecrets(config, secretBox3);
|
|
7227
|
+
const llmConfig = new ConfigFileProviderConfigSource(decryptedConfig);
|
|
7228
|
+
const keyDb = new JsonOutboundKeyDb(paths.keysPath);
|
|
7229
|
+
const voucherDb = new JsonVoucherDb(defaultVouchersPath(paths.configPath));
|
|
7230
|
+
const settingsStore = new JsonApiServerSettingsStore(paths.configPath, secretBox3);
|
|
7231
|
+
const credentialStore = new JsonSubscriptionCredentialStore(paths.tokensPath, secretBox3);
|
|
7232
|
+
const subscriptionAccounts = new import_subscriptions4.SubscriptionAccountService(credentialStore);
|
|
7233
|
+
(0, import_subscriptions4.setSubscriptionAccountService)(subscriptionAccounts);
|
|
7234
|
+
const subscriptionRegistry = new import_subscriptions4.SubscriptionProviderRegistry(
|
|
7235
|
+
subscriptionAccounts,
|
|
7236
|
+
credentialStore
|
|
7237
|
+
);
|
|
7238
|
+
(0, import_subscriptions4.setSubscriptionProviderRegistry)(subscriptionRegistry);
|
|
7239
|
+
setServerProxyConfig(decryptedConfig.server?.proxy);
|
|
7240
|
+
(0, import_upstreamFetch7.setUpstreamProxyResolver)(
|
|
7241
|
+
createUpstreamProxyResolver({
|
|
7242
|
+
getAccountProxy: (providerId, accountId) => credentialStore.getAccountProxy(providerId, accountId)
|
|
7243
|
+
})
|
|
7244
|
+
);
|
|
4493
7245
|
(0, import_gemini_code_assist_resolver.setGeminiCodeAssistResolver)((0, import_GeminiCodeAssistProjectResolver.getGeminiCodeAssistProjectResolver)());
|
|
4494
7246
|
const autoDisableStore = new AutoDisableStore();
|
|
4495
7247
|
const apiKeyPool = new import_ApiKeyPoolService.ApiKeyPoolService(
|
|
@@ -4510,19 +7262,59 @@ function buildDaemon(config, paths) {
|
|
|
4510
7262
|
defaultUsageEventsPath(paths.configPath),
|
|
4511
7263
|
async (providerId, model) => await pricingEngine.getEntry(providerId, model) !== null
|
|
4512
7264
|
);
|
|
4513
|
-
const
|
|
7265
|
+
const keySpendTracker = new import_outbound_api5.KeySpendTracker(usageEventStore);
|
|
7266
|
+
const usageRecorder = new import_usage.UsageRecorder(usageEventStore, pricingEngine, logger, {
|
|
7267
|
+
onRecord: (apiKeyId, costUsd, at) => keySpendTracker.add(apiKeyId, costUsd, at)
|
|
7268
|
+
});
|
|
4514
7269
|
const providerProxy = (0, import_provider_proxy.getProviderProxy)({ llmConfig, apiKeyPool, usageRecorder });
|
|
4515
7270
|
llmConfig.setReloadHook(() => apiKeyPool.invalidateCache());
|
|
4516
|
-
const
|
|
7271
|
+
const accountHealthProbeScheduler = new AccountHealthProbeScheduler(
|
|
7272
|
+
credentialStore,
|
|
7273
|
+
(0, import_SubscriptionAccountHealth2.getSharedAccountHealth)(),
|
|
7274
|
+
logger,
|
|
7275
|
+
import_outbound_api4.DEFAULT_ACCOUNT_PROBE
|
|
7276
|
+
);
|
|
7277
|
+
const getHealthReport = () => buildHealthReport({
|
|
7278
|
+
version: DAEMON_VERSION,
|
|
7279
|
+
// CRITICAL: the config loaded with a providers array.
|
|
7280
|
+
configPresent: () => Array.isArray(decryptedConfig.providers),
|
|
7281
|
+
// CRITICAL: the credential store's tokens.json is readable WITHOUT
|
|
7282
|
+
// decrypting (a missing file is fine — no accounts yet). A stat/access
|
|
7283
|
+
// only; never reads or decrypts token material.
|
|
7284
|
+
credentialStoreReadable: () => isTokensStoreReadable(paths.tokensPath),
|
|
7285
|
+
outboundServerRunning: () => outboundApiServer.getStatus().running,
|
|
7286
|
+
adminServerRunning: () => adminServer.getStatus().running,
|
|
7287
|
+
// Coarse, account-anonymous probe signal (#8, D5) — added to `checks` ONLY
|
|
7288
|
+
// when probing is ENABLED; disabled ⇒ `undefined` ⇒ key omitted ⇒ the
|
|
7289
|
+
// `/health` body stays byte-identical (zero regression).
|
|
7290
|
+
subscriptionAccountsHealthy: () => accountHealthProbeScheduler.enabled ? accountHealthProbeScheduler.probedAccountsHealthy() : void 0
|
|
7291
|
+
});
|
|
7292
|
+
const outboundApiServer = (0, import_outbound_api4.getOutboundApiServer)({
|
|
4517
7293
|
db: keyDb,
|
|
7294
|
+
// voucher-redemption #9: the key-authenticated `POST /redeem` endpoint redeems
|
|
7295
|
+
// cards against the presenting key (gated on `voucher.enabled`).
|
|
7296
|
+
voucherDb,
|
|
4518
7297
|
llmConfig,
|
|
4519
7298
|
providerProxy,
|
|
4520
|
-
proxyDeps: providerProxy.getDeps()
|
|
7299
|
+
proxyDeps: providerProxy.getDeps(),
|
|
7300
|
+
healthReportProvider: getHealthReport,
|
|
7301
|
+
// outbound-key-policy: the wire layer's 402 cost check reads per-key spend.
|
|
7302
|
+
keySpendTracker,
|
|
7303
|
+
// configurable-logging: route the server's OWN lifecycle + relay dispatch-error
|
|
7304
|
+
// lines through the injected logger (honors level/format/file sink).
|
|
7305
|
+
logger
|
|
4521
7306
|
});
|
|
7307
|
+
const auditDir = defaultAuditDir(paths.configPath);
|
|
7308
|
+
const billingDir = defaultBillingDir(paths.configPath);
|
|
4522
7309
|
const adminServer = new AdminServer({
|
|
4523
7310
|
configPath: paths.configPath,
|
|
4524
7311
|
llmConfig,
|
|
4525
7312
|
keyDb,
|
|
7313
|
+
// voucher-redemption #9: the admin `/admin/api/voucher` surface generates/
|
|
7314
|
+
// lists/revokes redemption cards (gated on `voucher.enabled`).
|
|
7315
|
+
voucherDb,
|
|
7316
|
+
// outbound-key-policy: the admin key list surfaces each key's OWN spend.
|
|
7317
|
+
keySpendReader: keySpendTracker,
|
|
4526
7318
|
settingsStore,
|
|
4527
7319
|
outboundApiServer,
|
|
4528
7320
|
subscriptionAccounts,
|
|
@@ -4544,14 +7336,16 @@ function buildDaemon(config, paths) {
|
|
|
4544
7336
|
oauthSessions: new OAuthSessionStore(),
|
|
4545
7337
|
// Real global fetch by default; a test seam (`paths.oauthExchangeFetch`) can
|
|
4546
7338
|
// inject a mock so no real token endpoint is hit.
|
|
4547
|
-
|
|
7339
|
+
// upstream-proxy: default the OAuth token-exchange fetch to the proxy-aware
|
|
7340
|
+
// helper so interactive login honors a configured proxy (global/env layers).
|
|
7341
|
+
oauthExchangeFetch: paths.oauthExchangeFetch ?? ((url, init) => (0, import_upstreamFetch7.fetchUpstream)(url, init)),
|
|
4548
7342
|
subscriptionAccountAppender: credentialStore,
|
|
4549
7343
|
// Codex interactive OAuth (app-parity-2 child 5) — the async loopback flow store
|
|
4550
7344
|
// + the one-shot 127.0.0.1:1455 listener. Token captured + persisted daemon-side;
|
|
4551
7345
|
// the app polls the token-free status. A test seam (`paths.codexAwaitLoopback`)
|
|
4552
7346
|
// can inject a mock so no real port is bound.
|
|
4553
7347
|
codexSessions: new CodexOAuthSessionStore(),
|
|
4554
|
-
codexAwaitLoopback: paths.codexAwaitLoopback ?? ((state, timeoutMs) => awaitLoopbackCode(state, timeoutMs)),
|
|
7348
|
+
codexAwaitLoopback: paths.codexAwaitLoopback ?? ((state, timeoutMs, signal) => awaitLoopbackCode(state, timeoutMs, signal)),
|
|
4555
7349
|
// Migration pack (app-parity child 6, design D2/D3) — the concrete credential
|
|
4556
7350
|
// store provides BOTH the full DECRYPTED read (`getFullConfig`, export) and
|
|
4557
7351
|
// the multi-account append (`appendProviderAccount`, import re-encrypts at-
|
|
@@ -4571,9 +7365,48 @@ function buildDaemon(config, paths) {
|
|
|
4571
7365
|
pricingStore,
|
|
4572
7366
|
// Use the DECRYPTED config so `admin.token` (if stored as `enc:`) is the
|
|
4573
7367
|
// plaintext bearer the AdminServer's constant-time compare expects (D4).
|
|
4574
|
-
getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin)
|
|
7368
|
+
getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin),
|
|
7369
|
+
// Unauthenticated `/health` probe (daemon-health-endpoint) — the SAME shared
|
|
7370
|
+
// builder the outbound server uses, served before the admin auth gate.
|
|
7371
|
+
getHealthReport,
|
|
7372
|
+
// configurable-logging: the admin listener's lifecycle lines route through
|
|
7373
|
+
// the injected logger.
|
|
7374
|
+
logger,
|
|
7375
|
+
// subscription-account-probe #8: the AUTHED `GET /admin/api/account-probes`
|
|
7376
|
+
// reads per-account probe history from the scheduler (secret-free — ids +
|
|
7377
|
+
// status labels only). Routed in `AdminServer` (not `adminApi.ts`).
|
|
7378
|
+
probeHistoryReader: accountHealthProbeScheduler,
|
|
7379
|
+
// request-audit-log: the AUTHED `GET /admin/api/audit` reads + filters the
|
|
7380
|
+
// date-rotated audit store. Bound to the store dir here so the AdminServer
|
|
7381
|
+
// carries no path/store coupling. Records hold IP/UA/bodies → admin-only,
|
|
7382
|
+
// NEVER unauth, NEVER on `/health`. Routed in `AdminServer` (not `adminApi.ts`).
|
|
7383
|
+
auditReader: (query) => readAuditRecords(auditDir, query),
|
|
7384
|
+
// billing-event-stream: the AUTHED `GET /admin/api/billing-status` returns the
|
|
7385
|
+
// secret-free total/delivered/pending counts of the durable ledger.
|
|
7386
|
+
billingStatusReader: () => readBillingStatus(billingDir)
|
|
7387
|
+
});
|
|
7388
|
+
const webhookDispatcher = new WebhookDispatcher({
|
|
7389
|
+
logger,
|
|
7390
|
+
fetchImpl: (url, init) => (0, import_upstreamFetch7.fetchUpstream)(url, init)
|
|
4575
7391
|
});
|
|
7392
|
+
setWebhookRuntime(webhookDispatcher, (0, import_SubscriptionAccountHealth2.getSharedAccountHealth)());
|
|
7393
|
+
const auditWriter = new AuditWriter(auditDir, logger);
|
|
7394
|
+
const auditPruneSweeper = new AuditPruneSweeper(auditDir, logger, import_audit_types.DEFAULT_AUDIT_CONFIG);
|
|
7395
|
+
setAuditRuntime(auditWriter, auditPruneSweeper);
|
|
7396
|
+
const billingPublisher = new BillingPublisher(billingDir, logger);
|
|
7397
|
+
const billingRetrySweeper = new BillingRetrySweeper(
|
|
7398
|
+
billingDir,
|
|
7399
|
+
billingPublisher,
|
|
7400
|
+
logger,
|
|
7401
|
+
import_billing_types.DEFAULT_BILLING_CONFIG
|
|
7402
|
+
);
|
|
7403
|
+
setBillingRuntime(billingPublisher, billingRetrySweeper);
|
|
4576
7404
|
const tokenRefreshScheduler = new TokenRefreshScheduler(credentialStore, logger);
|
|
7405
|
+
const accountHealthSweeper = new AccountHealthSweeper(
|
|
7406
|
+
credentialStore,
|
|
7407
|
+
(0, import_SubscriptionAccountHealth2.getSharedAccountHealth)(),
|
|
7408
|
+
logger
|
|
7409
|
+
);
|
|
4577
7410
|
return {
|
|
4578
7411
|
logger,
|
|
4579
7412
|
llmConfig,
|
|
@@ -4590,20 +7423,63 @@ function buildDaemon(config, paths) {
|
|
|
4590
7423
|
pricingEngine,
|
|
4591
7424
|
usageRecorder,
|
|
4592
7425
|
adminServer,
|
|
4593
|
-
tokenRefreshScheduler
|
|
7426
|
+
tokenRefreshScheduler,
|
|
7427
|
+
accountHealthSweeper,
|
|
7428
|
+
accountHealthProbeScheduler,
|
|
7429
|
+
webhookDispatcher,
|
|
7430
|
+
auditWriter,
|
|
7431
|
+
auditPruneSweeper,
|
|
7432
|
+
billingPublisher,
|
|
7433
|
+
billingRetrySweeper
|
|
4594
7434
|
};
|
|
4595
7435
|
}
|
|
4596
7436
|
function resetDaemonSingletonsForTests() {
|
|
4597
7437
|
(0, import_provider_proxy.__resetProviderProxyForTests)();
|
|
4598
|
-
(0,
|
|
7438
|
+
(0, import_outbound_api4.__resetOutboundApiServerForTests)();
|
|
4599
7439
|
(0, import_subscriptionRegistryPort.setSubscriptionRegistryForOutbound)(null);
|
|
4600
7440
|
(0, import_subscriptions4.setSubscriptionProviderRegistry)(null);
|
|
4601
7441
|
(0, import_subscriptions4.setSubscriptionAccountService)(null);
|
|
7442
|
+
(0, import_upstreamFetch7.setUpstreamProxyResolver)(null);
|
|
7443
|
+
setServerProxyConfig(void 0);
|
|
4602
7444
|
(0, import_gemini_code_assist_resolver.setGeminiCodeAssistResolver)(null);
|
|
4603
7445
|
setSecretBox(null);
|
|
4604
7446
|
setSecretBox2(null);
|
|
7447
|
+
resetWebhookRuntimeForTests();
|
|
7448
|
+
resetAuditRuntimeForTests();
|
|
7449
|
+
resetBillingRuntimeForTests();
|
|
7450
|
+
(0, import_SubscriptionIdentityStore2.__resetSharedIdentityStoreForTests)();
|
|
7451
|
+
}
|
|
7452
|
+
function isTokensStoreReadable(tokensPath) {
|
|
7453
|
+
try {
|
|
7454
|
+
if (!(0, import_node_fs19.existsSync)(tokensPath)) return true;
|
|
7455
|
+
(0, import_node_fs19.accessSync)(tokensPath, import_node_fs19.constants.R_OK);
|
|
7456
|
+
return true;
|
|
7457
|
+
} catch {
|
|
7458
|
+
return false;
|
|
7459
|
+
}
|
|
4605
7460
|
}
|
|
4606
7461
|
|
|
7462
|
+
// src/ports/ConsoleLogger.ts
|
|
7463
|
+
var ConsoleLogger = class {
|
|
7464
|
+
info(message, meta) {
|
|
7465
|
+
if (meta === void 0) console.info(message);
|
|
7466
|
+
else console.info(message, meta);
|
|
7467
|
+
}
|
|
7468
|
+
warn(message, meta) {
|
|
7469
|
+
if (meta === void 0) console.warn(message);
|
|
7470
|
+
else console.warn(message, meta);
|
|
7471
|
+
}
|
|
7472
|
+
error(message, error, meta) {
|
|
7473
|
+
if (error === void 0 && meta === void 0) console.error(message);
|
|
7474
|
+
else if (meta === void 0) console.error(message, error);
|
|
7475
|
+
else console.error(message, error, meta);
|
|
7476
|
+
}
|
|
7477
|
+
debug(message, meta) {
|
|
7478
|
+
if (meta === void 0) console.debug(message);
|
|
7479
|
+
else console.debug(message, meta);
|
|
7480
|
+
}
|
|
7481
|
+
};
|
|
7482
|
+
|
|
4607
7483
|
// src/ccr-import.ts
|
|
4608
7484
|
function parseCcrConfig(raw) {
|
|
4609
7485
|
if (!raw || typeof raw !== "object") {
|
|
@@ -4679,12 +7555,14 @@ function mapCcrToOmnicross(ccr) {
|
|
|
4679
7555
|
0 && (module.exports = {
|
|
4680
7556
|
AdminServer,
|
|
4681
7557
|
ConfigFileProviderConfigSource,
|
|
7558
|
+
ConfigurableLogger,
|
|
4682
7559
|
ConsoleLogger,
|
|
4683
7560
|
DEFAULT_ADMIN_PORT,
|
|
4684
7561
|
JsonApiServerSettingsStore,
|
|
4685
7562
|
JsonOutboundKeyDb,
|
|
4686
7563
|
JsonSubscriptionCredentialStore,
|
|
4687
7564
|
buildDaemon,
|
|
7565
|
+
buildHealthReport,
|
|
4688
7566
|
handleAdminApi,
|
|
4689
7567
|
inferApiFormat,
|
|
4690
7568
|
loadConfig,
|