@omnicross/core 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/GeminiCodeAssistProjectResolver.cjs +6 -1
- package/dist/auth/GeminiCodeAssistProjectResolver.js +6 -1
- package/dist/{chunk-SN3YWBX7.cjs → chunk-2FMI2B6V.cjs} +2329 -533
- package/dist/{chunk-XBSYYZIY.cjs → chunk-3QOSDDNI.cjs} +39 -3
- package/dist/{chunk-2FEVTJWG.js → chunk-4DECIFU7.js} +2162 -366
- package/dist/chunk-4IH4EL7M.cjs +9 -0
- package/dist/chunk-5ENKBSWO.js +311 -0
- package/dist/chunk-6RPZADX3.cjs +92 -0
- package/dist/chunk-AEOZIDEB.cjs +32 -0
- package/dist/chunk-BO4RFWBR.js +89 -0
- package/dist/{chunk-MNYKI4CI.js → chunk-CWM2ZDU5.js} +36 -0
- package/dist/chunk-CZHZGRU7.js +383 -0
- package/dist/{chunk-4NBS6KPV.cjs → chunk-DQ5VAYZ6.cjs} +7 -8
- package/dist/chunk-HUZZZ3JL.js +32 -0
- package/dist/{chunk-QOCNX236.js → chunk-IAXWICZU.js} +24 -1
- package/dist/{chunk-V5KPWNYX.cjs → chunk-JMJBSACD.cjs} +3 -3
- package/dist/chunk-L74VDN4A.cjs +32 -0
- package/dist/{chunk-74TMJA7Z.js → chunk-N5QFKSDO.js} +7 -4
- package/dist/{chunk-LKZJEL6E.cjs → chunk-Q27JY5RG.cjs} +6 -4
- package/dist/{chunk-SPVWWUHX.cjs → chunk-QWBWGODS.cjs} +24 -1
- package/dist/chunk-RPII3E6Z.cjs +145 -0
- package/dist/chunk-S6JGLF3G.cjs +89 -0
- package/dist/chunk-SVXC36MU.cjs +383 -0
- package/dist/chunk-TKJMZBAH.js +32 -0
- package/dist/{chunk-ZSVQT3PW.js → chunk-UBWEBKRC.js} +5 -6
- package/dist/chunk-UNEFIWXI.cjs +32 -0
- package/dist/chunk-VQY5W4YW.cjs +311 -0
- package/dist/chunk-WNY4WMRF.js +32 -0
- package/dist/chunk-XEKD23J5.js +9 -0
- package/dist/chunk-XTEI64OU.js +145 -0
- package/dist/chunk-XX6NQJMA.js +92 -0
- package/dist/{chunk-N3V2J5ZO.cjs → chunk-Y6OM7EV7.cjs} +7 -4
- package/dist/{chunk-745DV5FL.js → chunk-Y7FO65VT.js} +3 -3
- package/dist/{chunk-PXUJF5HS.js → chunk-ZY6KU6P3.js} +4 -2
- package/dist/completion/CompletionService.cjs +22 -15
- package/dist/completion/CompletionService.js +21 -14
- package/dist/completion/NativeSearchInjector.cjs +3 -3
- package/dist/completion/NativeSearchInjector.js +2 -2
- package/dist/completion/openrouter-headers.cjs +3 -6
- package/dist/completion/openrouter-headers.d.cts +9 -12
- package/dist/completion/openrouter-headers.d.ts +9 -12
- package/dist/completion/openrouter-headers.js +2 -5
- package/dist/completion/openrouter-models.cjs +4 -3
- package/dist/completion/openrouter-models.js +2 -1
- package/dist/completion.cjs +22 -15
- package/dist/completion.js +21 -14
- package/dist/index.cjs +139 -17
- package/dist/index.d.cts +40 -10
- package/dist/index.d.ts +40 -10
- package/dist/index.js +146 -24
- package/dist/outbound-api/routeResolver.cjs +5 -2
- package/dist/outbound-api/routeResolver.d.cts +8 -2
- package/dist/outbound-api/routeResolver.d.ts +8 -2
- package/dist/outbound-api/routeResolver.js +4 -1
- package/dist/outbound-api/types.cjs +6 -1
- package/dist/outbound-api/types.d.cts +11 -144
- package/dist/outbound-api/types.d.ts +11 -144
- package/dist/outbound-api/types.js +6 -0
- package/dist/outbound-api.cjs +133 -15
- package/dist/outbound-api.d.cts +786 -25
- package/dist/outbound-api.d.ts +786 -25
- package/dist/outbound-api.js +137 -19
- package/dist/pipeline/AuthSource.d.cts +23 -1
- package/dist/pipeline/AuthSource.d.ts +23 -1
- package/dist/pipeline/LlmConfigProviderAuth.cjs +3 -3
- package/dist/pipeline/LlmConfigProviderAuth.js +2 -2
- package/dist/pipeline/SubscriptionAccountHealth.cjs +22 -0
- package/dist/pipeline/SubscriptionAccountHealth.d.cts +235 -0
- package/dist/pipeline/SubscriptionAccountHealth.d.ts +235 -0
- package/dist/pipeline/SubscriptionAccountHealth.js +22 -0
- package/dist/pipeline/SubscriptionAuthSource.cjs +2 -2
- package/dist/pipeline/SubscriptionAuthSource.d.cts +3 -2
- package/dist/pipeline/SubscriptionAuthSource.d.ts +3 -2
- package/dist/pipeline/SubscriptionAuthSource.js +1 -1
- package/dist/pipeline/SubscriptionAuthStrategy.d.cts +24 -1
- package/dist/pipeline/SubscriptionAuthStrategy.d.ts +24 -1
- package/dist/pipeline/auditSink.cjs +14 -0
- package/dist/pipeline/auditSink.d.cts +48 -0
- package/dist/pipeline/auditSink.d.ts +48 -0
- package/dist/pipeline/auditSink.js +14 -0
- package/dist/pipeline/billingEmit.cjs +14 -0
- package/dist/pipeline/billingEmit.d.cts +49 -0
- package/dist/pipeline/billingEmit.d.ts +49 -0
- package/dist/pipeline/billingEmit.js +14 -0
- package/dist/ports/usage-event-store.d.cts +26 -1
- package/dist/ports/usage-event-store.d.ts +26 -1
- package/dist/ports.d.cts +7 -1
- package/dist/ports.d.ts +7 -1
- package/dist/provider-proxy/ProviderProxy.cjs +22 -15
- package/dist/provider-proxy/ProviderProxy.js +21 -14
- package/dist/provider-proxy/identity/SubscriptionIdentityStore.cjs +15 -0
- package/dist/provider-proxy/identity/SubscriptionIdentityStore.d.cts +121 -0
- package/dist/provider-proxy/identity/SubscriptionIdentityStore.d.ts +121 -0
- package/dist/provider-proxy/identity/SubscriptionIdentityStore.js +15 -0
- package/dist/provider-proxy/identity/fingerprintHeaders.cjs +20 -0
- package/dist/provider-proxy/identity/fingerprintHeaders.d.cts +89 -0
- package/dist/provider-proxy/identity/fingerprintHeaders.d.ts +89 -0
- package/dist/provider-proxy/identity/fingerprintHeaders.js +20 -0
- package/dist/provider-proxy/ingress/providerProxyShared.cjs +22 -15
- package/dist/provider-proxy/ingress/providerProxyShared.d.cts +11 -2
- package/dist/provider-proxy/ingress/providerProxyShared.d.ts +11 -2
- package/dist/provider-proxy/ingress/providerProxyShared.js +21 -14
- package/dist/provider-proxy/matchText.cjs +4 -2
- package/dist/provider-proxy/matchText.d.cts +14 -1
- package/dist/provider-proxy/matchText.d.ts +14 -1
- package/dist/provider-proxy/matchText.js +3 -1
- package/dist/provider-proxy/types.d.cts +23 -0
- package/dist/provider-proxy/types.d.ts +23 -0
- package/dist/provider-proxy.cjs +22 -15
- package/dist/provider-proxy.js +21 -14
- package/dist/{routeResolver-HE-ZO0fO.d.ts → routeResolver-B1NglTfe.d.ts} +36 -10
- package/dist/{routeResolver-BrbK6ja9.d.cts → routeResolver-CbG5uBgm.d.cts} +36 -10
- package/dist/transformer/transformers.cjs +7 -7
- package/dist/transformer/transformers.js +7 -7
- package/dist/transformer.cjs +5 -5
- package/dist/transformer.js +4 -4
- package/dist/types-6OUY_xfA.d.ts +830 -0
- package/dist/types-aBFFXoBz.d.cts +830 -0
- package/dist/usage/usage-recorder.cjs +2 -2
- package/dist/usage/usage-recorder.d.cts +19 -1
- package/dist/usage/usage-recorder.d.ts +19 -1
- package/dist/usage/usage-recorder.js +1 -1
- package/dist/usage.cjs +2 -2
- package/dist/usage.js +1 -1
- package/package.json +4 -2
- package/dist/chunk-3MEACFK3.js +0 -193
- package/dist/chunk-6VIXXLMX.cjs +0 -14
- package/dist/chunk-E3WHL7CO.js +0 -14
- package/dist/chunk-G2FUJNA2.cjs +0 -193
- package/dist/{chunk-UYPEN5XE.cjs → chunk-FFALUUZZ.cjs} +5 -5
- package/dist/{chunk-O466Y272.js → chunk-MQNDWYPN.js} +6 -6
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
collectMatchText
|
|
3
|
-
} from "./chunk-QOCNX236.js";
|
|
4
1
|
import {
|
|
5
2
|
streamSSEResponse
|
|
6
3
|
} from "./chunk-VVEHS2LI.js";
|
|
@@ -8,8 +5,8 @@ import {
|
|
|
8
5
|
TransformerChainExecutor
|
|
9
6
|
} from "./chunk-G2UTNSGU.js";
|
|
10
7
|
import {
|
|
11
|
-
|
|
12
|
-
} from "./chunk-
|
|
8
|
+
AnthropicTransformer
|
|
9
|
+
} from "./chunk-FTQ7IQUC.js";
|
|
13
10
|
import {
|
|
14
11
|
GeminiTransformer
|
|
15
12
|
} from "./chunk-C7PSERMH.js";
|
|
@@ -17,52 +14,88 @@ import {
|
|
|
17
14
|
OpenAIResponseTransformer
|
|
18
15
|
} from "./chunk-5HTVET6E.js";
|
|
19
16
|
import {
|
|
20
|
-
|
|
21
|
-
} from "./chunk-
|
|
17
|
+
readAuditUsage
|
|
18
|
+
} from "./chunk-CWM2ZDU5.js";
|
|
22
19
|
import {
|
|
23
|
-
|
|
24
|
-
} from "./chunk-
|
|
20
|
+
injectExtendedContextBeta
|
|
21
|
+
} from "./chunk-MZNPGW5Q.js";
|
|
25
22
|
import {
|
|
26
|
-
|
|
27
|
-
} from "./chunk-
|
|
23
|
+
getSharedIdentityStore
|
|
24
|
+
} from "./chunk-XTEI64OU.js";
|
|
28
25
|
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
} from "./chunk-
|
|
26
|
+
applyFingerprint,
|
|
27
|
+
captureCallerIdentity
|
|
28
|
+
} from "./chunk-XX6NQJMA.js";
|
|
32
29
|
import {
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
collectMatchText,
|
|
31
|
+
deriveSubscriptionSessionKey
|
|
32
|
+
} from "./chunk-IAXWICZU.js";
|
|
35
33
|
import {
|
|
36
34
|
resolveSubscriptionChain
|
|
37
35
|
} from "./chunk-5ERIZW3D.js";
|
|
36
|
+
import {
|
|
37
|
+
getSharedAccountHealth,
|
|
38
|
+
resolveResetSeconds
|
|
39
|
+
} from "./chunk-5ENKBSWO.js";
|
|
38
40
|
import {
|
|
39
41
|
SubscriptionAuthSource
|
|
40
|
-
} from "./chunk-
|
|
42
|
+
} from "./chunk-N5QFKSDO.js";
|
|
41
43
|
import {
|
|
42
44
|
serializeError
|
|
43
45
|
} from "./chunk-AVYJ4YBN.js";
|
|
46
|
+
import {
|
|
47
|
+
getGeminiCodeAssistResolver
|
|
48
|
+
} from "./chunk-DRWHTIIZ.js";
|
|
49
|
+
import {
|
|
50
|
+
getAuditCaptureConfig,
|
|
51
|
+
recordAudit
|
|
52
|
+
} from "./chunk-WNY4WMRF.js";
|
|
53
|
+
import {
|
|
54
|
+
getBillingCaptureConfig,
|
|
55
|
+
publishBillingEvent
|
|
56
|
+
} from "./chunk-TKJMZBAH.js";
|
|
57
|
+
import {
|
|
58
|
+
executeProviderCall
|
|
59
|
+
} from "./chunk-JO5NLTLY.js";
|
|
60
|
+
import {
|
|
61
|
+
LlmConfigProviderAuth,
|
|
62
|
+
getProviderHeaders
|
|
63
|
+
} from "./chunk-ZY6KU6P3.js";
|
|
64
|
+
import {
|
|
65
|
+
resolveProviderChain
|
|
66
|
+
} from "./chunk-JWC6APFQ.js";
|
|
44
67
|
import {
|
|
45
68
|
buildProviderApiUrl,
|
|
46
69
|
resolveApiFormat,
|
|
47
70
|
resolveProviderEndpoint
|
|
48
71
|
} from "./chunk-H5JUT3KV.js";
|
|
49
72
|
import {
|
|
50
|
-
|
|
51
|
-
|
|
73
|
+
detectRequestRole,
|
|
74
|
+
endpointToIngressFormat,
|
|
75
|
+
extractRequestedModel,
|
|
76
|
+
isKindMappedEndpoint,
|
|
77
|
+
modelKindsForEndpoint,
|
|
78
|
+
parseModelRef,
|
|
79
|
+
resolveRoute,
|
|
80
|
+
validateServerModelConfig
|
|
81
|
+
} from "./chunk-CZHZGRU7.js";
|
|
52
82
|
import {
|
|
53
83
|
convertAnthropicToOpenAI,
|
|
54
84
|
convertOpenAIToAnthropic
|
|
55
85
|
} from "./chunk-5ZQBEOMD.js";
|
|
56
86
|
import {
|
|
57
|
-
|
|
58
|
-
} from "./chunk-
|
|
87
|
+
fetchUpstream
|
|
88
|
+
} from "./chunk-BO4RFWBR.js";
|
|
59
89
|
import {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
} from "./chunk-E3WHL7CO.js";
|
|
90
|
+
applyAugmentation
|
|
91
|
+
} from "./chunk-Y7FO65VT.js";
|
|
63
92
|
import {
|
|
64
93
|
NATIVE_SEARCH_TOOL_NAMES
|
|
65
94
|
} from "./chunk-FMOBD2OQ.js";
|
|
95
|
+
import {
|
|
96
|
+
OPENROUTER_APP_HEADERS,
|
|
97
|
+
isOpenRouterProvider
|
|
98
|
+
} from "./chunk-HUZZZ3JL.js";
|
|
66
99
|
|
|
67
100
|
// src/completion/StreamEventBuffer.ts
|
|
68
101
|
var TERMINAL_TYPES = /* @__PURE__ */ new Set(["done", "error", "abort"]);
|
|
@@ -462,10 +495,11 @@ function recordAnthropicNonStreamUsage(recorder, bodyText, attribution) {
|
|
|
462
495
|
sessionId: attribution.sessionId,
|
|
463
496
|
providerId: attribution.providerId,
|
|
464
497
|
model: attribution.model,
|
|
465
|
-
apiKeyId:
|
|
498
|
+
apiKeyId: attribution.apiKeyId,
|
|
466
499
|
engineOrigin: "codex-ingress",
|
|
467
500
|
usage: tapped,
|
|
468
|
-
rawUsage: parsed.usage
|
|
501
|
+
rawUsage: parsed.usage,
|
|
502
|
+
auditResponse: attribution.auditResponse
|
|
469
503
|
});
|
|
470
504
|
} catch {
|
|
471
505
|
}
|
|
@@ -502,7 +536,84 @@ function readBody(req) {
|
|
|
502
536
|
req.on("error", reject);
|
|
503
537
|
});
|
|
504
538
|
}
|
|
505
|
-
|
|
539
|
+
function rewriteJsonModel(bodyText, rewriteModel) {
|
|
540
|
+
try {
|
|
541
|
+
const obj = JSON.parse(bodyText);
|
|
542
|
+
if (!obj || typeof obj !== "object") return bodyText;
|
|
543
|
+
let changed = false;
|
|
544
|
+
if (typeof obj.model === "string") {
|
|
545
|
+
obj.model = rewriteModel;
|
|
546
|
+
changed = true;
|
|
547
|
+
}
|
|
548
|
+
const nested = obj.response;
|
|
549
|
+
if (nested && typeof nested === "object" && typeof nested.model === "string") {
|
|
550
|
+
nested.model = rewriteModel;
|
|
551
|
+
changed = true;
|
|
552
|
+
}
|
|
553
|
+
return changed ? JSON.stringify(obj) : bodyText;
|
|
554
|
+
} catch {
|
|
555
|
+
return bodyText;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
function rewriteSseLine(line, rewriteModel) {
|
|
559
|
+
if (!line.startsWith("data:") || !line.includes('"model"')) return line;
|
|
560
|
+
const nl = /\r?\n$/.exec(line);
|
|
561
|
+
const newline = nl ? nl[0] : "";
|
|
562
|
+
const withoutNl = newline ? line.slice(0, line.length - newline.length) : line;
|
|
563
|
+
const afterData = withoutNl.slice("data:".length);
|
|
564
|
+
const leadingSpace = afterData.startsWith(" ") ? " " : "";
|
|
565
|
+
const payload = afterData.slice(leadingSpace.length);
|
|
566
|
+
if (payload === "" || payload === "[DONE]") return line;
|
|
567
|
+
try {
|
|
568
|
+
const obj = JSON.parse(payload);
|
|
569
|
+
if (!obj || typeof obj !== "object") return line;
|
|
570
|
+
const type = typeof obj.type === "string" ? obj.type : "";
|
|
571
|
+
let changed = false;
|
|
572
|
+
if (type === "message_start") {
|
|
573
|
+
const message = obj.message;
|
|
574
|
+
if (message && typeof message === "object" && typeof message.model === "string") {
|
|
575
|
+
message.model = rewriteModel;
|
|
576
|
+
changed = true;
|
|
577
|
+
}
|
|
578
|
+
} else if (type === "response.created" || type === "response.in_progress" || type === "response.completed" || type === "response.failed" || type === "response.incomplete") {
|
|
579
|
+
const response = obj.response;
|
|
580
|
+
if (response && typeof response === "object" && typeof response.model === "string") {
|
|
581
|
+
response.model = rewriteModel;
|
|
582
|
+
changed = true;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (!changed) return line;
|
|
586
|
+
return `data:${leadingSpace}${JSON.stringify(obj)}${newline}`;
|
|
587
|
+
} catch {
|
|
588
|
+
return line;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
async function relayStreamWithModelRewrite(res, body, rewriteModel) {
|
|
592
|
+
const reader = body.getReader();
|
|
593
|
+
const decoder = new TextDecoder();
|
|
594
|
+
let buffer = "";
|
|
595
|
+
try {
|
|
596
|
+
for (; ; ) {
|
|
597
|
+
const { done, value } = await reader.read();
|
|
598
|
+
if (done) break;
|
|
599
|
+
buffer += decoder.decode(value, { stream: true });
|
|
600
|
+
let out = "";
|
|
601
|
+
let nlIndex;
|
|
602
|
+
while ((nlIndex = buffer.indexOf("\n")) !== -1) {
|
|
603
|
+
const linePart = buffer.slice(0, nlIndex + 1);
|
|
604
|
+
buffer = buffer.slice(nlIndex + 1);
|
|
605
|
+
out += rewriteSseLine(linePart, rewriteModel);
|
|
606
|
+
}
|
|
607
|
+
if (out) res.write(out);
|
|
608
|
+
}
|
|
609
|
+
const tail = buffer + decoder.decode();
|
|
610
|
+
if (tail) res.write(rewriteSseLine(tail, rewriteModel));
|
|
611
|
+
} finally {
|
|
612
|
+
reader.releaseLock();
|
|
613
|
+
res.end();
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async function relayResponse(res, providerResponse, isStream, rewriteModel) {
|
|
506
617
|
const contentType = providerResponse.headers.get("Content-Type") ?? "";
|
|
507
618
|
const status = providerResponse.status && providerResponse.status >= 100 ? providerResponse.status : 200;
|
|
508
619
|
if (isStream || contentType.includes("text/event-stream")) {
|
|
@@ -515,6 +626,10 @@ async function relayResponse(res, providerResponse, isStream) {
|
|
|
515
626
|
res.end();
|
|
516
627
|
return null;
|
|
517
628
|
}
|
|
629
|
+
if (rewriteModel) {
|
|
630
|
+
await relayStreamWithModelRewrite(res, providerResponse.body, rewriteModel);
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
518
633
|
const reader = providerResponse.body.getReader();
|
|
519
634
|
try {
|
|
520
635
|
for (; ; ) {
|
|
@@ -529,8 +644,9 @@ async function relayResponse(res, providerResponse, isStream) {
|
|
|
529
644
|
return null;
|
|
530
645
|
}
|
|
531
646
|
const bodyText = await providerResponse.text();
|
|
647
|
+
const clientText = rewriteModel ? rewriteJsonModel(bodyText, rewriteModel) : bodyText;
|
|
532
648
|
res.writeHead(status, { "Content-Type": contentType.includes("json") ? contentType : "application/json" });
|
|
533
|
-
res.end(
|
|
649
|
+
res.end(clientText);
|
|
534
650
|
return bodyText;
|
|
535
651
|
}
|
|
536
652
|
function writeError(res, status, message) {
|
|
@@ -568,14 +684,15 @@ async function buildSubscriptionPlan(res, route, deps, anthropicBody, routeModel
|
|
|
568
684
|
resolvedModel = mapped.resolvedModel;
|
|
569
685
|
scenario = mapped.scenario;
|
|
570
686
|
}
|
|
571
|
-
const
|
|
687
|
+
const sessionKey = deriveSubscriptionSessionKey(anthropicBody);
|
|
688
|
+
const plan = buildSubscriptionIterationPlan(profile, route, deps, resolvedModel, isStream, scenario, sessionKey);
|
|
572
689
|
if (!plan) {
|
|
573
690
|
writeError(res, 502, "Subscription profile is missing resolveUpstreamUrl");
|
|
574
691
|
return null;
|
|
575
692
|
}
|
|
576
693
|
return plan;
|
|
577
694
|
}
|
|
578
|
-
function buildSubscriptionIterationPlan(profile, route, deps, resolvedModel, isStream, scenario) {
|
|
695
|
+
function buildSubscriptionIterationPlan(profile, route, deps, resolvedModel, isStream, scenario, sessionKey) {
|
|
579
696
|
const upstreamUrl = profile.resolveUpstreamUrl?.(resolvedModel, route.subscriptionConfig);
|
|
580
697
|
if (!upstreamUrl) return null;
|
|
581
698
|
const auth = route.auth ?? new SubscriptionAuthSource(profile);
|
|
@@ -622,7 +739,8 @@ function buildSubscriptionIterationPlan(profile, route, deps, resolvedModel, isS
|
|
|
622
739
|
upstreamUrl,
|
|
623
740
|
sameFormat,
|
|
624
741
|
isSubscription: true,
|
|
625
|
-
scenario
|
|
742
|
+
scenario,
|
|
743
|
+
sessionKey
|
|
626
744
|
};
|
|
627
745
|
}
|
|
628
746
|
function usesResponsesChain(names) {
|
|
@@ -657,12 +775,24 @@ function collectText(value) {
|
|
|
657
775
|
}
|
|
658
776
|
return 0;
|
|
659
777
|
}
|
|
660
|
-
|
|
778
|
+
function proxyProviderId(plan) {
|
|
779
|
+
return plan.isSubscription ? plan.transformerProvider.name : "byo";
|
|
780
|
+
}
|
|
781
|
+
async function runPipeline(anthropicBody, plan, reportSelection) {
|
|
661
782
|
const executor = getSharedExecutor();
|
|
662
783
|
const endpointTransformer = getAnthropicEndpointTransformer();
|
|
663
784
|
const { auth, chain, transformerProvider, resolvedModel, isStream, resolveUrl, upstreamUrl } = plan;
|
|
785
|
+
let proxyAccountId;
|
|
664
786
|
const authHeaders = {};
|
|
665
|
-
await auth.applyHeaders(authHeaders, {
|
|
787
|
+
await auth.applyHeaders(authHeaders, {
|
|
788
|
+
upstreamUrl,
|
|
789
|
+
model: resolvedModel,
|
|
790
|
+
sessionKey: plan.sessionKey,
|
|
791
|
+
reportSelection: (accountId, isActive) => {
|
|
792
|
+
proxyAccountId = accountId;
|
|
793
|
+
reportSelection?.(accountId, isActive);
|
|
794
|
+
}
|
|
795
|
+
});
|
|
666
796
|
let rawStatus = null;
|
|
667
797
|
const { response } = await executeProviderCall({
|
|
668
798
|
executor,
|
|
@@ -682,7 +812,11 @@ async function runPipeline(anthropicBody, plan) {
|
|
|
682
812
|
},
|
|
683
813
|
fetchFn: (url, headers, body) => {
|
|
684
814
|
console.info(`[ProviderProxy:anthropic] -> ${url} model=${resolvedModel} stream=${isStream}`);
|
|
685
|
-
return
|
|
815
|
+
return fetchUpstream(
|
|
816
|
+
url,
|
|
817
|
+
{ method: "POST", headers, body: JSON.stringify(body) },
|
|
818
|
+
{ providerId: proxyProviderId(plan), accountId: proxyAccountId }
|
|
819
|
+
).then((r) => {
|
|
686
820
|
rawStatus = r.status;
|
|
687
821
|
return r;
|
|
688
822
|
});
|
|
@@ -691,29 +825,95 @@ async function runPipeline(anthropicBody, plan) {
|
|
|
691
825
|
});
|
|
692
826
|
return { response, rawStatus };
|
|
693
827
|
}
|
|
694
|
-
async function runSubscriptionSameFormatFetch(rawBody, plan) {
|
|
828
|
+
async function runSubscriptionSameFormatFetch(rawBody, plan, reportSelection, options = {}) {
|
|
829
|
+
let proxyAccountId;
|
|
830
|
+
let remappedModel;
|
|
695
831
|
const headers = { "content-type": "application/json" };
|
|
696
832
|
await plan.auth.applyHeaders(headers, {
|
|
697
833
|
upstreamUrl: plan.upstreamUrl,
|
|
698
|
-
model: plan.resolvedModel
|
|
834
|
+
model: plan.resolvedModel,
|
|
835
|
+
sessionKey: plan.sessionKey,
|
|
836
|
+
reportSelection: (accountId, isActive, remapped) => {
|
|
837
|
+
proxyAccountId = accountId;
|
|
838
|
+
remappedModel = remapped;
|
|
839
|
+
reportSelection?.(accountId, isActive, remapped);
|
|
840
|
+
}
|
|
699
841
|
});
|
|
842
|
+
if (plan.isSubscription && plan.transformerProvider.name === "claude") {
|
|
843
|
+
applyFingerprint(
|
|
844
|
+
getSharedIdentityStore(),
|
|
845
|
+
headers,
|
|
846
|
+
plan.transformerProvider.name,
|
|
847
|
+
proxyAccountId,
|
|
848
|
+
options.callerIdentity
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
const applyRemap = remappedModel !== void 0 && outboundRemapApplies(plan);
|
|
852
|
+
const outboundBody = applyRemap ? rewriteBodyModel(rawBody, remappedModel) : rawBody;
|
|
853
|
+
const outboundModel = applyRemap ? remappedModel : plan.resolvedModel;
|
|
700
854
|
console.info(
|
|
701
|
-
`[ProviderProxy:anthropic] (subscription same-format) -> ${plan.upstreamUrl} model=${
|
|
855
|
+
`[ProviderProxy:anthropic] (subscription same-format) -> ${plan.upstreamUrl} model=${outboundModel} stream=${plan.isStream}`
|
|
856
|
+
);
|
|
857
|
+
const response = await fetchUpstream(
|
|
858
|
+
plan.upstreamUrl,
|
|
859
|
+
{ method: "POST", headers, body: outboundBody },
|
|
860
|
+
{ providerId: proxyProviderId(plan), accountId: proxyAccountId }
|
|
702
861
|
);
|
|
703
|
-
const response = await fetch(plan.upstreamUrl, { method: "POST", headers, body: rawBody });
|
|
704
862
|
return { response, rawStatus: response.status };
|
|
705
863
|
}
|
|
706
|
-
|
|
707
|
-
|
|
864
|
+
function outboundRemapApplies(plan) {
|
|
865
|
+
return plan.isSubscription && plan.transformerProvider.name === "claude";
|
|
866
|
+
}
|
|
867
|
+
function rewriteBodyModel(rawBody, model) {
|
|
868
|
+
try {
|
|
869
|
+
const parsed = JSON.parse(rawBody);
|
|
870
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return rawBody;
|
|
871
|
+
parsed.model = model;
|
|
872
|
+
return JSON.stringify(parsed);
|
|
873
|
+
} catch {
|
|
874
|
+
return rawBody;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
async function runSubscriptionAttemptWith401Retry(anthropicBody, relayBody, plan, options = {}) {
|
|
878
|
+
let selectedAccountId;
|
|
879
|
+
const reportSelection = (accountId) => {
|
|
880
|
+
selectedAccountId = accountId;
|
|
881
|
+
};
|
|
882
|
+
const runOnce = () => plan.sameFormat ? runSubscriptionSameFormatFetch(relayBody, plan, reportSelection, options) : runPipeline(anthropicBody, plan, reportSelection);
|
|
708
883
|
const first = await runOnce();
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
884
|
+
let result = first;
|
|
885
|
+
if (first.rawStatus === 401) {
|
|
886
|
+
const refreshed = await plan.auth.onUnauthorized?.(plan.sessionKey);
|
|
887
|
+
if (refreshed) {
|
|
888
|
+
console.info("[ProviderProxy:anthropic] 401 \u2192 token refreshed; retrying once");
|
|
889
|
+
result = await runOnce();
|
|
890
|
+
} else {
|
|
891
|
+
console.warn("[ProviderProxy:anthropic] 401 not recoverable (onUnauthorized returned false)");
|
|
892
|
+
}
|
|
714
893
|
}
|
|
715
|
-
|
|
716
|
-
return
|
|
894
|
+
await markSubscriptionHealth(plan, selectedAccountId, result);
|
|
895
|
+
return result;
|
|
896
|
+
}
|
|
897
|
+
async function readBoundedBody(response, max = 2048) {
|
|
898
|
+
try {
|
|
899
|
+
const text = await response.clone().text();
|
|
900
|
+
return text.slice(0, max);
|
|
901
|
+
} catch {
|
|
902
|
+
return void 0;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
async function markSubscriptionHealth(plan, selectedAccountId, result) {
|
|
906
|
+
if (!plan.isSubscription || selectedAccountId === void 0) return;
|
|
907
|
+
const providerId = plan.transformerProvider.name;
|
|
908
|
+
const status = result.rawStatus;
|
|
909
|
+
const { resetHeaderSeconds, retryAfterSeconds } = resolveResetSeconds(providerId, result.response.headers);
|
|
910
|
+
const bodyText = status === 403 ? await readBoundedBody(result.response) : void 0;
|
|
911
|
+
getSharedAccountHealth().recordUpstreamOutcome(providerId, selectedAccountId, {
|
|
912
|
+
status,
|
|
913
|
+
resetHeaderSeconds,
|
|
914
|
+
retryAfterSeconds,
|
|
915
|
+
bodyText
|
|
916
|
+
});
|
|
717
917
|
}
|
|
718
918
|
function isFallbackEligibleStatus(rawStatus) {
|
|
719
919
|
return rawStatus === null || rawStatus >= 500 || rawStatus === 429;
|
|
@@ -728,9 +928,9 @@ function recordBreakerOutcome(profile, modelId, rawStatus) {
|
|
|
728
928
|
if (outcome === "neutral") return;
|
|
729
929
|
profile?.recordModelOutcome?.(modelId, outcome === "success");
|
|
730
930
|
}
|
|
731
|
-
async function runSubscriptionAttemptOutcome(anthropicBody, relayBody, plan) {
|
|
931
|
+
async function runSubscriptionAttemptOutcome(anthropicBody, relayBody, plan, options = {}) {
|
|
732
932
|
try {
|
|
733
|
-
return { kind: "result", result: await runSubscriptionAttemptWith401Retry(anthropicBody, relayBody, plan) };
|
|
933
|
+
return { kind: "result", result: await runSubscriptionAttemptWith401Retry(anthropicBody, relayBody, plan, options) };
|
|
734
934
|
} catch (error) {
|
|
735
935
|
return { kind: "thrown", error };
|
|
736
936
|
}
|
|
@@ -742,11 +942,11 @@ function settleOutcome(outcome) {
|
|
|
742
942
|
if (outcome.kind === "thrown") throw outcome.error;
|
|
743
943
|
return outcome.result;
|
|
744
944
|
}
|
|
745
|
-
async function runPipelineWithSubscriptionRetry(anthropicBody, rawBody, initialPlan, route, deps) {
|
|
945
|
+
async function runPipelineWithSubscriptionRetry(anthropicBody, rawBody, initialPlan, route, deps, options = {}) {
|
|
746
946
|
const profile = route.subscriptionProfile;
|
|
747
947
|
const scenario = initialPlan.scenario;
|
|
748
948
|
if (!profile?.nextFallback || scenario === void 0) {
|
|
749
|
-
const loneOutcome = await runSubscriptionAttemptOutcome(anthropicBody, rawBody, initialPlan);
|
|
949
|
+
const loneOutcome = await runSubscriptionAttemptOutcome(anthropicBody, rawBody, initialPlan, options);
|
|
750
950
|
recordBreakerOutcome(profile, initialPlan.resolvedModel, outcomeStatus(loneOutcome));
|
|
751
951
|
return settleOutcome(loneOutcome);
|
|
752
952
|
}
|
|
@@ -757,7 +957,7 @@ async function runPipelineWithSubscriptionRetry(anthropicBody, rawBody, initialP
|
|
|
757
957
|
if (profile.allowModel && !profile.allowModel(initialPlan.resolvedModel)) {
|
|
758
958
|
attempted.push(initialPlan.resolvedModel);
|
|
759
959
|
const firstAdmitting = profile.nextFallback(scenario, attempted, route.subscriptionConfig);
|
|
760
|
-
const gatedPlan = firstAdmitting ? buildSubscriptionIterationPlan(profile, route, deps, firstAdmitting.modelId, initialPlan.isStream, scenario) : null;
|
|
960
|
+
const gatedPlan = firstAdmitting ? buildSubscriptionIterationPlan(profile, route, deps, firstAdmitting.modelId, initialPlan.isStream, scenario, initialPlan.sessionKey) : null;
|
|
761
961
|
if (firstAdmitting && gatedPlan) {
|
|
762
962
|
console.warn(
|
|
763
963
|
`[ProviderProxy:anthropic] subscription primary ${initialPlan.resolvedModel} circuit open -> first admitting fallback ${firstAdmitting.modelId}`
|
|
@@ -772,7 +972,7 @@ async function runPipelineWithSubscriptionRetry(anthropicBody, rawBody, initialP
|
|
|
772
972
|
);
|
|
773
973
|
}
|
|
774
974
|
}
|
|
775
|
-
let outcome = await runSubscriptionAttemptOutcome(firstBodyObj, firstRelayBody, plan);
|
|
975
|
+
let outcome = await runSubscriptionAttemptOutcome(firstBodyObj, firstRelayBody, plan, options);
|
|
776
976
|
recordBreakerOutcome(profile, plan.resolvedModel, outcomeStatus(outcome));
|
|
777
977
|
if (!attempted.includes(plan.resolvedModel)) attempted.push(plan.resolvedModel);
|
|
778
978
|
while (attempted.length < MAX_FALLBACK_ATTEMPTS) {
|
|
@@ -785,7 +985,8 @@ async function runPipelineWithSubscriptionRetry(anthropicBody, rawBody, initialP
|
|
|
785
985
|
deps,
|
|
786
986
|
next.modelId,
|
|
787
987
|
plan.isStream,
|
|
788
|
-
scenario
|
|
988
|
+
scenario,
|
|
989
|
+
initialPlan.sessionKey
|
|
789
990
|
);
|
|
790
991
|
if (!nextPlan) return settleOutcome(outcome);
|
|
791
992
|
const sinceLabel = outcome.kind === "thrown" ? "thrown error" : `status ${String(outcome.result.rawStatus)}`;
|
|
@@ -796,7 +997,7 @@ async function runPipelineWithSubscriptionRetry(anthropicBody, rawBody, initialP
|
|
|
796
997
|
const fallbackRelayBody = nextPlan.sameFormat ? JSON.stringify(fallbackBodyObj) : rawBody;
|
|
797
998
|
plan = nextPlan;
|
|
798
999
|
attempted.push(next.modelId);
|
|
799
|
-
outcome = await runSubscriptionAttemptOutcome(fallbackBodyObj, fallbackRelayBody, plan);
|
|
1000
|
+
outcome = await runSubscriptionAttemptOutcome(fallbackBodyObj, fallbackRelayBody, plan, options);
|
|
800
1001
|
recordBreakerOutcome(profile, plan.resolvedModel, outcomeStatus(outcome));
|
|
801
1002
|
}
|
|
802
1003
|
return settleOutcome(outcome);
|
|
@@ -819,13 +1020,16 @@ async function handleAnthropicMessagesByo(res, rawBody, route, deps, options = {
|
|
|
819
1020
|
const plan = route.authMode === "subscription" ? await buildSubscriptionPlan(res, route, deps, anthropicBody, resolvedModel, isStream) : await buildByoPlan(res, route, deps, resolvedModel, isStream);
|
|
820
1021
|
if (!plan) return;
|
|
821
1022
|
anthropicBody.model = plan.resolvedModel;
|
|
822
|
-
const providerResponse = route.authMode === "subscription" ? await runPipelineWithSubscriptionRetry(anthropicBody, rawBody, plan, route, deps) : await runPipelineWithPoolReporting(anthropicBody, rawBody, plan, options);
|
|
823
|
-
const bodyText = await relayResponse(res, providerResponse.response, isStream);
|
|
1023
|
+
const providerResponse = route.authMode === "subscription" ? await runPipelineWithSubscriptionRetry(anthropicBody, rawBody, plan, route, deps, options) : await runPipelineWithPoolReporting(anthropicBody, rawBody, plan, options);
|
|
1024
|
+
const bodyText = await relayResponse(res, providerResponse.response, isStream, route.requestedModel);
|
|
824
1025
|
if (bodyText && deps.usageRecorder) {
|
|
825
1026
|
recordAnthropicNonStreamUsage(deps.usageRecorder, bodyText, {
|
|
826
1027
|
sessionId: route.sessionId,
|
|
827
1028
|
providerId: route.providerId ?? "anthropic",
|
|
828
|
-
model: plan.resolvedModel
|
|
1029
|
+
model: plan.resolvedModel,
|
|
1030
|
+
apiKeyId: route.apiKeyId ?? null,
|
|
1031
|
+
// request-audit-log: correlate this request's tokens/cost to its audit record.
|
|
1032
|
+
auditResponse: res
|
|
829
1033
|
});
|
|
830
1034
|
}
|
|
831
1035
|
} catch (err) {
|
|
@@ -881,7 +1085,7 @@ async function buildByoPlan(res, route, deps, resolvedModel, isStream) {
|
|
|
881
1085
|
extendedContextEnabled: route.anthropicSdkHints?.extendedContext?.enabled ?? false
|
|
882
1086
|
};
|
|
883
1087
|
}
|
|
884
|
-
async function runSameFormatFetch(
|
|
1088
|
+
async function runSameFormatFetch(bodyToSend, plan, options, keyOverride) {
|
|
885
1089
|
const { provider, apiKey, resolvedModel, isStream, extendedContextEnabled } = plan;
|
|
886
1090
|
if (!provider) {
|
|
887
1091
|
throw new Error("[ProviderProxy:anthropic] same-format BYO path requires a provider row");
|
|
@@ -902,11 +1106,26 @@ async function runSameFormatFetch(rawBody, plan, options, keyOverride) {
|
|
|
902
1106
|
injectExtendedContextBeta(headers, resolvedModel, extendedContextEnabled ?? false);
|
|
903
1107
|
const url = buildProviderApiUrl(provider, { model: resolvedModel, stream: isStream });
|
|
904
1108
|
console.info(`[ProviderProxy:anthropic] (same-format) -> ${url} model=${resolvedModel} stream=${isStream}`);
|
|
905
|
-
const response = await
|
|
1109
|
+
const response = await fetchUpstream(
|
|
1110
|
+
url,
|
|
1111
|
+
{ method: "POST", headers, body: bodyToSend },
|
|
1112
|
+
{ providerId: "byo" }
|
|
1113
|
+
);
|
|
906
1114
|
return { response, rawStatus: response.status };
|
|
907
1115
|
}
|
|
1116
|
+
function resolveSameFormatBody(anthropicBody, rawBody, plan) {
|
|
1117
|
+
let originalModel;
|
|
1118
|
+
try {
|
|
1119
|
+
const m = JSON.parse(rawBody).model;
|
|
1120
|
+
originalModel = typeof m === "string" ? m : void 0;
|
|
1121
|
+
} catch {
|
|
1122
|
+
originalModel = void 0;
|
|
1123
|
+
}
|
|
1124
|
+
return plan.resolvedModel !== originalModel ? JSON.stringify(anthropicBody) : rawBody;
|
|
1125
|
+
}
|
|
908
1126
|
async function runPipelineWithPoolReporting(anthropicBody, rawBody, plan, options) {
|
|
909
|
-
const
|
|
1127
|
+
const sameFormatBody = plan.sameFormat ? resolveSameFormatBody(anthropicBody, rawBody, plan) : rawBody;
|
|
1128
|
+
const runOnce = (keyOverride) => plan.sameFormat ? runSameFormatFetch(sameFormatBody, plan, options, keyOverride) : runPipeline(anthropicBody, plan);
|
|
910
1129
|
const first = await runOnce();
|
|
911
1130
|
const outcome = await plan.auth.onResult?.(first.rawStatus);
|
|
912
1131
|
if (outcome?.rebound) {
|
|
@@ -930,7 +1149,8 @@ async function handleAnthropicMessagesRequest(req, res, route, deps) {
|
|
|
930
1149
|
const rawBody = await readBody(req);
|
|
931
1150
|
const callerBetaRaw = req.headers["anthropic-beta"];
|
|
932
1151
|
const callerAnthropicBeta = Array.isArray(callerBetaRaw) ? callerBetaRaw.join(",") : callerBetaRaw;
|
|
933
|
-
|
|
1152
|
+
const callerIdentity = captureCallerIdentity(getSharedIdentityStore(), req.headers);
|
|
1153
|
+
await handleAnthropicMessagesByo(res, rawBody, route, deps, { callerAnthropicBeta, callerIdentity });
|
|
934
1154
|
return;
|
|
935
1155
|
}
|
|
936
1156
|
const hints = route.anthropicSdkHints;
|
|
@@ -992,7 +1212,8 @@ function recordGeminiNonStreamUsage(recorder, bodyText, attribution) {
|
|
|
992
1212
|
sessionId: attribution.sessionId,
|
|
993
1213
|
providerId: attribution.providerId,
|
|
994
1214
|
model: attribution.model,
|
|
995
|
-
apiKeyId:
|
|
1215
|
+
apiKeyId: attribution.apiKeyId,
|
|
1216
|
+
auditResponse: attribution.auditResponse,
|
|
996
1217
|
engineOrigin: "codex-ingress",
|
|
997
1218
|
usage: tapped,
|
|
998
1219
|
rawUsage: parsed.usageMetadata
|
|
@@ -1042,7 +1263,10 @@ async function handleGeminiGenerateContentRequest(res, rawBody, url, route, deps
|
|
|
1042
1263
|
recordGeminiNonStreamUsage(deps.usageRecorder, bodyText, {
|
|
1043
1264
|
sessionId: route.sessionId,
|
|
1044
1265
|
providerId: route.providerId ?? "gemini",
|
|
1045
|
-
model: resolvedModel
|
|
1266
|
+
model: resolvedModel,
|
|
1267
|
+
apiKeyId: route.apiKeyId ?? null,
|
|
1268
|
+
// request-audit-log: correlate this request's tokens/cost to its audit record.
|
|
1269
|
+
auditResponse: res
|
|
1046
1270
|
});
|
|
1047
1271
|
}
|
|
1048
1272
|
} catch (err) {
|
|
@@ -1117,7 +1341,11 @@ async function runPipeline2(geminiBody, plan) {
|
|
|
1117
1341
|
},
|
|
1118
1342
|
fetchFn: (url, headers, body) => {
|
|
1119
1343
|
console.log(`[ProviderProxy:gemini] -> ${url} model=${resolvedModel} stream=${isStream}`);
|
|
1120
|
-
return
|
|
1344
|
+
return fetchUpstream(
|
|
1345
|
+
url,
|
|
1346
|
+
{ method: "POST", headers, body: JSON.stringify(body) },
|
|
1347
|
+
{ providerId: "byo" }
|
|
1348
|
+
).then((r) => {
|
|
1121
1349
|
rawStatus = r.status;
|
|
1122
1350
|
return r;
|
|
1123
1351
|
});
|
|
@@ -1169,7 +1397,8 @@ function recordChatCompletionsNonStreamUsage(recorder, bodyText, attribution) {
|
|
|
1169
1397
|
sessionId: attribution.sessionId,
|
|
1170
1398
|
providerId: attribution.providerId,
|
|
1171
1399
|
model: attribution.model,
|
|
1172
|
-
apiKeyId:
|
|
1400
|
+
apiKeyId: attribution.apiKeyId,
|
|
1401
|
+
auditResponse: attribution.auditResponse,
|
|
1173
1402
|
engineOrigin: "codex-ingress",
|
|
1174
1403
|
usage: tapped,
|
|
1175
1404
|
rawUsage: parsed.usage
|
|
@@ -1184,6 +1413,8 @@ function isOpenAIChatRequest(method, url) {
|
|
|
1184
1413
|
const path = url.split("?")[0]?.replace(/\/+$/, "") ?? "";
|
|
1185
1414
|
return path.endsWith("/chat/completions");
|
|
1186
1415
|
}
|
|
1416
|
+
var CHAT_BRIDGE_SUBSCRIPTION_PROVIDERS = /* @__PURE__ */ new Set(["claude"]);
|
|
1417
|
+
var IDENTITY_ENDPOINT_TRANSFORMER = { name: "identity-openai-chat" };
|
|
1187
1418
|
async function handleOpenAIChatRequest(res, rawBody, route, deps) {
|
|
1188
1419
|
let chatBody;
|
|
1189
1420
|
try {
|
|
@@ -1192,23 +1423,22 @@ async function handleOpenAIChatRequest(res, rawBody, route, deps) {
|
|
|
1192
1423
|
writeError(res, 400, "Invalid JSON in request body");
|
|
1193
1424
|
return;
|
|
1194
1425
|
}
|
|
1195
|
-
if (route.authMode === "subscription") {
|
|
1196
|
-
writeError(res, 502, "OpenAI Chat Completions ingress does not support subscription auth");
|
|
1197
|
-
return;
|
|
1198
|
-
}
|
|
1199
1426
|
const isStream = chatBody.stream === true;
|
|
1200
1427
|
const resolvedModel = route.model;
|
|
1201
1428
|
chatBody.model = resolvedModel;
|
|
1202
1429
|
try {
|
|
1203
|
-
const plan = await buildByoPlan3(res, route, deps, resolvedModel, isStream);
|
|
1430
|
+
const plan = route.authMode === "subscription" ? await buildSubscriptionPlan2(res, route, deps, resolvedModel, isStream) : await buildByoPlan3(res, route, deps, resolvedModel, isStream);
|
|
1204
1431
|
if (!plan) return;
|
|
1205
|
-
const providerResponse = await runPipelineWithPoolReporting3(chatBody, plan);
|
|
1432
|
+
const providerResponse = plan.isSubscription ? await runPipelineWithSubscriptionRetry2(chatBody, plan) : await runPipelineWithPoolReporting3(chatBody, plan);
|
|
1206
1433
|
const bodyText = await relayResponse(res, providerResponse.response, isStream);
|
|
1207
1434
|
if (bodyText && deps.usageRecorder) {
|
|
1208
1435
|
recordChatCompletionsNonStreamUsage(deps.usageRecorder, bodyText, {
|
|
1209
1436
|
sessionId: route.sessionId,
|
|
1210
1437
|
providerId: route.providerId ?? "openai",
|
|
1211
|
-
model: resolvedModel
|
|
1438
|
+
model: resolvedModel,
|
|
1439
|
+
apiKeyId: route.apiKeyId ?? null,
|
|
1440
|
+
// request-audit-log: correlate this request's tokens/cost to its audit record.
|
|
1441
|
+
auditResponse: res
|
|
1212
1442
|
});
|
|
1213
1443
|
}
|
|
1214
1444
|
} catch (err) {
|
|
@@ -1255,14 +1485,67 @@ async function buildByoPlan3(res, route, deps, resolvedModel, isStream) {
|
|
|
1255
1485
|
resolvedModel,
|
|
1256
1486
|
isStream,
|
|
1257
1487
|
resolveUrl: (config) => config.url instanceof URL ? config.url.toString() : byoUrl,
|
|
1258
|
-
upstreamUrl: byoUrl
|
|
1488
|
+
upstreamUrl: byoUrl,
|
|
1489
|
+
proxyProviderId: "byo",
|
|
1490
|
+
isSubscription: false
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
async function buildSubscriptionPlan2(res, route, deps, resolvedModel, isStream) {
|
|
1494
|
+
const profile = route.subscriptionProfile;
|
|
1495
|
+
if (!profile) {
|
|
1496
|
+
writeError(res, 502, "Subscription mode requires a subscription profile");
|
|
1497
|
+
return null;
|
|
1498
|
+
}
|
|
1499
|
+
const providerId = profile.authStrategy.providerId;
|
|
1500
|
+
if (!CHAT_BRIDGE_SUBSCRIPTION_PROVIDERS.has(providerId)) {
|
|
1501
|
+
writeError(
|
|
1502
|
+
res,
|
|
1503
|
+
501,
|
|
1504
|
+
`the OpenAI-chat\u2192subscription bridge currently supports claude only; subscription provider '${providerId}' is not implemented on this endpoint yet (use the /v1/responses endpoint, a Claude subscription, or a BYO provider)`
|
|
1505
|
+
);
|
|
1506
|
+
return null;
|
|
1507
|
+
}
|
|
1508
|
+
const upstreamUrl = profile.resolveUpstreamUrl?.(resolvedModel);
|
|
1509
|
+
if (!upstreamUrl) {
|
|
1510
|
+
writeError(res, 502, "Subscription profile is missing resolveUpstreamUrl");
|
|
1511
|
+
return null;
|
|
1512
|
+
}
|
|
1513
|
+
const auth = route.auth ?? new SubscriptionAuthSource(profile);
|
|
1514
|
+
const chain = resolveSubscriptionChain(
|
|
1515
|
+
profile,
|
|
1516
|
+
deps.llmConfig.getTransformerService(),
|
|
1517
|
+
IDENTITY_ENDPOINT_TRANSFORMER
|
|
1518
|
+
);
|
|
1519
|
+
const transformerProvider = {
|
|
1520
|
+
name: providerId,
|
|
1521
|
+
baseUrl: upstreamUrl,
|
|
1522
|
+
apiKey: "",
|
|
1523
|
+
models: [resolvedModel]
|
|
1524
|
+
};
|
|
1525
|
+
return {
|
|
1526
|
+
auth,
|
|
1527
|
+
chain,
|
|
1528
|
+
transformerProvider,
|
|
1529
|
+
resolvedModel,
|
|
1530
|
+
isStream,
|
|
1531
|
+
resolveUrl: (config) => config.url instanceof URL ? config.url.toString() : typeof config.url === "string" ? config.url : upstreamUrl,
|
|
1532
|
+
upstreamUrl,
|
|
1533
|
+
proxyProviderId: providerId,
|
|
1534
|
+
isSubscription: true
|
|
1259
1535
|
};
|
|
1260
1536
|
}
|
|
1261
1537
|
async function runPipeline3(chatBody, plan) {
|
|
1262
1538
|
const executor = getSharedExecutor();
|
|
1263
1539
|
const { auth, chain, transformerProvider, resolvedModel, isStream, resolveUrl, upstreamUrl } = plan;
|
|
1540
|
+
let proxyAccountId;
|
|
1264
1541
|
const authHeaders = {};
|
|
1265
|
-
await auth.applyHeaders(authHeaders, {
|
|
1542
|
+
await auth.applyHeaders(authHeaders, {
|
|
1543
|
+
upstreamUrl,
|
|
1544
|
+
model: resolvedModel,
|
|
1545
|
+
reportSelection: (accountId) => {
|
|
1546
|
+
proxyAccountId = accountId;
|
|
1547
|
+
}
|
|
1548
|
+
});
|
|
1266
1549
|
let rawStatus = null;
|
|
1267
1550
|
const { response } = await executeProviderCall({
|
|
1268
1551
|
executor,
|
|
@@ -1281,7 +1564,11 @@ async function runPipeline3(chatBody, plan) {
|
|
|
1281
1564
|
},
|
|
1282
1565
|
fetchFn: (url, headers, body) => {
|
|
1283
1566
|
console.log(`[ProviderProxy:chat] -> ${url} model=${resolvedModel} stream=${isStream}`);
|
|
1284
|
-
return
|
|
1567
|
+
return fetchUpstream(
|
|
1568
|
+
url,
|
|
1569
|
+
{ method: "POST", headers, body: JSON.stringify(body) },
|
|
1570
|
+
{ providerId: plan.proxyProviderId, accountId: proxyAccountId }
|
|
1571
|
+
).then((r) => {
|
|
1285
1572
|
rawStatus = r.status;
|
|
1286
1573
|
return r;
|
|
1287
1574
|
});
|
|
@@ -1303,6 +1590,17 @@ async function runPipelineWithPoolReporting3(chatBody, plan) {
|
|
|
1303
1590
|
}
|
|
1304
1591
|
return first;
|
|
1305
1592
|
}
|
|
1593
|
+
async function runPipelineWithSubscriptionRetry2(chatBody, plan) {
|
|
1594
|
+
const first = await runPipeline3(chatBody, plan);
|
|
1595
|
+
if (first.rawStatus !== 401) return first;
|
|
1596
|
+
const refreshed = await plan.auth.onUnauthorized?.();
|
|
1597
|
+
if (!refreshed) {
|
|
1598
|
+
console.warn("[ProviderProxy:chat] 401 not recoverable (onUnauthorized returned false)");
|
|
1599
|
+
return first;
|
|
1600
|
+
}
|
|
1601
|
+
console.log("[ProviderProxy:chat] 401 \u2192 token refreshed; retrying once");
|
|
1602
|
+
return runPipeline3(chatBody, plan);
|
|
1603
|
+
}
|
|
1306
1604
|
|
|
1307
1605
|
// src/provider-proxy/usage/recordResponsesUsage.ts
|
|
1308
1606
|
function readResponsesUsage(usage) {
|
|
@@ -1333,7 +1631,8 @@ function recordResponsesNonStreamUsage(recorder, bodyText, attribution) {
|
|
|
1333
1631
|
sessionId: attribution.sessionId,
|
|
1334
1632
|
providerId: attribution.providerId,
|
|
1335
1633
|
model: attribution.model,
|
|
1336
|
-
apiKeyId:
|
|
1634
|
+
apiKeyId: attribution.apiKeyId,
|
|
1635
|
+
auditResponse: attribution.auditResponse,
|
|
1337
1636
|
engineOrigin: "codex-ingress",
|
|
1338
1637
|
usage: tapped,
|
|
1339
1638
|
rawUsage: parsed.usage
|
|
@@ -1360,15 +1659,18 @@ async function handleOpenAIResponsesRequest(res, rawBody, route, deps) {
|
|
|
1360
1659
|
const resolvedModel = route.model;
|
|
1361
1660
|
responsesBody.model = resolvedModel;
|
|
1362
1661
|
try {
|
|
1363
|
-
const plan = route.authMode === "subscription" ? await
|
|
1662
|
+
const plan = route.authMode === "subscription" ? await buildSubscriptionPlan3(res, route, deps, resolvedModel, isStream) : await buildByoPlan4(res, route, deps, resolvedModel, isStream);
|
|
1364
1663
|
if (!plan) return;
|
|
1365
|
-
const providerResponse = route.authMode === "subscription" ? await
|
|
1366
|
-
const bodyText = await relayResponse(res, providerResponse.response, isStream);
|
|
1664
|
+
const providerResponse = route.authMode === "subscription" ? await runPipelineWithSubscriptionRetry3(responsesBody, plan) : await runPipelineWithPoolReporting4(responsesBody, plan);
|
|
1665
|
+
const bodyText = await relayResponse(res, providerResponse.response, isStream, route.requestedModel);
|
|
1367
1666
|
if (bodyText && deps.usageRecorder) {
|
|
1368
1667
|
recordResponsesNonStreamUsage(deps.usageRecorder, bodyText, {
|
|
1369
1668
|
sessionId: route.sessionId,
|
|
1370
1669
|
providerId: route.providerId ?? "codex",
|
|
1371
|
-
model: resolvedModel
|
|
1670
|
+
model: resolvedModel,
|
|
1671
|
+
apiKeyId: route.apiKeyId ?? null,
|
|
1672
|
+
// request-audit-log: correlate this request's tokens/cost to its audit record.
|
|
1673
|
+
auditResponse: res
|
|
1372
1674
|
});
|
|
1373
1675
|
}
|
|
1374
1676
|
} catch (err) {
|
|
@@ -1415,10 +1717,11 @@ async function buildByoPlan4(res, route, deps, resolvedModel, isStream) {
|
|
|
1415
1717
|
resolvedModel,
|
|
1416
1718
|
isStream,
|
|
1417
1719
|
resolveUrl: (config) => config.url instanceof URL ? config.url.toString() : byoUrl,
|
|
1418
|
-
upstreamUrl: byoUrl
|
|
1720
|
+
upstreamUrl: byoUrl,
|
|
1721
|
+
proxyProviderId: "byo"
|
|
1419
1722
|
};
|
|
1420
1723
|
}
|
|
1421
|
-
async function
|
|
1724
|
+
async function buildSubscriptionPlan3(res, route, deps, resolvedModel, isStream) {
|
|
1422
1725
|
const profile = route.subscriptionProfile;
|
|
1423
1726
|
if (!profile) {
|
|
1424
1727
|
writeError(res, 502, "Subscription mode requires a codex subscription profile");
|
|
@@ -1459,7 +1762,8 @@ async function buildSubscriptionPlan2(res, route, deps, resolvedModel, isStream)
|
|
|
1459
1762
|
// stream vs non-stream colon-method URL in `config.url`); fall back to the
|
|
1460
1763
|
// profile's `resolveUpstreamUrl` for non-Code-Assist subscriptions.
|
|
1461
1764
|
resolveUrl: (config) => config.url instanceof URL ? config.url.toString() : typeof config.url === "string" ? config.url : upstreamUrl,
|
|
1462
|
-
upstreamUrl
|
|
1765
|
+
upstreamUrl,
|
|
1766
|
+
proxyProviderId: profile.authStrategy.providerId
|
|
1463
1767
|
};
|
|
1464
1768
|
}
|
|
1465
1769
|
async function resolveGeminiCodeAssistProject(profile) {
|
|
@@ -1476,8 +1780,15 @@ async function runPipeline4(responsesBody, plan) {
|
|
|
1476
1780
|
const executor = getSharedExecutor();
|
|
1477
1781
|
const endpointTransformer = getResponsesEndpointTransformer();
|
|
1478
1782
|
const { auth, chain, transformerProvider, resolvedModel, isStream, resolveUrl, upstreamUrl } = plan;
|
|
1783
|
+
let proxyAccountId;
|
|
1479
1784
|
const authHeaders = {};
|
|
1480
|
-
await auth.applyHeaders(authHeaders, {
|
|
1785
|
+
await auth.applyHeaders(authHeaders, {
|
|
1786
|
+
upstreamUrl,
|
|
1787
|
+
model: resolvedModel,
|
|
1788
|
+
reportSelection: (accountId) => {
|
|
1789
|
+
proxyAccountId = accountId;
|
|
1790
|
+
}
|
|
1791
|
+
});
|
|
1481
1792
|
let rawStatus = null;
|
|
1482
1793
|
const { response } = await executeProviderCall({
|
|
1483
1794
|
executor,
|
|
@@ -1497,7 +1808,11 @@ async function runPipeline4(responsesBody, plan) {
|
|
|
1497
1808
|
},
|
|
1498
1809
|
fetchFn: (url, headers, body) => {
|
|
1499
1810
|
console.log(`[ProviderProxy:responses] -> ${url} model=${resolvedModel} stream=${isStream}`);
|
|
1500
|
-
return
|
|
1811
|
+
return fetchUpstream(
|
|
1812
|
+
url,
|
|
1813
|
+
{ method: "POST", headers, body: JSON.stringify(body) },
|
|
1814
|
+
{ providerId: plan.proxyProviderId, accountId: proxyAccountId }
|
|
1815
|
+
).then((r) => {
|
|
1501
1816
|
rawStatus = r.status;
|
|
1502
1817
|
return r;
|
|
1503
1818
|
});
|
|
@@ -1519,7 +1834,7 @@ async function runPipelineWithPoolReporting4(responsesBody, plan) {
|
|
|
1519
1834
|
}
|
|
1520
1835
|
return first;
|
|
1521
1836
|
}
|
|
1522
|
-
async function
|
|
1837
|
+
async function runPipelineWithSubscriptionRetry3(responsesBody, plan) {
|
|
1523
1838
|
const first = await runPipeline4(responsesBody, plan);
|
|
1524
1839
|
if (first.rawStatus !== 401) return first;
|
|
1525
1840
|
const refreshed = await plan.auth.onUnauthorized?.();
|
|
@@ -1696,12 +2011,644 @@ function __resetProviderProxyForTests() {
|
|
|
1696
2011
|
// src/outbound-api/OutboundApiServer.ts
|
|
1697
2012
|
import http2 from "http";
|
|
1698
2013
|
import { networkInterfaces } from "os";
|
|
2014
|
+
import { healthHttpStatus } from "@omnicross/contracts/health-logging-types";
|
|
2015
|
+
|
|
2016
|
+
// src/outbound-api/keyedMutex.ts
|
|
2017
|
+
var KeyedMutex = class {
|
|
2018
|
+
/** key → the tail of the pending-op chain (resolves when the last op frees). */
|
|
2019
|
+
tails = /* @__PURE__ */ new Map();
|
|
2020
|
+
/**
|
|
2021
|
+
* Run `fn` exclusively for `key`: it starts only after every previously
|
|
2022
|
+
* enqueued op for the SAME key has settled, and the next waiter starts only
|
|
2023
|
+
* after `fn` settles. Returns `fn`'s result (or rejection). Never lets one op's
|
|
2024
|
+
* failure wedge the queue (waiters proceed regardless).
|
|
2025
|
+
*/
|
|
2026
|
+
async runExclusive(key, fn) {
|
|
2027
|
+
const prev = this.tails.get(key) ?? Promise.resolve();
|
|
2028
|
+
let release2;
|
|
2029
|
+
const gate = new Promise((resolve) => {
|
|
2030
|
+
release2 = resolve;
|
|
2031
|
+
});
|
|
2032
|
+
const chain = prev.then(() => gate);
|
|
2033
|
+
this.tails.set(key, chain);
|
|
2034
|
+
await prev.catch(() => void 0);
|
|
2035
|
+
try {
|
|
2036
|
+
return await fn();
|
|
2037
|
+
} finally {
|
|
2038
|
+
release2();
|
|
2039
|
+
if (this.tails.get(key) === chain) this.tails.delete(key);
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
};
|
|
1699
2043
|
|
|
1700
2044
|
// src/outbound-api/outboundApiRouter.ts
|
|
1701
2045
|
import { Readable } from "stream";
|
|
1702
2046
|
|
|
2047
|
+
// src/pipeline/webhookEmit.ts
|
|
2048
|
+
var sink = null;
|
|
2049
|
+
function emitWebhookEvent(event) {
|
|
2050
|
+
const active = sink;
|
|
2051
|
+
if (!active) return;
|
|
2052
|
+
try {
|
|
2053
|
+
active(event);
|
|
2054
|
+
} catch {
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// src/outbound-api/apiServerConfig.ts
|
|
2059
|
+
import { DEFAULT_AUDIT_CONFIG } from "@omnicross/contracts/audit-types";
|
|
2060
|
+
import { DEFAULT_BILLING_CONFIG } from "@omnicross/contracts/billing-types";
|
|
2061
|
+
import {
|
|
2062
|
+
WEBHOOK_DESTINATION_TYPES,
|
|
2063
|
+
WEBHOOK_EVENT_KINDS
|
|
2064
|
+
} from "@omnicross/contracts/webhook-types";
|
|
2065
|
+
var OUTBOUND_API_SERVER_CONFIG_KEY = "outboundApiServer.config";
|
|
2066
|
+
var DEFAULT_USER_MESSAGE_QUEUE = {
|
|
2067
|
+
enabled: false,
|
|
2068
|
+
delayMs: 200,
|
|
2069
|
+
waitTimeoutMs: 6e4
|
|
2070
|
+
};
|
|
2071
|
+
var DEFAULT_CONCURRENCY_QUEUE = {
|
|
2072
|
+
maxQueueSizeFactor: 2,
|
|
2073
|
+
minQueueSize: 4,
|
|
2074
|
+
waitTimeoutMs: 6e4
|
|
2075
|
+
};
|
|
2076
|
+
var DEFAULT_ACCOUNT_HEALTH = {
|
|
2077
|
+
overloadCooldownEnabled: true,
|
|
2078
|
+
overloadCooldownMs: 10 * 6e4
|
|
2079
|
+
};
|
|
2080
|
+
function normalizeAccountHealth(raw) {
|
|
2081
|
+
const ah = raw?.accountHealth;
|
|
2082
|
+
return {
|
|
2083
|
+
overloadCooldownEnabled: ah?.overloadCooldownEnabled !== false,
|
|
2084
|
+
overloadCooldownMs: clampNumber(
|
|
2085
|
+
ah?.overloadCooldownMs,
|
|
2086
|
+
6e4,
|
|
2087
|
+
36e5,
|
|
2088
|
+
DEFAULT_ACCOUNT_HEALTH.overloadCooldownMs
|
|
2089
|
+
)
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
var DEFAULT_ACCOUNT_PROBE = {
|
|
2093
|
+
enabled: false,
|
|
2094
|
+
intervalMs: 15 * 6e4,
|
|
2095
|
+
onlyMultiAccount: true,
|
|
2096
|
+
timeoutMs: 5e3,
|
|
2097
|
+
historySize: 10,
|
|
2098
|
+
staggerMs: 500
|
|
2099
|
+
};
|
|
2100
|
+
function normalizeAccountProbe(raw) {
|
|
2101
|
+
const ap = raw?.accountProbe;
|
|
2102
|
+
return {
|
|
2103
|
+
enabled: ap?.enabled === true,
|
|
2104
|
+
intervalMs: clampNumber(ap?.intervalMs, 6e4, 864e5, DEFAULT_ACCOUNT_PROBE.intervalMs),
|
|
2105
|
+
onlyMultiAccount: ap?.onlyMultiAccount !== false,
|
|
2106
|
+
timeoutMs: clampNumber(ap?.timeoutMs, 1e3, 6e4, DEFAULT_ACCOUNT_PROBE.timeoutMs),
|
|
2107
|
+
historySize: Math.trunc(
|
|
2108
|
+
clampNumber(ap?.historySize, 1, 200, DEFAULT_ACCOUNT_PROBE.historySize)
|
|
2109
|
+
),
|
|
2110
|
+
staggerMs: clampNumber(ap?.staggerMs, 0, 6e4, DEFAULT_ACCOUNT_PROBE.staggerMs)
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
function normalizeAudit(raw) {
|
|
2114
|
+
const a = raw?.audit;
|
|
2115
|
+
return {
|
|
2116
|
+
enabled: a?.enabled === true,
|
|
2117
|
+
captureBodies: a?.captureBodies === true,
|
|
2118
|
+
maxBodyBytes: Math.trunc(
|
|
2119
|
+
clampNumber(a?.maxBodyBytes, 256, 1048576, DEFAULT_AUDIT_CONFIG.maxBodyBytes)
|
|
2120
|
+
),
|
|
2121
|
+
retentionDays: Math.trunc(
|
|
2122
|
+
clampNumber(a?.retentionDays, 1, 365, DEFAULT_AUDIT_CONFIG.retentionDays)
|
|
2123
|
+
),
|
|
2124
|
+
trustForwardedFor: a?.trustForwardedFor === true
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
function normalizeBilling(raw) {
|
|
2128
|
+
const b = raw?.billing;
|
|
2129
|
+
const config = {
|
|
2130
|
+
enabled: b?.enabled === true,
|
|
2131
|
+
maxRetryAgeMs: Math.trunc(
|
|
2132
|
+
clampNumber(b?.maxRetryAgeMs, 6e4, 2592e6, DEFAULT_BILLING_CONFIG.maxRetryAgeMs)
|
|
2133
|
+
)
|
|
2134
|
+
};
|
|
2135
|
+
if (typeof b?.endpoint === "string" && b.endpoint.trim()) config.endpoint = b.endpoint.trim();
|
|
2136
|
+
if (typeof b?.secret === "string" && b.secret.length > 0) config.secret = b.secret;
|
|
2137
|
+
return config;
|
|
2138
|
+
}
|
|
2139
|
+
var DEFAULT_FINGERPRINT = { enabled: false };
|
|
2140
|
+
function normalizeFingerprint(raw) {
|
|
2141
|
+
const f = raw?.fingerprint;
|
|
2142
|
+
const out = { enabled: f?.enabled === true };
|
|
2143
|
+
if (typeof f?.ua === "string" && f.ua.trim().length > 0) out.ua = f.ua.trim();
|
|
2144
|
+
return out;
|
|
2145
|
+
}
|
|
2146
|
+
function normalizeVoucher(raw) {
|
|
2147
|
+
return { enabled: raw?.voucher?.enabled === true };
|
|
2148
|
+
}
|
|
2149
|
+
var PROXY_TYPES = ["http", "https", "socks5"];
|
|
2150
|
+
function normalizeProxyConfig(raw) {
|
|
2151
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
2152
|
+
const r = raw;
|
|
2153
|
+
if (typeof r["url"] === "string" && r["url"].trim().length > 0) {
|
|
2154
|
+
return { url: r["url"].trim() };
|
|
2155
|
+
}
|
|
2156
|
+
const type = r["type"];
|
|
2157
|
+
const host = r["host"];
|
|
2158
|
+
const port = r["port"];
|
|
2159
|
+
if (typeof type === "string" && PROXY_TYPES.includes(type) && typeof host === "string" && host.trim().length > 0 && typeof port === "number" && Number.isFinite(port) && port >= 1 && port <= 65535) {
|
|
2160
|
+
const out = {
|
|
2161
|
+
type,
|
|
2162
|
+
host: host.trim(),
|
|
2163
|
+
port: Math.trunc(port)
|
|
2164
|
+
};
|
|
2165
|
+
if (typeof r["username"] === "string" && r["username"].length > 0) out.username = r["username"];
|
|
2166
|
+
if (typeof r["password"] === "string" && r["password"].length > 0) out.password = r["password"];
|
|
2167
|
+
return out;
|
|
2168
|
+
}
|
|
2169
|
+
return void 0;
|
|
2170
|
+
}
|
|
2171
|
+
function normalizeProxySegment(raw) {
|
|
2172
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
2173
|
+
const r = raw;
|
|
2174
|
+
const out = {};
|
|
2175
|
+
const global = normalizeProxyConfig(r["global"]);
|
|
2176
|
+
if (global) out.global = global;
|
|
2177
|
+
if (r["byProvider"] && typeof r["byProvider"] === "object") {
|
|
2178
|
+
const byProvider = {};
|
|
2179
|
+
for (const [key, value] of Object.entries(r["byProvider"])) {
|
|
2180
|
+
if (!key.trim()) continue;
|
|
2181
|
+
const cfg = normalizeProxyConfig(value);
|
|
2182
|
+
if (cfg) byProvider[key] = cfg;
|
|
2183
|
+
}
|
|
2184
|
+
if (Object.keys(byProvider).length > 0) out.byProvider = byProvider;
|
|
2185
|
+
}
|
|
2186
|
+
return out.global || out.byProvider ? out : void 0;
|
|
2187
|
+
}
|
|
2188
|
+
function normalizeWebhookDestination(raw) {
|
|
2189
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
2190
|
+
const r = raw;
|
|
2191
|
+
const id = typeof r["id"] === "string" ? r["id"].trim() : "";
|
|
2192
|
+
const url = typeof r["url"] === "string" ? r["url"].trim() : "";
|
|
2193
|
+
const type = r["type"];
|
|
2194
|
+
if (!id || !url || typeof type !== "string" || !WEBHOOK_DESTINATION_TYPES.includes(type)) {
|
|
2195
|
+
return void 0;
|
|
2196
|
+
}
|
|
2197
|
+
const out = {
|
|
2198
|
+
id,
|
|
2199
|
+
type,
|
|
2200
|
+
url,
|
|
2201
|
+
enabled: r["enabled"] !== false
|
|
2202
|
+
};
|
|
2203
|
+
if (typeof r["secret"] === "string" && r["secret"].length > 0) out.secret = r["secret"];
|
|
2204
|
+
if (Array.isArray(r["events"])) {
|
|
2205
|
+
const events = r["events"].filter(
|
|
2206
|
+
(e) => typeof e === "string" && WEBHOOK_EVENT_KINDS.includes(e)
|
|
2207
|
+
);
|
|
2208
|
+
if (events.length > 0) out.events = events;
|
|
2209
|
+
}
|
|
2210
|
+
return out;
|
|
2211
|
+
}
|
|
2212
|
+
function normalizeWebhookSegment(raw) {
|
|
2213
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
2214
|
+
const r = raw;
|
|
2215
|
+
const destinations = [];
|
|
2216
|
+
if (Array.isArray(r["destinations"])) {
|
|
2217
|
+
for (const entry of r["destinations"]) {
|
|
2218
|
+
const d = normalizeWebhookDestination(entry);
|
|
2219
|
+
if (d) destinations.push(d);
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
const enabled = r["enabled"] === true;
|
|
2223
|
+
if (!enabled && destinations.length === 0) return void 0;
|
|
2224
|
+
return { enabled, destinations };
|
|
2225
|
+
}
|
|
2226
|
+
function clampNumber(value, min, max, fallback) {
|
|
2227
|
+
const n = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
2228
|
+
return Math.min(max, Math.max(min, n));
|
|
2229
|
+
}
|
|
2230
|
+
function normalizeQueueSegments(raw) {
|
|
2231
|
+
const umq = raw?.userMessageQueue;
|
|
2232
|
+
const cq = raw?.concurrencyQueue;
|
|
2233
|
+
return {
|
|
2234
|
+
userMessageQueue: {
|
|
2235
|
+
enabled: umq?.enabled === true,
|
|
2236
|
+
delayMs: clampNumber(umq?.delayMs, 0, 1e4, DEFAULT_USER_MESSAGE_QUEUE.delayMs),
|
|
2237
|
+
waitTimeoutMs: clampNumber(
|
|
2238
|
+
umq?.waitTimeoutMs,
|
|
2239
|
+
1e3,
|
|
2240
|
+
3e5,
|
|
2241
|
+
DEFAULT_USER_MESSAGE_QUEUE.waitTimeoutMs
|
|
2242
|
+
)
|
|
2243
|
+
},
|
|
2244
|
+
concurrencyQueue: {
|
|
2245
|
+
maxQueueSizeFactor: clampNumber(
|
|
2246
|
+
cq?.maxQueueSizeFactor,
|
|
2247
|
+
1,
|
|
2248
|
+
10,
|
|
2249
|
+
DEFAULT_CONCURRENCY_QUEUE.maxQueueSizeFactor
|
|
2250
|
+
),
|
|
2251
|
+
minQueueSize: clampNumber(cq?.minQueueSize, 1, 100, DEFAULT_CONCURRENCY_QUEUE.minQueueSize),
|
|
2252
|
+
waitTimeoutMs: clampNumber(
|
|
2253
|
+
cq?.waitTimeoutMs,
|
|
2254
|
+
1e3,
|
|
2255
|
+
3e5,
|
|
2256
|
+
DEFAULT_CONCURRENCY_QUEUE.waitTimeoutMs
|
|
2257
|
+
)
|
|
2258
|
+
}
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
var ALL_ENDPOINTS = ["chat", "responses", "messages", "gemini"];
|
|
2262
|
+
function blankModelMap(endpoint) {
|
|
2263
|
+
const modelMap = {};
|
|
2264
|
+
for (const kind of modelKindsForEndpoint(endpoint)) modelMap[kind] = "";
|
|
2265
|
+
return modelMap;
|
|
2266
|
+
}
|
|
2267
|
+
function defaultEndpointConfig(endpoint) {
|
|
2268
|
+
if (isKindMappedEndpoint(endpoint)) {
|
|
2269
|
+
return { endpoint, modelMap: blankModelMap(endpoint), useSubscription: false };
|
|
2270
|
+
}
|
|
2271
|
+
if (endpoint === "chat") {
|
|
2272
|
+
return { endpoint, models: [], useSubscription: false };
|
|
2273
|
+
}
|
|
2274
|
+
return {
|
|
2275
|
+
endpoint,
|
|
2276
|
+
defaultModel: "",
|
|
2277
|
+
backgroundModel: "",
|
|
2278
|
+
useSubscription: false
|
|
2279
|
+
};
|
|
2280
|
+
}
|
|
2281
|
+
function normalizePrefixTargets(raw) {
|
|
2282
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
2283
|
+
const r = raw;
|
|
2284
|
+
const out = {};
|
|
2285
|
+
for (const key of ["claude", "gpt", "gemini"]) {
|
|
2286
|
+
const v = r[key];
|
|
2287
|
+
if (typeof v === "string" && v.trim() !== "") out[key] = v.trim();
|
|
2288
|
+
}
|
|
2289
|
+
return out.claude || out.gpt || out.gemini ? out : void 0;
|
|
2290
|
+
}
|
|
2291
|
+
function normalizeEndpointConfig(e) {
|
|
2292
|
+
const endpoint = e.endpoint;
|
|
2293
|
+
const useSubscription = e.useSubscription === true;
|
|
2294
|
+
if (isKindMappedEndpoint(endpoint)) {
|
|
2295
|
+
const rawMap = e.modelMap && typeof e.modelMap === "object" ? e.modelMap : {};
|
|
2296
|
+
const modelMap = {};
|
|
2297
|
+
for (const kind of modelKindsForEndpoint(endpoint)) {
|
|
2298
|
+
const v = rawMap[kind];
|
|
2299
|
+
modelMap[kind] = typeof v === "string" ? v : "";
|
|
2300
|
+
}
|
|
2301
|
+
return { endpoint, modelMap, useSubscription };
|
|
2302
|
+
}
|
|
2303
|
+
if (endpoint === "chat") {
|
|
2304
|
+
const models = Array.isArray(e.models) ? e.models.filter((m) => typeof m === "string" && m.trim() !== "") : [];
|
|
2305
|
+
const config2 = { endpoint, models, useSubscription };
|
|
2306
|
+
if (e.dispatchMode === "prefix") {
|
|
2307
|
+
config2.dispatchMode = "prefix";
|
|
2308
|
+
const targets = normalizePrefixTargets(e.prefixTargets);
|
|
2309
|
+
if (targets) config2.prefixTargets = targets;
|
|
2310
|
+
}
|
|
2311
|
+
return config2;
|
|
2312
|
+
}
|
|
2313
|
+
const config = {
|
|
2314
|
+
endpoint,
|
|
2315
|
+
defaultModel: typeof e.defaultModel === "string" ? e.defaultModel : "",
|
|
2316
|
+
backgroundModel: typeof e.backgroundModel === "string" ? e.backgroundModel : "",
|
|
2317
|
+
useSubscription
|
|
2318
|
+
};
|
|
2319
|
+
if (Array.isArray(e.backgroundModelIds)) {
|
|
2320
|
+
config.backgroundModelIds = e.backgroundModelIds;
|
|
2321
|
+
}
|
|
2322
|
+
return config;
|
|
2323
|
+
}
|
|
2324
|
+
function defaultServerConfig() {
|
|
2325
|
+
const queues = normalizeQueueSegments(void 0);
|
|
2326
|
+
return {
|
|
2327
|
+
enabled: false,
|
|
2328
|
+
networkBinding: false,
|
|
2329
|
+
endpoints: ALL_ENDPOINTS.map(defaultEndpointConfig),
|
|
2330
|
+
port: DEFAULT_OUTBOUND_PORT,
|
|
2331
|
+
userMessageQueue: queues.userMessageQueue,
|
|
2332
|
+
concurrencyQueue: queues.concurrencyQueue,
|
|
2333
|
+
accountHealth: normalizeAccountHealth(void 0),
|
|
2334
|
+
accountProbe: normalizeAccountProbe(void 0),
|
|
2335
|
+
audit: normalizeAudit(void 0),
|
|
2336
|
+
billing: normalizeBilling(void 0),
|
|
2337
|
+
fingerprint: normalizeFingerprint(void 0),
|
|
2338
|
+
voucher: normalizeVoucher(void 0)
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
function normalizeServerConfig(raw) {
|
|
2342
|
+
const base = defaultServerConfig();
|
|
2343
|
+
if (!raw) return base;
|
|
2344
|
+
const byEndpoint = /* @__PURE__ */ new Map();
|
|
2345
|
+
for (const e of raw.endpoints ?? []) {
|
|
2346
|
+
if (e && ALL_ENDPOINTS.includes(e.endpoint)) {
|
|
2347
|
+
byEndpoint.set(e.endpoint, normalizeEndpointConfig(e));
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
const queues = normalizeQueueSegments(raw);
|
|
2351
|
+
const config = {
|
|
2352
|
+
enabled: raw.enabled === true,
|
|
2353
|
+
networkBinding: raw.networkBinding === true,
|
|
2354
|
+
endpoints: ALL_ENDPOINTS.map(
|
|
2355
|
+
(ep) => byEndpoint.get(ep) ?? defaultEndpointConfig(ep)
|
|
2356
|
+
),
|
|
2357
|
+
port: raw.port ?? base.port,
|
|
2358
|
+
userMessageQueue: queues.userMessageQueue,
|
|
2359
|
+
concurrencyQueue: queues.concurrencyQueue,
|
|
2360
|
+
accountHealth: normalizeAccountHealth(raw),
|
|
2361
|
+
accountProbe: normalizeAccountProbe(raw),
|
|
2362
|
+
audit: normalizeAudit(raw),
|
|
2363
|
+
billing: normalizeBilling(raw),
|
|
2364
|
+
fingerprint: normalizeFingerprint(raw),
|
|
2365
|
+
voucher: normalizeVoucher(raw)
|
|
2366
|
+
};
|
|
2367
|
+
const proxy = normalizeProxySegment(raw.proxy);
|
|
2368
|
+
if (proxy) config.proxy = proxy;
|
|
2369
|
+
const webhook = normalizeWebhookSegment(raw.webhook);
|
|
2370
|
+
if (webhook) config.webhook = webhook;
|
|
2371
|
+
return config;
|
|
2372
|
+
}
|
|
2373
|
+
async function loadServerConfig(store) {
|
|
2374
|
+
const raw = await store.get(
|
|
2375
|
+
OUTBOUND_API_SERVER_CONFIG_KEY
|
|
2376
|
+
);
|
|
2377
|
+
return normalizeServerConfig(raw);
|
|
2378
|
+
}
|
|
2379
|
+
async function saveServerConfig(store, config) {
|
|
2380
|
+
await store.set(OUTBOUND_API_SERVER_CONFIG_KEY, config);
|
|
2381
|
+
}
|
|
2382
|
+
function mergeServerConfig(current, patch) {
|
|
2383
|
+
return normalizeServerConfig({
|
|
2384
|
+
enabled: patch.enabled ?? current.enabled,
|
|
2385
|
+
networkBinding: patch.networkBinding ?? current.networkBinding,
|
|
2386
|
+
endpoints: patch.endpoints ?? current.endpoints,
|
|
2387
|
+
port: patch.port ?? current.port,
|
|
2388
|
+
userMessageQueue: patch.userMessageQueue ?? current.userMessageQueue,
|
|
2389
|
+
concurrencyQueue: patch.concurrencyQueue ?? current.concurrencyQueue,
|
|
2390
|
+
accountHealth: patch.accountHealth ?? current.accountHealth,
|
|
2391
|
+
accountProbe: patch.accountProbe ?? current.accountProbe,
|
|
2392
|
+
audit: patch.audit ?? current.audit,
|
|
2393
|
+
billing: patch.billing ?? current.billing,
|
|
2394
|
+
// Proxy is layer-replaced (not deep-merged): a PUT carrying `proxy` swaps the
|
|
2395
|
+
// whole segment; omitting it keeps the current one. `undefined` on both ⇒ absent.
|
|
2396
|
+
proxy: patch.proxy ?? current.proxy,
|
|
2397
|
+
// Webhook is layer-replaced too (a PUT carrying `webhook` swaps the whole
|
|
2398
|
+
// segment; omitting it keeps the current one). `undefined` on both ⇒ absent.
|
|
2399
|
+
webhook: patch.webhook ?? current.webhook,
|
|
2400
|
+
// Fingerprint is always-filled (normalizeFingerprint synthesizes a default);
|
|
2401
|
+
// a PUT carrying it replaces the segment, else the current one is kept.
|
|
2402
|
+
fingerprint: patch.fingerprint ?? current.fingerprint,
|
|
2403
|
+
// Voucher is always-filled (normalizeVoucher synthesizes a default); a PUT
|
|
2404
|
+
// carrying it replaces the segment, else the current one is kept.
|
|
2405
|
+
voucher: patch.voucher ?? current.voucher
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
// src/outbound-api/auditCapture.ts
|
|
2410
|
+
import { randomUUID } from "crypto";
|
|
2411
|
+
|
|
2412
|
+
// src/outbound-api/auditRedact.ts
|
|
2413
|
+
var AUDIT_REDACTED = "***REDACTED***";
|
|
2414
|
+
var RULES = [
|
|
2415
|
+
// `sk-…` family (Anthropic sk-ant-, omnicross sk-omnicross-, OpenAI sk-proj-/sk-).
|
|
2416
|
+
{ re: /\bsk-[A-Za-z0-9_-]{6,}/g, replace: AUDIT_REDACTED },
|
|
2417
|
+
// Google API keys.
|
|
2418
|
+
{ re: /\bAIza[A-Za-z0-9_-]{10,}/g, replace: AUDIT_REDACTED },
|
|
2419
|
+
// `Bearer <token>` (auth headers echoed into a body / SSE error).
|
|
2420
|
+
{ re: /\bBearer\s+[A-Za-z0-9._~+/=-]{6,}/gi, replace: `Bearer ${AUDIT_REDACTED}` },
|
|
2421
|
+
// Inline `x-api-key` / `api-key` / `authorization` label followed by a value.
|
|
2422
|
+
{
|
|
2423
|
+
re: /\b(x-api-key|api[_-]?key|authorization)(["']?\s*[:=]\s*["']?)[A-Za-z0-9._~+/=-]{6,}/gi,
|
|
2424
|
+
replace: `$1$2${AUDIT_REDACTED}`
|
|
2425
|
+
},
|
|
2426
|
+
// `x-goog-api-key` header echoed inline.
|
|
2427
|
+
{
|
|
2428
|
+
re: /\b(x-goog-api-key)(["']?\s*[:=]\s*["']?)[A-Za-z0-9._~+/=-]{6,}/gi,
|
|
2429
|
+
replace: `$1$2${AUDIT_REDACTED}`
|
|
2430
|
+
}
|
|
2431
|
+
];
|
|
2432
|
+
function redactAuditText(text) {
|
|
2433
|
+
if (typeof text !== "string" || text.length === 0) return text ?? "";
|
|
2434
|
+
let out = text;
|
|
2435
|
+
for (const { re, replace } of RULES) {
|
|
2436
|
+
out = out.replace(re, replace);
|
|
2437
|
+
}
|
|
2438
|
+
return out;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
// src/outbound-api/auditCapture.ts
|
|
2442
|
+
function isStreamingContentType(res) {
|
|
2443
|
+
const ct = res.getHeader("content-type");
|
|
2444
|
+
const value = Array.isArray(ct) ? ct.join(";") : String(ct ?? "");
|
|
2445
|
+
return value.toLowerCase().includes("text/event-stream");
|
|
2446
|
+
}
|
|
2447
|
+
function resolveClientIp(req, trustForwardedFor) {
|
|
2448
|
+
if (trustForwardedFor) {
|
|
2449
|
+
const xff = req.headers["x-forwarded-for"];
|
|
2450
|
+
const raw = Array.isArray(xff) ? xff[0] : xff;
|
|
2451
|
+
if (typeof raw === "string" && raw.trim()) {
|
|
2452
|
+
const first = raw.split(",")[0]?.trim();
|
|
2453
|
+
if (first) return first;
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
return req.socket?.remoteAddress ?? void 0;
|
|
2457
|
+
}
|
|
2458
|
+
function truncateToBytes(text, maxBytes) {
|
|
2459
|
+
const buf = Buffer.from(text, "utf8");
|
|
2460
|
+
if (buf.length <= maxBytes) return text;
|
|
2461
|
+
return buf.subarray(0, maxBytes).toString("utf8");
|
|
2462
|
+
}
|
|
2463
|
+
function beginAuditCapture(req, res, now) {
|
|
2464
|
+
const config = getAuditCaptureConfig();
|
|
2465
|
+
if (!config) return null;
|
|
2466
|
+
let rawRequestBody;
|
|
2467
|
+
const responseChunks = [];
|
|
2468
|
+
let responseBytes = 0;
|
|
2469
|
+
let streamDetected = false;
|
|
2470
|
+
let finished = false;
|
|
2471
|
+
const ctx = {
|
|
2472
|
+
setRequestBody(raw) {
|
|
2473
|
+
if (config.captureBodies) rawRequestBody = raw;
|
|
2474
|
+
}
|
|
2475
|
+
};
|
|
2476
|
+
if (config.captureBodies) {
|
|
2477
|
+
installResponseCapture(res, config.maxBodyBytes, {
|
|
2478
|
+
push(chunk) {
|
|
2479
|
+
responseBytes += chunk.length;
|
|
2480
|
+
responseChunks.push(chunk);
|
|
2481
|
+
},
|
|
2482
|
+
get bytes() {
|
|
2483
|
+
return responseBytes;
|
|
2484
|
+
},
|
|
2485
|
+
markStream() {
|
|
2486
|
+
streamDetected = true;
|
|
2487
|
+
},
|
|
2488
|
+
get isStream() {
|
|
2489
|
+
return streamDetected;
|
|
2490
|
+
}
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
const finalize = () => {
|
|
2494
|
+
if (finished) return;
|
|
2495
|
+
finished = true;
|
|
2496
|
+
try {
|
|
2497
|
+
const record = {
|
|
2498
|
+
id: randomUUID(),
|
|
2499
|
+
ts: now,
|
|
2500
|
+
method: req.method ?? "",
|
|
2501
|
+
path: (req.url ?? "").split("?")[0] ?? "",
|
|
2502
|
+
status: res.statusCode,
|
|
2503
|
+
latencyMs: Math.max(0, Date.now() - now)
|
|
2504
|
+
};
|
|
2505
|
+
if (ctx.keyId != null) record.keyId = ctx.keyId;
|
|
2506
|
+
const ip = resolveClientIp(req, config.trustForwardedFor);
|
|
2507
|
+
if (ip) record.ip = ip;
|
|
2508
|
+
const ua = req.headers["user-agent"];
|
|
2509
|
+
if (typeof ua === "string" && ua) record.ua = ua;
|
|
2510
|
+
if (ctx.model) record.model = ctx.model;
|
|
2511
|
+
if (ctx.provider) record.provider = ctx.provider;
|
|
2512
|
+
const usage = readAuditUsage(res);
|
|
2513
|
+
if (usage) {
|
|
2514
|
+
if (typeof usage.inputTokens === "number") record.inputTokens = usage.inputTokens;
|
|
2515
|
+
if (typeof usage.outputTokens === "number") record.outputTokens = usage.outputTokens;
|
|
2516
|
+
if (typeof usage.costUsd === "number") record.costUsd = usage.costUsd;
|
|
2517
|
+
if (!record.model && usage.model) record.model = usage.model;
|
|
2518
|
+
if (!record.provider && usage.provider) record.provider = usage.provider;
|
|
2519
|
+
}
|
|
2520
|
+
if (ctx.error) record.error = redactAuditText(ctx.error);
|
|
2521
|
+
if (config.captureBodies) {
|
|
2522
|
+
if (rawRequestBody != null && rawRequestBody.length > 0) {
|
|
2523
|
+
record.requestBody = redactAuditText(truncateToBytes(rawRequestBody, config.maxBodyBytes));
|
|
2524
|
+
}
|
|
2525
|
+
if (!streamDetected && responseChunks.length > 0) {
|
|
2526
|
+
const body = Buffer.concat(responseChunks).toString("utf8");
|
|
2527
|
+
record.responseBody = redactAuditText(body);
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
recordAudit(record);
|
|
2531
|
+
} catch {
|
|
2532
|
+
}
|
|
2533
|
+
};
|
|
2534
|
+
res.once("close", finalize);
|
|
2535
|
+
return ctx;
|
|
2536
|
+
}
|
|
2537
|
+
function installResponseCapture(res, maxBodyBytes, sink2) {
|
|
2538
|
+
let contentTypeChecked = false;
|
|
2539
|
+
const capture = (chunk) => {
|
|
2540
|
+
if (sink2.isStream) return;
|
|
2541
|
+
if (!contentTypeChecked) {
|
|
2542
|
+
contentTypeChecked = true;
|
|
2543
|
+
if (isStreamingContentType(res)) {
|
|
2544
|
+
sink2.markStream();
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
if (sink2.bytes >= maxBodyBytes) return;
|
|
2549
|
+
let buf = null;
|
|
2550
|
+
if (Buffer.isBuffer(chunk)) buf = chunk;
|
|
2551
|
+
else if (typeof chunk === "string") buf = Buffer.from(chunk, "utf8");
|
|
2552
|
+
if (!buf || buf.length === 0) return;
|
|
2553
|
+
const remaining = maxBodyBytes - sink2.bytes;
|
|
2554
|
+
sink2.push(buf.length > remaining ? buf.subarray(0, remaining) : buf);
|
|
2555
|
+
};
|
|
2556
|
+
const originalWrite = res.write.bind(res);
|
|
2557
|
+
const originalEnd = res.end.bind(res);
|
|
2558
|
+
res.write = ((chunk, ...args) => {
|
|
2559
|
+
if (chunk != null) capture(chunk);
|
|
2560
|
+
return originalWrite(chunk, ...args);
|
|
2561
|
+
});
|
|
2562
|
+
res.end = ((chunk, ...args) => {
|
|
2563
|
+
if (chunk != null && typeof chunk !== "function") capture(chunk);
|
|
2564
|
+
return originalEnd(chunk, ...args);
|
|
2565
|
+
});
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
// src/outbound-api/billingCapture.ts
|
|
2569
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2570
|
+
var defaultDefer = (fn) => {
|
|
2571
|
+
setTimeout(fn, 0);
|
|
2572
|
+
};
|
|
2573
|
+
function beginBillingCapture(_req, res, now, defer = defaultDefer) {
|
|
2574
|
+
if (!getBillingCaptureConfig()) return null;
|
|
2575
|
+
const ctx = {};
|
|
2576
|
+
let finished = false;
|
|
2577
|
+
const finalize = () => {
|
|
2578
|
+
if (finished) return;
|
|
2579
|
+
finished = true;
|
|
2580
|
+
const status = res.statusCode;
|
|
2581
|
+
defer(() => {
|
|
2582
|
+
try {
|
|
2583
|
+
const usage = readAuditUsage(res);
|
|
2584
|
+
if (!usage) return;
|
|
2585
|
+
const event = {
|
|
2586
|
+
id: randomUUID2(),
|
|
2587
|
+
ts: now,
|
|
2588
|
+
model: ctx.model ?? usage.model ?? "",
|
|
2589
|
+
authMode: ctx.authMode ?? "byo",
|
|
2590
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
2591
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
2592
|
+
costUsd: usage.costUsd ?? 0,
|
|
2593
|
+
status
|
|
2594
|
+
};
|
|
2595
|
+
if (ctx.keyId != null) event.keyId = ctx.keyId;
|
|
2596
|
+
const provider = ctx.provider ?? usage.provider;
|
|
2597
|
+
if (provider) event.provider = provider;
|
|
2598
|
+
publishBillingEvent(event);
|
|
2599
|
+
} catch {
|
|
2600
|
+
}
|
|
2601
|
+
});
|
|
2602
|
+
};
|
|
2603
|
+
res.once("close", finalize);
|
|
2604
|
+
return ctx;
|
|
2605
|
+
}
|
|
2606
|
+
|
|
1703
2607
|
// src/outbound-api/outboundApiKeyAuth.ts
|
|
1704
2608
|
import { createHash, randomBytes as randomBytes2 } from "crypto";
|
|
2609
|
+
|
|
2610
|
+
// src/outbound-api/keyPolicy.ts
|
|
2611
|
+
var DAY_MS = 864e5;
|
|
2612
|
+
function computeKeyExpiry(row, now) {
|
|
2613
|
+
const mode = row.activationMode ?? "fixed";
|
|
2614
|
+
if (mode === "activation") {
|
|
2615
|
+
if (row.activatedAt == null) return { expired: false, needsActivation: true };
|
|
2616
|
+
if (row.activationDays == null) return { expired: false };
|
|
2617
|
+
const effectiveExpiresAt = row.activatedAt + row.activationDays * DAY_MS;
|
|
2618
|
+
return { expired: now >= effectiveExpiresAt, effectiveExpiresAt };
|
|
2619
|
+
}
|
|
2620
|
+
if (row.expiresAt == null) return { expired: false };
|
|
2621
|
+
return { expired: now >= row.expiresAt, effectiveExpiresAt: row.expiresAt };
|
|
2622
|
+
}
|
|
2623
|
+
function checkKeyQuota(limits, spend) {
|
|
2624
|
+
if (!limits) return { allowed: true };
|
|
2625
|
+
if (limits.dailyUsd != null && spend.dailyUsd >= limits.dailyUsd) {
|
|
2626
|
+
return { allowed: false, scope: "daily", limitUsd: limits.dailyUsd, spentUsd: spend.dailyUsd };
|
|
2627
|
+
}
|
|
2628
|
+
if (limits.weeklyUsd != null && spend.weeklyUsd >= limits.weeklyUsd) {
|
|
2629
|
+
return { allowed: false, scope: "weekly", limitUsd: limits.weeklyUsd, spentUsd: spend.weeklyUsd };
|
|
2630
|
+
}
|
|
2631
|
+
if (limits.totalUsd != null && spend.totalUsd >= limits.totalUsd) {
|
|
2632
|
+
return { allowed: false, scope: "total", limitUsd: limits.totalUsd, spentUsd: spend.totalUsd };
|
|
2633
|
+
}
|
|
2634
|
+
return { allowed: true };
|
|
2635
|
+
}
|
|
2636
|
+
function canonicalModelId(value) {
|
|
2637
|
+
const idx = value.indexOf(",");
|
|
2638
|
+
const bare = idx >= 0 ? value.slice(idx + 1) : value;
|
|
2639
|
+
return bare.trim().toLowerCase();
|
|
2640
|
+
}
|
|
2641
|
+
function checkModelAllowed(restriction, resolvedModelId) {
|
|
2642
|
+
if (!restriction) return { allowed: true };
|
|
2643
|
+
const target = canonicalModelId(resolvedModelId);
|
|
2644
|
+
const listed = restriction.models.some((m) => canonicalModelId(m) === target);
|
|
2645
|
+
if (restriction.mode === "allowlist") {
|
|
2646
|
+
return listed ? { allowed: true } : { allowed: false, model: resolvedModelId };
|
|
2647
|
+
}
|
|
2648
|
+
return listed ? { allowed: false, model: resolvedModelId } : { allowed: true };
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
// src/outbound-api/outboundApiKeyAuth.ts
|
|
1705
2652
|
var SECRET_BYTES = 32;
|
|
1706
2653
|
var PREFIX_LEN = 14;
|
|
1707
2654
|
var KEY_PREFIX = "sk-omnicross-";
|
|
@@ -1749,143 +2696,730 @@ async function createNamedKey(db, name) {
|
|
|
1749
2696
|
plaintextOnce: secret
|
|
1750
2697
|
};
|
|
1751
2698
|
}
|
|
1752
|
-
|
|
1753
|
-
|
|
2699
|
+
function extractCostLimits(row) {
|
|
2700
|
+
const limits = {};
|
|
2701
|
+
if (row.dailyCostLimitUsd != null) limits.dailyUsd = row.dailyCostLimitUsd;
|
|
2702
|
+
if (row.totalCostLimitUsd != null) limits.totalUsd = row.totalCostLimitUsd;
|
|
2703
|
+
if (row.weeklyCostLimitUsd != null) limits.weeklyUsd = row.weeklyCostLimitUsd;
|
|
2704
|
+
return limits.dailyUsd != null || limits.totalUsd != null || limits.weeklyUsd != null ? limits : void 0;
|
|
2705
|
+
}
|
|
2706
|
+
function extractRateLimit(row) {
|
|
2707
|
+
if (row.rateLimitMaxRequests == null && row.rateLimitWindowMs == null) return void 0;
|
|
2708
|
+
const override = {};
|
|
2709
|
+
if (row.rateLimitMaxRequests != null) override.maxRequests = row.rateLimitMaxRequests;
|
|
2710
|
+
if (row.rateLimitWindowMs != null) override.windowMs = row.rateLimitWindowMs;
|
|
2711
|
+
return override;
|
|
2712
|
+
}
|
|
2713
|
+
function extractModelRestriction(row) {
|
|
2714
|
+
if (row.enableModelRestriction !== true) return void 0;
|
|
2715
|
+
return {
|
|
2716
|
+
mode: row.restrictionMode ?? "blacklist",
|
|
2717
|
+
models: row.restrictedModels ?? []
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2720
|
+
function toVerifiedKey(row) {
|
|
2721
|
+
const key = { id: row.id };
|
|
2722
|
+
if (row.maxConcurrency !== void 0 && row.maxConcurrency !== null) {
|
|
2723
|
+
key.maxConcurrency = row.maxConcurrency;
|
|
2724
|
+
}
|
|
2725
|
+
const costLimits = extractCostLimits(row);
|
|
2726
|
+
if (costLimits) key.costLimits = costLimits;
|
|
2727
|
+
const rateLimit = extractRateLimit(row);
|
|
2728
|
+
if (rateLimit) key.rateLimit = rateLimit;
|
|
2729
|
+
const modelRestriction = extractModelRestriction(row);
|
|
2730
|
+
if (modelRestriction) key.modelRestriction = modelRestriction;
|
|
2731
|
+
return key;
|
|
2732
|
+
}
|
|
2733
|
+
async function verifyKey(db, presentedKey, now = Date.now()) {
|
|
2734
|
+
if (!presentedKey) return { status: "invalid" };
|
|
1754
2735
|
const trimmed = presentedKey.trim();
|
|
1755
|
-
if (!trimmed) return
|
|
2736
|
+
if (!trimmed) return { status: "invalid" };
|
|
1756
2737
|
const row = await db.outboundApiKeysGetByHash(hashKey(trimmed));
|
|
1757
|
-
if (!row) return
|
|
1758
|
-
if (!row.enabled || row.revokedAt !== null) return
|
|
2738
|
+
if (!row) return { status: "invalid" };
|
|
2739
|
+
if (!row.enabled || row.revokedAt !== null) return { status: "invalid" };
|
|
2740
|
+
const expiry = computeKeyExpiry(row, now);
|
|
2741
|
+
if (expiry.expired) return { status: "expired" };
|
|
1759
2742
|
void db.outboundApiKeysTouchLastUsed(row.id).catch(() => {
|
|
1760
2743
|
});
|
|
1761
|
-
|
|
2744
|
+
if (expiry.needsActivation) {
|
|
2745
|
+
void db.outboundApiKeysMarkActivated(row.id, now).catch(() => {
|
|
2746
|
+
});
|
|
2747
|
+
}
|
|
2748
|
+
return { status: "ok", key: toVerifiedKey(row) };
|
|
2749
|
+
}
|
|
2750
|
+
async function verifyPresentedKey(db, presentedKey) {
|
|
2751
|
+
const result = await verifyKey(db, presentedKey);
|
|
2752
|
+
return result.status === "ok" ? result.key : null;
|
|
1762
2753
|
}
|
|
1763
2754
|
|
|
1764
|
-
// src/outbound-api/
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
"
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
return
|
|
1777
|
-
}
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
return
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
2755
|
+
// src/outbound-api/quotaWarn.ts
|
|
2756
|
+
var WEBHOOK_QUOTA_WARN_RATIO = 0.8;
|
|
2757
|
+
function computeQuotaWarnings(limits, spend, ratio = WEBHOOK_QUOTA_WARN_RATIO) {
|
|
2758
|
+
const out = [];
|
|
2759
|
+
const consider = (scope, limit, spent, windowStart) => {
|
|
2760
|
+
if (limit == null || limit <= 0) return;
|
|
2761
|
+
if (spent >= limit) return;
|
|
2762
|
+
if (spent >= ratio * limit) out.push({ scope, limitUsd: limit, spentUsd: spent, windowStart });
|
|
2763
|
+
};
|
|
2764
|
+
consider("daily", limits.dailyUsd, spend.dailyUsd, spend.dailyWindowStart);
|
|
2765
|
+
consider("weekly", limits.weeklyUsd, spend.weeklyUsd, spend.weeklyWindowStart);
|
|
2766
|
+
consider("total", limits.totalUsd, spend.totalUsd, 0);
|
|
2767
|
+
return out;
|
|
2768
|
+
}
|
|
2769
|
+
var WARN_GUARD_MAX = 1e4;
|
|
2770
|
+
var warned = /* @__PURE__ */ new Set();
|
|
2771
|
+
function markQuotaWarnedOnce(keyId, scope, windowStart) {
|
|
2772
|
+
const key = `${keyId}\0${scope}\0${windowStart}`;
|
|
2773
|
+
if (warned.has(key)) return false;
|
|
2774
|
+
if (warned.size >= WARN_GUARD_MAX) warned.clear();
|
|
2775
|
+
warned.add(key);
|
|
2776
|
+
return true;
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
// src/outbound-api/outboundConcurrencyGate.ts
|
|
2780
|
+
var ConcurrencyQueueFullError = class extends Error {
|
|
2781
|
+
constructor(apiKeyId, maxQueueSize) {
|
|
2782
|
+
super(`concurrency queue full (max ${maxQueueSize}) for key '${apiKeyId}'`);
|
|
2783
|
+
this.apiKeyId = apiKeyId;
|
|
2784
|
+
this.maxQueueSize = maxQueueSize;
|
|
2785
|
+
this.name = "ConcurrencyQueueFullError";
|
|
2786
|
+
}
|
|
2787
|
+
apiKeyId;
|
|
2788
|
+
maxQueueSize;
|
|
2789
|
+
code = "concurrency_queue_full";
|
|
2790
|
+
};
|
|
2791
|
+
var ConcurrencyWaitTimeoutError = class extends Error {
|
|
2792
|
+
constructor(apiKeyId, waitTimeoutMs) {
|
|
2793
|
+
super(`concurrency wait timed out after ${waitTimeoutMs}ms for key '${apiKeyId}'`);
|
|
2794
|
+
this.apiKeyId = apiKeyId;
|
|
2795
|
+
this.waitTimeoutMs = waitTimeoutMs;
|
|
2796
|
+
this.name = "ConcurrencyWaitTimeoutError";
|
|
2797
|
+
}
|
|
2798
|
+
apiKeyId;
|
|
2799
|
+
waitTimeoutMs;
|
|
2800
|
+
code = "concurrency_wait_timeout";
|
|
2801
|
+
};
|
|
2802
|
+
var ConcurrencyWaitCancelledError = class extends Error {
|
|
2803
|
+
constructor(apiKeyId) {
|
|
2804
|
+
super(`concurrency wait cancelled for key '${apiKeyId}'`);
|
|
2805
|
+
this.apiKeyId = apiKeyId;
|
|
2806
|
+
this.name = "ConcurrencyWaitCancelledError";
|
|
2807
|
+
}
|
|
2808
|
+
apiKeyId;
|
|
2809
|
+
code = "concurrency_wait_cancelled";
|
|
2810
|
+
};
|
|
2811
|
+
function isConcurrencyRejection(err) {
|
|
2812
|
+
return err instanceof ConcurrencyQueueFullError || err instanceof ConcurrencyWaitTimeoutError || err instanceof ConcurrencyWaitCancelledError;
|
|
2813
|
+
}
|
|
2814
|
+
var OutboundConcurrencyGate = class {
|
|
2815
|
+
states = /* @__PURE__ */ new Map();
|
|
2816
|
+
ensure(apiKeyId) {
|
|
2817
|
+
let state = this.states.get(apiKeyId);
|
|
2818
|
+
if (!state) {
|
|
2819
|
+
state = { active: 0, waiters: [] };
|
|
2820
|
+
this.states.set(apiKeyId, state);
|
|
2821
|
+
}
|
|
2822
|
+
return state;
|
|
2823
|
+
}
|
|
2824
|
+
/** Delete a key's state once it is fully idle (no active, no waiters). */
|
|
2825
|
+
gc(apiKeyId) {
|
|
2826
|
+
const state = this.states.get(apiKeyId);
|
|
2827
|
+
if (state && state.active === 0 && state.waiters.length === 0) {
|
|
2828
|
+
this.states.delete(apiKeyId);
|
|
1795
2829
|
}
|
|
1796
2830
|
}
|
|
2831
|
+
/**
|
|
2832
|
+
* Acquire a concurrency slot for `apiKeyId` under `limit`. Grants immediately
|
|
2833
|
+
* when `active < limit`; else enqueues FIFO while under the per-key queue cap;
|
|
2834
|
+
* else rejects queue-full. `limit <= 0` = unlimited (always granted).
|
|
2835
|
+
*/
|
|
2836
|
+
acquire(apiKeyId, limit, options) {
|
|
2837
|
+
const state = this.ensure(apiKeyId);
|
|
2838
|
+
const cap = limit > 0 ? limit : Infinity;
|
|
2839
|
+
if (state.active < cap) {
|
|
2840
|
+
state.active += 1;
|
|
2841
|
+
const slot = this.makeSlot(apiKeyId);
|
|
2842
|
+
return { granted: Promise.resolve(slot), cancel: slot.cancel };
|
|
2843
|
+
}
|
|
2844
|
+
const maxQueueSize = Math.max(
|
|
2845
|
+
Number.isFinite(cap) ? cap * options.maxQueueSizeFactor : Infinity,
|
|
2846
|
+
options.minQueueSize
|
|
2847
|
+
);
|
|
2848
|
+
if (state.waiters.length >= maxQueueSize) {
|
|
2849
|
+
const err = new ConcurrencyQueueFullError(apiKeyId, maxQueueSize);
|
|
2850
|
+
return { granted: Promise.reject(err), cancel: () => {
|
|
2851
|
+
} };
|
|
2852
|
+
}
|
|
2853
|
+
let settled = false;
|
|
2854
|
+
let granted = false;
|
|
2855
|
+
let resolveGrant;
|
|
2856
|
+
let rejectGrant;
|
|
2857
|
+
const grantedPromise = new Promise((resolve, reject) => {
|
|
2858
|
+
resolveGrant = resolve;
|
|
2859
|
+
rejectGrant = reject;
|
|
2860
|
+
});
|
|
2861
|
+
const waiter = {
|
|
2862
|
+
cap,
|
|
2863
|
+
settled: false,
|
|
2864
|
+
timeoutHandle: null,
|
|
2865
|
+
grant: () => {
|
|
2866
|
+
if (settled) return;
|
|
2867
|
+
settled = true;
|
|
2868
|
+
granted = true;
|
|
2869
|
+
waiter.settled = true;
|
|
2870
|
+
if (waiter.timeoutHandle) clearTimeout(waiter.timeoutHandle);
|
|
2871
|
+
state.active += 1;
|
|
2872
|
+
resolveGrant(this.makeSlot(apiKeyId));
|
|
2873
|
+
},
|
|
2874
|
+
fail: (err) => {
|
|
2875
|
+
if (settled) return;
|
|
2876
|
+
settled = true;
|
|
2877
|
+
waiter.settled = true;
|
|
2878
|
+
if (waiter.timeoutHandle) clearTimeout(waiter.timeoutHandle);
|
|
2879
|
+
const idx = state.waiters.indexOf(waiter);
|
|
2880
|
+
if (idx >= 0) state.waiters.splice(idx, 1);
|
|
2881
|
+
rejectGrant(err);
|
|
2882
|
+
this.gc(apiKeyId);
|
|
2883
|
+
}
|
|
2884
|
+
};
|
|
2885
|
+
waiter.timeoutHandle = setTimeout(() => {
|
|
2886
|
+
waiter.fail(new ConcurrencyWaitTimeoutError(apiKeyId, options.waitTimeoutMs));
|
|
2887
|
+
}, options.waitTimeoutMs);
|
|
2888
|
+
state.waiters.push(waiter);
|
|
2889
|
+
const cancel = () => {
|
|
2890
|
+
if (settled || granted) return;
|
|
2891
|
+
waiter.fail(new ConcurrencyWaitCancelledError(apiKeyId));
|
|
2892
|
+
};
|
|
2893
|
+
return { granted: grantedPromise, cancel };
|
|
2894
|
+
}
|
|
2895
|
+
/** Build an idempotent granted slot (`release` decrements; `cancel` is a no-op). */
|
|
2896
|
+
makeSlot(apiKeyId) {
|
|
2897
|
+
let released = false;
|
|
2898
|
+
const release2 = () => {
|
|
2899
|
+
if (released) return;
|
|
2900
|
+
released = true;
|
|
2901
|
+
const state = this.states.get(apiKeyId);
|
|
2902
|
+
if (!state) return;
|
|
2903
|
+
state.active = Math.max(0, state.active - 1);
|
|
2904
|
+
this.dispatchNext(apiKeyId);
|
|
2905
|
+
this.gc(apiKeyId);
|
|
2906
|
+
};
|
|
2907
|
+
return { release: release2, cancel: () => {
|
|
2908
|
+
} };
|
|
2909
|
+
}
|
|
2910
|
+
/** After a release freed a slot, grant the FIFO head if it fits under its cap. */
|
|
2911
|
+
dispatchNext(apiKeyId) {
|
|
2912
|
+
const state = this.states.get(apiKeyId);
|
|
2913
|
+
if (!state || state.waiters.length === 0) return;
|
|
2914
|
+
const head = state.waiters[0];
|
|
2915
|
+
if (state.active < head.cap) {
|
|
2916
|
+
state.waiters.shift();
|
|
2917
|
+
head.grant();
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
/** Snapshot for observability — only keys with active slots or waiters. */
|
|
2921
|
+
getStatus() {
|
|
2922
|
+
const out = [];
|
|
2923
|
+
for (const [apiKeyId, state] of this.states) {
|
|
2924
|
+
if (state.active > 0 || state.waiters.length > 0) {
|
|
2925
|
+
out.push({ apiKeyId, active: state.active, waiting: state.waiters.length });
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
return out;
|
|
2929
|
+
}
|
|
2930
|
+
/** Drop all state + pending timers (tests / teardown). */
|
|
2931
|
+
reset() {
|
|
2932
|
+
for (const state of this.states.values()) {
|
|
2933
|
+
for (const w of state.waiters) {
|
|
2934
|
+
if (w.timeoutHandle) clearTimeout(w.timeoutHandle);
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
this.states.clear();
|
|
2938
|
+
}
|
|
2939
|
+
};
|
|
2940
|
+
|
|
2941
|
+
// src/outbound-api/outboundRateLimiter.ts
|
|
2942
|
+
var DEFAULT_WINDOW_MS = 6e4;
|
|
2943
|
+
var DEFAULT_MAX_REQUESTS = 60;
|
|
2944
|
+
var OutboundRateLimiter = class {
|
|
2945
|
+
windowMs;
|
|
2946
|
+
maxRequests;
|
|
2947
|
+
/** apiKeyId → ascending request timestamps within the current window. */
|
|
2948
|
+
hits = /* @__PURE__ */ new Map();
|
|
2949
|
+
constructor(options = {}) {
|
|
2950
|
+
this.windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
|
|
2951
|
+
this.maxRequests = options.maxRequests ?? DEFAULT_MAX_REQUESTS;
|
|
2952
|
+
}
|
|
2953
|
+
/**
|
|
2954
|
+
* Record a request for `apiKeyId` and decide whether it is allowed. Prunes
|
|
2955
|
+
* timestamps older than the window first; when allowed, the request's
|
|
2956
|
+
* timestamp is appended.
|
|
2957
|
+
*
|
|
2958
|
+
* `override` (outbound-key-policy) supplies a PER-KEY window/max for this
|
|
2959
|
+
* bucket, superseding the instance defaults for THAT key. Absent ⇒ the
|
|
2960
|
+
* instance default 60/60s (byte-identical to before this change). An effective
|
|
2961
|
+
* `maxRequests` of `0` means UNLIMITED — the request is allowed and NOT
|
|
2962
|
+
* recorded (no bucket growth).
|
|
2963
|
+
*/
|
|
2964
|
+
check(apiKeyId, now = Date.now(), override) {
|
|
2965
|
+
const maxRequests = override?.maxRequests ?? this.maxRequests;
|
|
2966
|
+
const windowMs = override?.windowMs ?? this.windowMs;
|
|
2967
|
+
if (maxRequests === 0) return { allowed: true, retryAfterSeconds: 0 };
|
|
2968
|
+
const cutoff = now - windowMs;
|
|
2969
|
+
const timestamps = (this.hits.get(apiKeyId) ?? []).filter((ts) => ts > cutoff);
|
|
2970
|
+
if (timestamps.length >= maxRequests) {
|
|
2971
|
+
if (timestamps.length === 0) {
|
|
2972
|
+
this.hits.delete(apiKeyId);
|
|
2973
|
+
return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil(windowMs / 1e3)) };
|
|
2974
|
+
}
|
|
2975
|
+
const oldest = timestamps[0];
|
|
2976
|
+
const retryAfterMs = Math.max(0, oldest + windowMs - now);
|
|
2977
|
+
this.hits.set(apiKeyId, timestamps);
|
|
2978
|
+
return {
|
|
2979
|
+
allowed: false,
|
|
2980
|
+
retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1e3))
|
|
2981
|
+
};
|
|
2982
|
+
}
|
|
2983
|
+
timestamps.push(now);
|
|
2984
|
+
this.hits.set(apiKeyId, timestamps);
|
|
2985
|
+
return { allowed: true, retryAfterSeconds: 0 };
|
|
2986
|
+
}
|
|
2987
|
+
/** Drop all recorded state (tests / teardown). */
|
|
2988
|
+
reset() {
|
|
2989
|
+
this.hits.clear();
|
|
2990
|
+
}
|
|
2991
|
+
};
|
|
2992
|
+
|
|
2993
|
+
// src/outbound-api/userMessageDetection.ts
|
|
2994
|
+
function asArray(v) {
|
|
2995
|
+
return Array.isArray(v) ? v : [];
|
|
2996
|
+
}
|
|
2997
|
+
function asObject(v) {
|
|
2998
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
2999
|
+
}
|
|
3000
|
+
function last(arr) {
|
|
3001
|
+
return arr.length > 0 ? arr[arr.length - 1] : void 0;
|
|
3002
|
+
}
|
|
3003
|
+
function isUserMessageAnthropic(body) {
|
|
3004
|
+
const messages = asArray(body["messages"]);
|
|
3005
|
+
const lastMsg = asObject(last(messages));
|
|
3006
|
+
if (!lastMsg || lastMsg["role"] !== "user") return false;
|
|
3007
|
+
const content = lastMsg["content"];
|
|
3008
|
+
if (typeof content === "string") return true;
|
|
3009
|
+
if (Array.isArray(content)) {
|
|
3010
|
+
const hasToolResult = content.some(
|
|
3011
|
+
(block) => asObject(block)?.["type"] === "tool_result"
|
|
3012
|
+
);
|
|
3013
|
+
return !hasToolResult;
|
|
3014
|
+
}
|
|
3015
|
+
return false;
|
|
3016
|
+
}
|
|
3017
|
+
function isUserMessageResponses(body) {
|
|
3018
|
+
const input = body["input"];
|
|
3019
|
+
if (typeof input === "string") return input.length > 0;
|
|
3020
|
+
if (Array.isArray(input)) {
|
|
3021
|
+
const lastItem = asObject(last(input));
|
|
3022
|
+
if (!lastItem) return false;
|
|
3023
|
+
if (lastItem["type"] === "function_call_output") return false;
|
|
3024
|
+
return lastItem["role"] === "user";
|
|
3025
|
+
}
|
|
1797
3026
|
return false;
|
|
1798
3027
|
}
|
|
1799
|
-
function
|
|
1800
|
-
const messages = body["messages"];
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
3028
|
+
function isUserMessageChat(body) {
|
|
3029
|
+
const messages = asArray(body["messages"]);
|
|
3030
|
+
const lastMsg = asObject(last(messages));
|
|
3031
|
+
if (!lastMsg) return false;
|
|
3032
|
+
return lastMsg["role"] === "user";
|
|
3033
|
+
}
|
|
3034
|
+
function isUserMessageGemini(body) {
|
|
3035
|
+
const contents = asArray(body["contents"]);
|
|
3036
|
+
const lastContent = asObject(last(contents));
|
|
3037
|
+
if (!lastContent || lastContent["role"] !== "user") return false;
|
|
3038
|
+
const parts = asArray(lastContent["parts"]);
|
|
3039
|
+
const hasFunctionResponse = parts.some(
|
|
3040
|
+
(part) => asObject(part)?.["functionResponse"] !== void 0
|
|
3041
|
+
);
|
|
3042
|
+
return !hasFunctionResponse;
|
|
3043
|
+
}
|
|
3044
|
+
function isUserMessageRequest(endpoint, parsedBody) {
|
|
3045
|
+
const body = asObject(parsedBody);
|
|
3046
|
+
if (!body) return false;
|
|
3047
|
+
switch (endpoint) {
|
|
3048
|
+
case "messages":
|
|
3049
|
+
return isUserMessageAnthropic(body);
|
|
3050
|
+
case "responses":
|
|
3051
|
+
return isUserMessageResponses(body);
|
|
3052
|
+
case "chat":
|
|
3053
|
+
return isUserMessageChat(body);
|
|
3054
|
+
case "gemini":
|
|
3055
|
+
return isUserMessageGemini(body);
|
|
3056
|
+
default:
|
|
3057
|
+
return false;
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
// src/outbound-api/userMessageSerialQueue.ts
|
|
3062
|
+
var SerialQueueTimeoutError = class extends Error {
|
|
3063
|
+
constructor(providerId, waitTimeoutMs) {
|
|
3064
|
+
super(`user-message serial queue wait timed out after ${waitTimeoutMs}ms for '${providerId}'`);
|
|
3065
|
+
this.providerId = providerId;
|
|
3066
|
+
this.waitTimeoutMs = waitTimeoutMs;
|
|
3067
|
+
this.name = "SerialQueueTimeoutError";
|
|
3068
|
+
}
|
|
3069
|
+
providerId;
|
|
3070
|
+
waitTimeoutMs;
|
|
3071
|
+
code = "serial_queue_timeout";
|
|
3072
|
+
};
|
|
3073
|
+
function isSerialQueueTimeout(err) {
|
|
3074
|
+
return err instanceof SerialQueueTimeoutError;
|
|
3075
|
+
}
|
|
3076
|
+
var UserMessageSerialQueue = class {
|
|
3077
|
+
states = /* @__PURE__ */ new Map();
|
|
3078
|
+
ensure(providerId) {
|
|
3079
|
+
let state = this.states.get(providerId);
|
|
3080
|
+
if (!state) {
|
|
3081
|
+
state = { holding: false, lastReleaseTs: null, waiters: [], dispatchHandle: null };
|
|
3082
|
+
this.states.set(providerId, state);
|
|
3083
|
+
}
|
|
3084
|
+
return state;
|
|
3085
|
+
}
|
|
3086
|
+
/**
|
|
3087
|
+
* Acquire the serial slot for `providerId`. Resolves immediately when the slot
|
|
3088
|
+
* is free, no one is queued, and the `delayMs` gap since the last release has
|
|
3089
|
+
* elapsed; otherwise enqueues FIFO and resolves in order (or rejects with a
|
|
3090
|
+
* {@link SerialQueueTimeoutError} after `waitTimeoutMs`).
|
|
3091
|
+
*/
|
|
3092
|
+
acquire(providerId, options) {
|
|
3093
|
+
const { waitTimeoutMs, delayMs } = options;
|
|
3094
|
+
const now = options.now ?? Date.now();
|
|
3095
|
+
const state = this.ensure(providerId);
|
|
3096
|
+
const spacingElapsed = state.lastReleaseTs === null || now - state.lastReleaseTs >= delayMs;
|
|
3097
|
+
if (!state.holding && state.waiters.length === 0 && spacingElapsed) {
|
|
3098
|
+
state.holding = true;
|
|
3099
|
+
return Promise.resolve(this.makeSlot(providerId));
|
|
3100
|
+
}
|
|
3101
|
+
return new Promise((resolve, reject) => {
|
|
3102
|
+
const waiter = {
|
|
3103
|
+
delayMs,
|
|
3104
|
+
resolve,
|
|
3105
|
+
reject,
|
|
3106
|
+
timeoutHandle: null,
|
|
3107
|
+
done: false
|
|
3108
|
+
};
|
|
3109
|
+
waiter.timeoutHandle = setTimeout(() => {
|
|
3110
|
+
if (waiter.done) return;
|
|
3111
|
+
waiter.done = true;
|
|
3112
|
+
const idx = state.waiters.indexOf(waiter);
|
|
3113
|
+
if (idx >= 0) state.waiters.splice(idx, 1);
|
|
3114
|
+
reject(new SerialQueueTimeoutError(providerId, waitTimeoutMs));
|
|
3115
|
+
}, waitTimeoutMs);
|
|
3116
|
+
state.waiters.push(waiter);
|
|
3117
|
+
this.maybeDispatch(providerId);
|
|
3118
|
+
});
|
|
3119
|
+
}
|
|
3120
|
+
/** Build an idempotent release for a granted slot. */
|
|
3121
|
+
makeSlot(providerId) {
|
|
3122
|
+
let released = false;
|
|
3123
|
+
return {
|
|
3124
|
+
release: () => {
|
|
3125
|
+
if (released) return;
|
|
3126
|
+
released = true;
|
|
3127
|
+
const state = this.states.get(providerId);
|
|
3128
|
+
if (!state) return;
|
|
3129
|
+
state.holding = false;
|
|
3130
|
+
state.lastReleaseTs = Date.now();
|
|
3131
|
+
this.maybeDispatch(providerId);
|
|
3132
|
+
}
|
|
3133
|
+
};
|
|
3134
|
+
}
|
|
3135
|
+
/**
|
|
3136
|
+
* Grant the head waiter when the slot is free and the head's `delayMs` gap has
|
|
3137
|
+
* elapsed; otherwise schedule a dispatch for the residual gap. No-op while the
|
|
3138
|
+
* slot is held (release re-invokes this) or the queue is empty.
|
|
3139
|
+
*/
|
|
3140
|
+
maybeDispatch(providerId) {
|
|
3141
|
+
const state = this.states.get(providerId);
|
|
3142
|
+
if (!state || state.holding || state.waiters.length === 0) return;
|
|
3143
|
+
const head = state.waiters[0];
|
|
3144
|
+
const now = Date.now();
|
|
3145
|
+
const elapsed = state.lastReleaseTs === null ? Infinity : now - state.lastReleaseTs;
|
|
3146
|
+
const residual = Math.max(0, head.delayMs - elapsed);
|
|
3147
|
+
if (residual <= 0) {
|
|
3148
|
+
this.grantHead(providerId);
|
|
3149
|
+
return;
|
|
3150
|
+
}
|
|
3151
|
+
if (state.dispatchHandle !== null) return;
|
|
3152
|
+
state.dispatchHandle = setTimeout(() => {
|
|
3153
|
+
state.dispatchHandle = null;
|
|
3154
|
+
this.grantHead(providerId);
|
|
3155
|
+
}, residual);
|
|
3156
|
+
}
|
|
3157
|
+
/** Hand the slot to the FIFO head (if free and someone is waiting). */
|
|
3158
|
+
grantHead(providerId) {
|
|
3159
|
+
const state = this.states.get(providerId);
|
|
3160
|
+
if (!state || state.holding || state.waiters.length === 0) return;
|
|
3161
|
+
if (state.dispatchHandle !== null) {
|
|
3162
|
+
clearTimeout(state.dispatchHandle);
|
|
3163
|
+
state.dispatchHandle = null;
|
|
3164
|
+
}
|
|
3165
|
+
const head = state.waiters.shift();
|
|
3166
|
+
if (!head) return;
|
|
3167
|
+
if (head.timeoutHandle) clearTimeout(head.timeoutHandle);
|
|
3168
|
+
head.done = true;
|
|
3169
|
+
state.holding = true;
|
|
3170
|
+
head.resolve(this.makeSlot(providerId));
|
|
3171
|
+
}
|
|
3172
|
+
/** Snapshot for observability — only keys holding a slot or with waiters. */
|
|
3173
|
+
getStatus() {
|
|
3174
|
+
const out = [];
|
|
3175
|
+
for (const [providerId, state] of this.states) {
|
|
3176
|
+
if (state.holding || state.waiters.length > 0) {
|
|
3177
|
+
out.push({ providerId, holding: state.holding, waiting: state.waiters.length });
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
return out;
|
|
3181
|
+
}
|
|
3182
|
+
/** Drop all state + pending timers (tests / teardown). */
|
|
3183
|
+
reset() {
|
|
3184
|
+
for (const state of this.states.values()) {
|
|
3185
|
+
if (state.dispatchHandle) clearTimeout(state.dispatchHandle);
|
|
3186
|
+
for (const w of state.waiters) {
|
|
3187
|
+
if (w.timeoutHandle) clearTimeout(w.timeoutHandle);
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
this.states.clear();
|
|
3191
|
+
}
|
|
3192
|
+
};
|
|
3193
|
+
|
|
3194
|
+
// src/outbound-api/voucher.ts
|
|
3195
|
+
import { createHash as createHash2 } from "crypto";
|
|
3196
|
+
var CODE_ENTROPY_CHARS = 32;
|
|
3197
|
+
var CODE_PREFIX = "CC_";
|
|
3198
|
+
var DISPLAY_PREFIX_LEN = 8;
|
|
3199
|
+
var DAY_MS2 = 864e5;
|
|
3200
|
+
function generateVoucherCode() {
|
|
3201
|
+
return CODE_PREFIX + randomBase62(CODE_ENTROPY_CHARS);
|
|
3202
|
+
}
|
|
3203
|
+
function hashVoucherCode(code) {
|
|
3204
|
+
return createHash2("sha256").update(code, "utf8").digest("hex");
|
|
3205
|
+
}
|
|
3206
|
+
function voucherCodePrefix(code) {
|
|
3207
|
+
return code.slice(0, DISPLAY_PREFIX_LEN);
|
|
3208
|
+
}
|
|
3209
|
+
function newVoucherId() {
|
|
3210
|
+
return `vch_${Date.now()}_${randomBase62(8)}`;
|
|
3211
|
+
}
|
|
3212
|
+
function computeVoucherGrant(voucher, currentKey, now) {
|
|
3213
|
+
if (voucher.type === "credit") {
|
|
3214
|
+
const current = currentKey?.totalCostLimitUsd ?? 0;
|
|
3215
|
+
let granted2 = current + (voucher.creditUsd ?? 0);
|
|
3216
|
+
if (voucher.maxTotalCostLimitUsd != null) {
|
|
3217
|
+
granted2 = Math.min(granted2, voucher.maxTotalCostLimitUsd);
|
|
3218
|
+
}
|
|
3219
|
+
return { totalCostLimitUsd: granted2 };
|
|
3220
|
+
}
|
|
3221
|
+
const currentExpiry = currentKey?.expiresAt ?? now;
|
|
3222
|
+
let granted = currentExpiry + (voucher.renewalDays ?? 0) * DAY_MS2;
|
|
3223
|
+
if (voucher.maxExpiryDays != null) {
|
|
3224
|
+
granted = Math.min(granted, now + voucher.maxExpiryDays * DAY_MS2);
|
|
3225
|
+
}
|
|
3226
|
+
return { expiresAt: granted };
|
|
3227
|
+
}
|
|
3228
|
+
function toVoucherInfo(v) {
|
|
3229
|
+
const info = {
|
|
3230
|
+
id: v.id,
|
|
3231
|
+
codePrefix: v.codePrefix,
|
|
3232
|
+
type: v.type,
|
|
3233
|
+
status: v.status,
|
|
3234
|
+
createdAt: v.createdAt
|
|
3235
|
+
};
|
|
3236
|
+
if (v.creditUsd != null) info.creditUsd = v.creditUsd;
|
|
3237
|
+
if (v.renewalDays != null) info.renewalDays = v.renewalDays;
|
|
3238
|
+
if (v.maxTotalCostLimitUsd != null) info.maxTotalCostLimitUsd = v.maxTotalCostLimitUsd;
|
|
3239
|
+
if (v.maxExpiryDays != null) info.maxExpiryDays = v.maxExpiryDays;
|
|
3240
|
+
if (v.redeemedAt != null) info.redeemedAt = v.redeemedAt;
|
|
3241
|
+
if (v.redeemedByKeyId != null) info.redeemedByKeyId = v.redeemedByKeyId;
|
|
3242
|
+
if (v.grantApplied != null) info.grantApplied = v.grantApplied;
|
|
3243
|
+
if (v.grantedTotalCostLimitUsd != null) info.grantedTotalCostLimitUsd = v.grantedTotalCostLimitUsd;
|
|
3244
|
+
if (v.grantedExpiresAt != null) info.grantedExpiresAt = v.grantedExpiresAt;
|
|
3245
|
+
if (v.revokedAt != null) info.revokedAt = v.revokedAt;
|
|
3246
|
+
return info;
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
// src/outbound-api/voucherRedeem.ts
|
|
3250
|
+
function isRedeemRequest(method, url) {
|
|
3251
|
+
if (method !== "POST" || !url) return false;
|
|
3252
|
+
const path = url.split("?")[0]?.replace(/\/+$/, "") ?? "";
|
|
3253
|
+
return path.endsWith("/redeem");
|
|
3254
|
+
}
|
|
3255
|
+
function writeErr(res, status, message, headers = {}) {
|
|
3256
|
+
if (res.headersSent) return;
|
|
3257
|
+
res.writeHead(status, { "Content-Type": "application/json", ...headers });
|
|
3258
|
+
res.end(JSON.stringify({ error: { type: "voucher_error", message } }));
|
|
3259
|
+
}
|
|
3260
|
+
function writeResult(res, result) {
|
|
3261
|
+
if (res.headersSent) return;
|
|
3262
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3263
|
+
res.end(JSON.stringify({ ok: true, ...result }));
|
|
1810
3264
|
}
|
|
1811
|
-
function
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
if (part?.type === "input_image") return true;
|
|
1819
|
-
}
|
|
1820
|
-
}
|
|
1821
|
-
if (item?.type === "input_image") return true;
|
|
1822
|
-
}
|
|
1823
|
-
return false;
|
|
3265
|
+
function readBody2(req) {
|
|
3266
|
+
return new Promise((resolve, reject) => {
|
|
3267
|
+
const chunks = [];
|
|
3268
|
+
req.on("data", (c) => chunks.push(c));
|
|
3269
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
3270
|
+
req.on("error", reject);
|
|
3271
|
+
});
|
|
1824
3272
|
}
|
|
1825
|
-
function
|
|
1826
|
-
const
|
|
1827
|
-
if (
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
3273
|
+
function grantToResult(type, granted) {
|
|
3274
|
+
const result = { type };
|
|
3275
|
+
if (granted.totalCostLimitUsd != null) result.totalCostLimitUsd = granted.totalCostLimitUsd;
|
|
3276
|
+
if (granted.expiresAt != null) result.expiresAt = granted.expiresAt;
|
|
3277
|
+
return result;
|
|
3278
|
+
}
|
|
3279
|
+
function currentResult(type, keyRow) {
|
|
3280
|
+
const result = { type };
|
|
3281
|
+
if (type === "credit") {
|
|
3282
|
+
if (keyRow?.totalCostLimitUsd != null) result.totalCostLimitUsd = keyRow.totalCostLimitUsd;
|
|
3283
|
+
} else if (keyRow?.expiresAt != null) {
|
|
3284
|
+
result.expiresAt = keyRow.expiresAt;
|
|
3285
|
+
}
|
|
3286
|
+
return result;
|
|
3287
|
+
}
|
|
3288
|
+
function recordedAbsolute(v) {
|
|
3289
|
+
const granted = {};
|
|
3290
|
+
if (v.grantedTotalCostLimitUsd != null) granted.totalCostLimitUsd = v.grantedTotalCostLimitUsd;
|
|
3291
|
+
if (v.grantedExpiresAt != null) granted.expiresAt = v.grantedExpiresAt;
|
|
3292
|
+
return granted;
|
|
3293
|
+
}
|
|
3294
|
+
function policyFrom(granted) {
|
|
3295
|
+
const policy = {};
|
|
3296
|
+
if (granted.totalCostLimitUsd != null) policy.totalCostLimitUsd = granted.totalCostLimitUsd;
|
|
3297
|
+
if (granted.expiresAt != null) policy.expiresAt = granted.expiresAt;
|
|
3298
|
+
return policy;
|
|
3299
|
+
}
|
|
3300
|
+
var NOT_REDEEMABLE = "Invalid or non-redeemable code";
|
|
3301
|
+
var fallbackMutex = null;
|
|
3302
|
+
function getFallbackMutex() {
|
|
3303
|
+
fallbackMutex ??= new KeyedMutex();
|
|
3304
|
+
return fallbackMutex;
|
|
3305
|
+
}
|
|
3306
|
+
async function handleVoucherRedeem(req, res, deps, voucherEnabled, redeemLimiter, verifiedKeyId, presentedKey, now, redeemMutex) {
|
|
3307
|
+
if (!voucherEnabled || !deps.voucherDb) {
|
|
3308
|
+
writeErr(res, 403, "Voucher redemption is not enabled");
|
|
3309
|
+
return;
|
|
1835
3310
|
}
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
return anthropicHasVision(body);
|
|
1842
|
-
case "openai-chat":
|
|
1843
|
-
return openaiChatHasVision(body);
|
|
1844
|
-
case "openai-responses":
|
|
1845
|
-
return openaiResponsesHasVision(body);
|
|
1846
|
-
case "gemini-generatecontent":
|
|
1847
|
-
return geminiHasVision(body);
|
|
1848
|
-
default:
|
|
1849
|
-
return false;
|
|
3311
|
+
const voucherDb = deps.voucherDb;
|
|
3312
|
+
const rl = redeemLimiter.check(verifiedKeyId, now);
|
|
3313
|
+
if (!rl.allowed) {
|
|
3314
|
+
writeErr(res, 429, "Too many redeem attempts", { "Retry-After": String(rl.retryAfterSeconds) });
|
|
3315
|
+
return;
|
|
1850
3316
|
}
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
const reqNorm = normalizeModelId(requestedModel);
|
|
1859
|
-
const inOverride = overrides.some((id) => {
|
|
1860
|
-
if (!id) return false;
|
|
1861
|
-
const candidate = id.includes(",") ? id.slice(id.indexOf(",") + 1) : id;
|
|
1862
|
-
return normalizeModelId(candidate) === reqNorm || candidate === requestedModel;
|
|
1863
|
-
});
|
|
1864
|
-
if (inOverride) return "background";
|
|
1865
|
-
}
|
|
1866
|
-
if (isBackgroundTierModel(requestedModel)) return "background";
|
|
3317
|
+
const raw = await readBody2(req);
|
|
3318
|
+
let code;
|
|
3319
|
+
try {
|
|
3320
|
+
code = raw ? JSON.parse(raw)["code"] : void 0;
|
|
3321
|
+
} catch {
|
|
3322
|
+
writeErr(res, 400, "Invalid JSON in request body");
|
|
3323
|
+
return;
|
|
1867
3324
|
}
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
switch (endpoint) {
|
|
1872
|
-
case "chat":
|
|
1873
|
-
return "openai-chat";
|
|
1874
|
-
case "responses":
|
|
1875
|
-
return "openai-responses";
|
|
1876
|
-
case "messages":
|
|
1877
|
-
return "anthropic-messages";
|
|
1878
|
-
case "gemini":
|
|
1879
|
-
return "gemini-generatecontent";
|
|
3325
|
+
if (typeof code !== "string" || !code.trim()) {
|
|
3326
|
+
writeErr(res, 400, "Missing redemption code");
|
|
3327
|
+
return;
|
|
1880
3328
|
}
|
|
3329
|
+
const codeHash = hashVoucherCode(code.trim());
|
|
3330
|
+
const keyHash = hashKey(presentedKey.trim());
|
|
3331
|
+
const applyFirstPass = async (voucher, absolute) => {
|
|
3332
|
+
const ok = await deps.db.outboundApiKeysSetPolicy(verifiedKeyId, policyFrom(absolute));
|
|
3333
|
+
if (!ok) {
|
|
3334
|
+
await voucherDb.voucherRevertRedeem(voucher.id, verifiedKeyId);
|
|
3335
|
+
return { ok: false, status: 409, message: "Could not apply the grant \u2014 please retry" };
|
|
3336
|
+
}
|
|
3337
|
+
await voucherDb.voucherMarkGrantApplied(voucher.id);
|
|
3338
|
+
return { ok: true, result: grantToResult(voucher.type, absolute) };
|
|
3339
|
+
};
|
|
3340
|
+
const reconcileOne = async (voucher) => {
|
|
3341
|
+
const ok = await deps.db.outboundApiKeysSetPolicy(verifiedKeyId, policyFrom(recordedAbsolute(voucher)));
|
|
3342
|
+
if (ok) await voucherDb.voucherMarkGrantApplied(voucher.id);
|
|
3343
|
+
};
|
|
3344
|
+
const reconcileStranded = async () => {
|
|
3345
|
+
const all = await voucherDb.voucherList();
|
|
3346
|
+
const stranded = all.filter((v) => v.status === "redeemed" && v.grantApplied !== true && v.redeemedByKeyId === verifiedKeyId).sort((a, b) => (a.redeemedAt ?? 0) - (b.redeemedAt ?? 0));
|
|
3347
|
+
for (const v of stranded) await reconcileOne(v);
|
|
3348
|
+
};
|
|
3349
|
+
const runCritical = async () => {
|
|
3350
|
+
await reconcileStranded();
|
|
3351
|
+
const voucher = await voucherDb.voucherGetByHash(codeHash);
|
|
3352
|
+
if (!voucher || voucher.status === "revoked") {
|
|
3353
|
+
return { ok: false, status: 400, message: NOT_REDEEMABLE };
|
|
3354
|
+
}
|
|
3355
|
+
if (voucher.status === "redeemed") {
|
|
3356
|
+
if (voucher.redeemedByKeyId !== verifiedKeyId) {
|
|
3357
|
+
return { ok: false, status: 400, message: NOT_REDEEMABLE };
|
|
3358
|
+
}
|
|
3359
|
+
if (voucher.grantApplied === true) {
|
|
3360
|
+
const keyRow2 = await deps.db.outboundApiKeysGetByHash(keyHash);
|
|
3361
|
+
return { ok: true, result: currentResult(voucher.type, keyRow2) };
|
|
3362
|
+
}
|
|
3363
|
+
return await applyFirstPass(voucher, recordedAbsolute(voucher));
|
|
3364
|
+
}
|
|
3365
|
+
const keyRow = await deps.db.outboundApiKeysGetByHash(keyHash);
|
|
3366
|
+
const absolute = computeVoucherGrant(voucher, keyRow, now);
|
|
3367
|
+
const won = await voucherDb.voucherRedeemCas(voucher.id, verifiedKeyId, absolute, now);
|
|
3368
|
+
if (!won) {
|
|
3369
|
+
const after = await voucherDb.voucherGetByHash(codeHash);
|
|
3370
|
+
if (after?.status === "redeemed" && after.redeemedByKeyId === verifiedKeyId) {
|
|
3371
|
+
return after.grantApplied === true ? { ok: true, result: currentResult(after.type, await deps.db.outboundApiKeysGetByHash(keyHash)) } : await applyFirstPass(after, recordedAbsolute(after));
|
|
3372
|
+
}
|
|
3373
|
+
return { ok: false, status: 409, message: "Code was already redeemed" };
|
|
3374
|
+
}
|
|
3375
|
+
return await applyFirstPass(voucher, absolute);
|
|
3376
|
+
};
|
|
3377
|
+
const mutex = redeemMutex ?? getFallbackMutex();
|
|
3378
|
+
const outcome = await mutex.runExclusive(verifiedKeyId, runCritical);
|
|
3379
|
+
if (outcome.ok) writeResult(res, outcome.result);
|
|
3380
|
+
else writeErr(res, outcome.status, outcome.message);
|
|
1881
3381
|
}
|
|
1882
3382
|
|
|
1883
3383
|
// src/outbound-api/outboundApiRouter.ts
|
|
3384
|
+
var fallbackRedeemLimiter = null;
|
|
3385
|
+
function getFallbackRedeemLimiter() {
|
|
3386
|
+
fallbackRedeemLimiter ??= new OutboundRateLimiter({ maxRequests: 10, windowMs: 6e4 });
|
|
3387
|
+
return fallbackRedeemLimiter;
|
|
3388
|
+
}
|
|
1884
3389
|
function writeJsonError(res, status, message, headers = {}) {
|
|
1885
3390
|
if (res.headersSent) return;
|
|
1886
3391
|
res.writeHead(status, { "Content-Type": "application/json", ...headers });
|
|
1887
3392
|
res.end(JSON.stringify({ error: { type: "outbound_api_error", message } }));
|
|
1888
3393
|
}
|
|
3394
|
+
function writeCostLimitError(res, scope, limitUsd, spentUsd) {
|
|
3395
|
+
if (res.headersSent) return;
|
|
3396
|
+
res.writeHead(402, { "Content-Type": "application/json" });
|
|
3397
|
+
res.end(
|
|
3398
|
+
JSON.stringify({
|
|
3399
|
+
error: {
|
|
3400
|
+
type: "outbound_api_error",
|
|
3401
|
+
message: `Cost limit reached for this key (${scope}): $${spentUsd.toFixed(4)} of $${limitUsd.toFixed(4)}`
|
|
3402
|
+
},
|
|
3403
|
+
scope,
|
|
3404
|
+
limitUsd,
|
|
3405
|
+
spentUsd
|
|
3406
|
+
})
|
|
3407
|
+
);
|
|
3408
|
+
}
|
|
3409
|
+
function writeModelNotAllowedError(res, model, mode) {
|
|
3410
|
+
if (res.headersSent) return;
|
|
3411
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
3412
|
+
res.end(
|
|
3413
|
+
JSON.stringify({
|
|
3414
|
+
error: {
|
|
3415
|
+
type: "outbound_api_error",
|
|
3416
|
+
message: `Model '${model}' is not permitted for this key (${mode})`
|
|
3417
|
+
},
|
|
3418
|
+
model,
|
|
3419
|
+
mode
|
|
3420
|
+
})
|
|
3421
|
+
);
|
|
3422
|
+
}
|
|
1889
3423
|
function extractPresentedKey(req) {
|
|
1890
3424
|
const auth = req.headers["authorization"];
|
|
1891
3425
|
if (typeof auth === "string" && auth.trim()) {
|
|
@@ -1917,7 +3451,18 @@ function extractGeminiModelFromUrl(url) {
|
|
|
1917
3451
|
const m = /\/models\/([^/:]+):(?:stream)?[gG]enerateContent$/.exec(path.replace(/\/+$/, ""));
|
|
1918
3452
|
return m ? decodeURIComponent(m[1]) : void 0;
|
|
1919
3453
|
}
|
|
1920
|
-
function
|
|
3454
|
+
function isModelsListRequest(url) {
|
|
3455
|
+
if (!url) return false;
|
|
3456
|
+
const path = url.split("?")[0]?.replace(/\/+$/, "") ?? "";
|
|
3457
|
+
return path.endsWith("/models");
|
|
3458
|
+
}
|
|
3459
|
+
function writeChatModelsList(res, config) {
|
|
3460
|
+
const chat = config.endpoints.find((e) => e.endpoint === "chat");
|
|
3461
|
+
const data = (chat?.models ?? []).map((ref) => parseModelRef(ref)).filter((p) => p !== null).map((p) => ({ id: p.modelId, object: "model", owned_by: "omnicross" }));
|
|
3462
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3463
|
+
res.end(JSON.stringify({ object: "list", data }));
|
|
3464
|
+
}
|
|
3465
|
+
function readBody3(req) {
|
|
1921
3466
|
return new Promise((resolve, reject) => {
|
|
1922
3467
|
const chunks = [];
|
|
1923
3468
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
@@ -1930,20 +3475,48 @@ function shimAuthHeader(req, routeToken) {
|
|
|
1930
3475
|
delete req.headers["x-goog-api-key"];
|
|
1931
3476
|
delete req.headers["x-api-key"];
|
|
1932
3477
|
}
|
|
1933
|
-
async function handleOutboundRequest(req, res, deps, config, rateLimiter) {
|
|
3478
|
+
async function handleOutboundRequest(req, res, deps, config, rateLimiter, serialQueue, concurrencyGate, redeemLimiter, redeemMutex) {
|
|
3479
|
+
const now = Date.now();
|
|
3480
|
+
const audit = beginAuditCapture(req, res, now);
|
|
3481
|
+
const billing = beginBillingCapture(req, res, now);
|
|
1934
3482
|
const presented = extractPresentedKey(req);
|
|
1935
|
-
const
|
|
1936
|
-
if (
|
|
1937
|
-
writeJsonError(
|
|
3483
|
+
const verification = await verifyKey(deps.db, presented, now);
|
|
3484
|
+
if (verification.status !== "ok") {
|
|
3485
|
+
writeJsonError(
|
|
3486
|
+
res,
|
|
3487
|
+
401,
|
|
3488
|
+
verification.status === "expired" ? "API key has expired" : "Invalid or missing API key"
|
|
3489
|
+
);
|
|
3490
|
+
return;
|
|
3491
|
+
}
|
|
3492
|
+
const verified = verification.key;
|
|
3493
|
+
if (audit) audit.keyId = verified.id;
|
|
3494
|
+
if (billing) billing.keyId = verified.id;
|
|
3495
|
+
if (isRedeemRequest(req.method, req.url)) {
|
|
3496
|
+
await handleVoucherRedeem(
|
|
3497
|
+
req,
|
|
3498
|
+
res,
|
|
3499
|
+
deps,
|
|
3500
|
+
config.voucher?.enabled === true,
|
|
3501
|
+
redeemLimiter ?? getFallbackRedeemLimiter(),
|
|
3502
|
+
verified.id,
|
|
3503
|
+
presented ?? "",
|
|
3504
|
+
now,
|
|
3505
|
+
redeemMutex
|
|
3506
|
+
);
|
|
1938
3507
|
return;
|
|
1939
3508
|
}
|
|
1940
|
-
const decision = rateLimiter.check(verified.id);
|
|
3509
|
+
const decision = rateLimiter.check(verified.id, now, verified.rateLimit);
|
|
1941
3510
|
if (!decision.allowed) {
|
|
1942
3511
|
writeJsonError(res, 429, "Rate limit exceeded", {
|
|
1943
3512
|
"Retry-After": String(decision.retryAfterSeconds)
|
|
1944
3513
|
});
|
|
1945
3514
|
return;
|
|
1946
3515
|
}
|
|
3516
|
+
if (req.method === "GET" && isModelsListRequest(req.url)) {
|
|
3517
|
+
writeChatModelsList(res, config);
|
|
3518
|
+
return;
|
|
3519
|
+
}
|
|
1947
3520
|
const endpoint = selectEndpoint(req.method, req.url);
|
|
1948
3521
|
if (!endpoint) {
|
|
1949
3522
|
writeJsonError(res, 404, `Unsupported: ${req.method} ${req.url}`);
|
|
@@ -1954,53 +3527,165 @@ async function handleOutboundRequest(req, res, deps, config, rateLimiter) {
|
|
|
1954
3527
|
writeJsonError(res, 503, `endpoint '${endpoint}' is not configured`);
|
|
1955
3528
|
return;
|
|
1956
3529
|
}
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
3530
|
+
if (verified.costLimits && deps.keySpendTracker) {
|
|
3531
|
+
const spend = await deps.keySpendTracker.getSpend(verified.id, now);
|
|
3532
|
+
const quota = checkKeyQuota(verified.costLimits, spend);
|
|
3533
|
+
if (!quota.allowed) {
|
|
3534
|
+
emitWebhookEvent({
|
|
3535
|
+
kind: "key.quotaExceeded",
|
|
3536
|
+
at: now,
|
|
3537
|
+
keyId: verified.id,
|
|
3538
|
+
scope: quota.scope,
|
|
3539
|
+
limitUsd: quota.limitUsd,
|
|
3540
|
+
spentUsd: quota.spentUsd
|
|
3541
|
+
});
|
|
3542
|
+
writeCostLimitError(res, quota.scope, quota.limitUsd, quota.spentUsd);
|
|
3543
|
+
return;
|
|
3544
|
+
}
|
|
3545
|
+
for (const warn of computeQuotaWarnings(verified.costLimits, spend)) {
|
|
3546
|
+
if (markQuotaWarnedOnce(verified.id, warn.scope, warn.windowStart)) {
|
|
3547
|
+
emitWebhookEvent({
|
|
3548
|
+
kind: "key.quotaWarning",
|
|
3549
|
+
at: now,
|
|
3550
|
+
keyId: verified.id,
|
|
3551
|
+
scope: warn.scope,
|
|
3552
|
+
limitUsd: warn.limitUsd,
|
|
3553
|
+
spentUsd: warn.spentUsd
|
|
3554
|
+
});
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
1969
3557
|
}
|
|
1970
|
-
const
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
3558
|
+
const concurrencyLimit = verified.maxConcurrency;
|
|
3559
|
+
let releaseConcurrency = null;
|
|
3560
|
+
if (typeof concurrencyLimit === "number" && concurrencyLimit > 0) {
|
|
3561
|
+
const cq = config.concurrencyQueue ?? DEFAULT_CONCURRENCY_QUEUE;
|
|
3562
|
+
const acquisition = concurrencyGate.acquire(verified.id, concurrencyLimit, {
|
|
3563
|
+
maxQueueSizeFactor: cq.maxQueueSizeFactor,
|
|
3564
|
+
minQueueSize: cq.minQueueSize,
|
|
3565
|
+
waitTimeoutMs: cq.waitTimeoutMs
|
|
3566
|
+
});
|
|
3567
|
+
const cancelOnClose = () => acquisition.cancel();
|
|
3568
|
+
res.once("close", cancelOnClose);
|
|
3569
|
+
let slot;
|
|
3570
|
+
try {
|
|
3571
|
+
slot = await acquisition.granted;
|
|
3572
|
+
} catch (err) {
|
|
3573
|
+
res.removeListener("close", cancelOnClose);
|
|
3574
|
+
if (isConcurrencyRejection(err)) {
|
|
3575
|
+
writeJsonError(res, 429, "Concurrency limit exceeded", { "Retry-After": "5" });
|
|
3576
|
+
return;
|
|
3577
|
+
}
|
|
3578
|
+
throw err;
|
|
3579
|
+
}
|
|
3580
|
+
res.removeListener("close", cancelOnClose);
|
|
3581
|
+
const release2 = () => slot.release();
|
|
3582
|
+
releaseConcurrency = release2;
|
|
3583
|
+
res.once("close", release2);
|
|
1991
3584
|
}
|
|
1992
|
-
const routeMap = deps.providerProxy.getRouteMap();
|
|
1993
|
-
const token = routeMap.addRoute(resolved.route);
|
|
1994
3585
|
try {
|
|
1995
|
-
|
|
1996
|
-
const
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
3586
|
+
const ingressFormat = endpointToIngressFormat(endpoint);
|
|
3587
|
+
const rawBody = await readBody3(req);
|
|
3588
|
+
let parsedBody = {};
|
|
3589
|
+
try {
|
|
3590
|
+
parsedBody = rawBody ? JSON.parse(rawBody) : {};
|
|
3591
|
+
} catch {
|
|
3592
|
+
writeJsonError(res, 400, "Invalid JSON in request body");
|
|
3593
|
+
return;
|
|
3594
|
+
}
|
|
3595
|
+
if (audit) audit.setRequestBody(rawBody);
|
|
3596
|
+
if (endpoint === "gemini" && typeof parsedBody["model"] !== "string") {
|
|
3597
|
+
const urlModel = extractGeminiModelFromUrl(req.url);
|
|
3598
|
+
if (urlModel) parsedBody["model"] = urlModel;
|
|
3599
|
+
}
|
|
3600
|
+
const sessionId = deps.proxyDeps.apiKeyPool ? `outbound:${verified.id}` : null;
|
|
3601
|
+
const resolved = isKindMappedEndpoint(endpoint) || endpoint === "chat" ? await resolveRoute({
|
|
3602
|
+
config: endpointConfig,
|
|
3603
|
+
ingressFormat,
|
|
3604
|
+
llmConfig: deps.llmConfig,
|
|
3605
|
+
sessionId,
|
|
3606
|
+
// Capture the ORIGINAL requested id BEFORE any downstream swap; for
|
|
3607
|
+
// kind-mapped endpoints it selects the kind AND is stamped onto
|
|
3608
|
+
// `route.requestedModel`; for chat it is matched against the list.
|
|
3609
|
+
requestedModel: extractRequestedModel(ingressFormat, parsedBody),
|
|
3610
|
+
// Attribution: stamp the verified named-key id onto the route.
|
|
3611
|
+
apiKeyId: verified.id
|
|
3612
|
+
}) : await resolveRoute({
|
|
3613
|
+
config: endpointConfig,
|
|
3614
|
+
role: detectRequestRole(ingressFormat, parsedBody, {
|
|
3615
|
+
backgroundModelIds: endpointConfig.backgroundModelIds
|
|
3616
|
+
}),
|
|
3617
|
+
ingressFormat,
|
|
3618
|
+
llmConfig: deps.llmConfig,
|
|
3619
|
+
sessionId,
|
|
3620
|
+
// Attribution: stamp the verified named-key id onto the route.
|
|
3621
|
+
apiKeyId: verified.id
|
|
3622
|
+
});
|
|
3623
|
+
if (!resolved.ok) {
|
|
3624
|
+
writeJsonError(res, resolved.error.status, resolved.error.message);
|
|
3625
|
+
return;
|
|
3626
|
+
}
|
|
3627
|
+
if (audit) {
|
|
3628
|
+
audit.model = resolved.route.model;
|
|
3629
|
+
audit.provider = resolved.route.providerId;
|
|
3630
|
+
}
|
|
3631
|
+
if (billing) {
|
|
3632
|
+
billing.model = resolved.route.model;
|
|
3633
|
+
billing.provider = resolved.route.providerId;
|
|
3634
|
+
billing.authMode = resolved.route.authMode;
|
|
3635
|
+
}
|
|
3636
|
+
if (verified.modelRestriction) {
|
|
3637
|
+
const modelDecision = checkModelAllowed(verified.modelRestriction, resolved.route.model);
|
|
3638
|
+
if (!modelDecision.allowed) {
|
|
3639
|
+
writeModelNotAllowedError(res, modelDecision.model, verified.modelRestriction.mode);
|
|
3640
|
+
return;
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
let releaseSerial = null;
|
|
3644
|
+
const providerKey = resolved.route.providerId;
|
|
3645
|
+
if (config.userMessageQueue?.enabled && providerKey && isUserMessageRequest(endpoint, parsedBody)) {
|
|
3646
|
+
const umq = config.userMessageQueue;
|
|
3647
|
+
try {
|
|
3648
|
+
const serialSlot = await serialQueue.acquire(providerKey, {
|
|
3649
|
+
waitTimeoutMs: umq.waitTimeoutMs,
|
|
3650
|
+
delayMs: umq.delayMs
|
|
3651
|
+
});
|
|
3652
|
+
releaseSerial = () => serialSlot.release();
|
|
3653
|
+
} catch (err) {
|
|
3654
|
+
if (isSerialQueueTimeout(err)) {
|
|
3655
|
+
writeJsonError(res, 503, "User-message serial queue wait timed out");
|
|
3656
|
+
return;
|
|
3657
|
+
}
|
|
3658
|
+
throw err;
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3661
|
+
if (releaseSerial) {
|
|
3662
|
+
const original = res.writeHead.bind(res);
|
|
3663
|
+
const releaseOnce = releaseSerial;
|
|
3664
|
+
res.writeHead = ((...args) => {
|
|
3665
|
+
res.writeHead = original;
|
|
3666
|
+
releaseOnce();
|
|
3667
|
+
return original(...args);
|
|
3668
|
+
});
|
|
3669
|
+
}
|
|
3670
|
+
const routeMap = deps.providerProxy.getRouteMap();
|
|
3671
|
+
const token = routeMap.addRoute(resolved.route);
|
|
3672
|
+
try {
|
|
3673
|
+
shimAuthHeader(req, token);
|
|
3674
|
+
const replay = makeReplayRequest(req, rawBody);
|
|
3675
|
+
await routeRequest(replay, res, routeMap, deps.proxyDeps);
|
|
3676
|
+
} catch (err) {
|
|
3677
|
+
const message = serializeError(err);
|
|
3678
|
+
if (deps.logger) deps.logger.error("[OutboundApi] dispatch error:", message);
|
|
3679
|
+
else console.error("[OutboundApi] dispatch error:", message);
|
|
3680
|
+
emitWebhookEvent({ kind: "server.error", at: Date.now(), message });
|
|
3681
|
+
if (audit) audit.error = message;
|
|
3682
|
+
writeJsonError(res, 502, message);
|
|
3683
|
+
} finally {
|
|
3684
|
+
routeMap.removeRoute(token);
|
|
3685
|
+
if (releaseSerial) releaseSerial();
|
|
3686
|
+
}
|
|
2002
3687
|
} finally {
|
|
2003
|
-
|
|
3688
|
+
if (releaseConcurrency) releaseConcurrency();
|
|
2004
3689
|
}
|
|
2005
3690
|
}
|
|
2006
3691
|
function makeReplayRequest(req, rawBody) {
|
|
@@ -2014,53 +3699,20 @@ function makeReplayRequest(req, rawBody) {
|
|
|
2014
3699
|
return readable;
|
|
2015
3700
|
}
|
|
2016
3701
|
|
|
2017
|
-
// src/outbound-api/outboundRateLimiter.ts
|
|
2018
|
-
var DEFAULT_WINDOW_MS = 6e4;
|
|
2019
|
-
var DEFAULT_MAX_REQUESTS = 60;
|
|
2020
|
-
var OutboundRateLimiter = class {
|
|
2021
|
-
windowMs;
|
|
2022
|
-
maxRequests;
|
|
2023
|
-
/** apiKeyId → ascending request timestamps within the current window. */
|
|
2024
|
-
hits = /* @__PURE__ */ new Map();
|
|
2025
|
-
constructor(options = {}) {
|
|
2026
|
-
this.windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
|
|
2027
|
-
this.maxRequests = options.maxRequests ?? DEFAULT_MAX_REQUESTS;
|
|
2028
|
-
}
|
|
2029
|
-
/**
|
|
2030
|
-
* Record a request for `apiKeyId` and decide whether it is allowed. Prunes
|
|
2031
|
-
* timestamps older than the window first; when allowed, the request's
|
|
2032
|
-
* timestamp is appended.
|
|
2033
|
-
*/
|
|
2034
|
-
check(apiKeyId, now = Date.now()) {
|
|
2035
|
-
const cutoff = now - this.windowMs;
|
|
2036
|
-
const timestamps = (this.hits.get(apiKeyId) ?? []).filter((ts) => ts > cutoff);
|
|
2037
|
-
if (timestamps.length >= this.maxRequests) {
|
|
2038
|
-
if (timestamps.length === 0) {
|
|
2039
|
-
this.hits.delete(apiKeyId);
|
|
2040
|
-
return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil(this.windowMs / 1e3)) };
|
|
2041
|
-
}
|
|
2042
|
-
const oldest = timestamps[0];
|
|
2043
|
-
const retryAfterMs = Math.max(0, oldest + this.windowMs - now);
|
|
2044
|
-
this.hits.set(apiKeyId, timestamps);
|
|
2045
|
-
return {
|
|
2046
|
-
allowed: false,
|
|
2047
|
-
retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1e3))
|
|
2048
|
-
};
|
|
2049
|
-
}
|
|
2050
|
-
timestamps.push(now);
|
|
2051
|
-
this.hits.set(apiKeyId, timestamps);
|
|
2052
|
-
return { allowed: true, retryAfterSeconds: 0 };
|
|
2053
|
-
}
|
|
2054
|
-
/** Drop all recorded state (tests / teardown). */
|
|
2055
|
-
reset() {
|
|
2056
|
-
this.hits.clear();
|
|
2057
|
-
}
|
|
2058
|
-
};
|
|
2059
|
-
|
|
2060
3702
|
// src/outbound-api/OutboundApiServer.ts
|
|
2061
3703
|
var DEFAULT_OUTBOUND_PORT = 8765;
|
|
2062
3704
|
var LOOPBACK_ADDR = "127.0.0.1";
|
|
2063
3705
|
var LAN_ADDR = "0.0.0.0";
|
|
3706
|
+
var OutboundApiConfigError = class extends Error {
|
|
3707
|
+
missing;
|
|
3708
|
+
constructor(missing) {
|
|
3709
|
+
super(
|
|
3710
|
+
"Outbound API server cannot start: incomplete model-kind configuration \u2014 " + missing.map((m) => `${m.endpoint} missing [${m.missingKinds.join(", ")}]`).join("; ")
|
|
3711
|
+
);
|
|
3712
|
+
this.name = "OutboundApiConfigError";
|
|
3713
|
+
this.missing = missing;
|
|
3714
|
+
}
|
|
3715
|
+
};
|
|
2064
3716
|
var OutboundApiServer = class {
|
|
2065
3717
|
constructor(deps, onPortChange) {
|
|
2066
3718
|
this.deps = deps;
|
|
@@ -2072,7 +3724,25 @@ var OutboundApiServer = class {
|
|
|
2072
3724
|
boundPort = 0;
|
|
2073
3725
|
boundAddr = LOOPBACK_ADDR;
|
|
2074
3726
|
endpoints = [];
|
|
3727
|
+
userMessageQueue;
|
|
3728
|
+
concurrencyQueue;
|
|
3729
|
+
voucherConfig;
|
|
2075
3730
|
rateLimiter = new OutboundRateLimiter();
|
|
3731
|
+
/**
|
|
3732
|
+
* Redeem-attempt limiter (voucher-redemption #9, design D6) — a SEPARATE bucket
|
|
3733
|
+
* from the traffic `rateLimiter`, keyed by the authenticating key id, so
|
|
3734
|
+
* brute-forcing `CC_` codes is throttled (a handful/min) without touching the
|
|
3735
|
+
* per-key request rate. Conservative fixed defaults (10 / 60s).
|
|
3736
|
+
*/
|
|
3737
|
+
redeemLimiter = new OutboundRateLimiter({ maxRequests: 10, windowMs: 6e4 });
|
|
3738
|
+
/**
|
|
3739
|
+
* Per-key redeem mutex (voucher-redemption #9, MJ1 fix). One instance for the
|
|
3740
|
+
* server's lifetime so concurrent redeem REQUESTS for the same key serialize
|
|
3741
|
+
* (relative grant increments accumulate instead of clobbering a snapshot).
|
|
3742
|
+
*/
|
|
3743
|
+
redeemMutex = new KeyedMutex();
|
|
3744
|
+
serialQueue = new UserMessageSerialQueue();
|
|
3745
|
+
concurrencyGate = new OutboundConcurrencyGate();
|
|
2076
3746
|
/**
|
|
2077
3747
|
* Apply a config. Restarts the listener ONLY when the bind address or port
|
|
2078
3748
|
* changes (or when toggling enabled); per-endpoint routing config is updated
|
|
@@ -2080,12 +3750,25 @@ var OutboundApiServer = class {
|
|
|
2080
3750
|
*/
|
|
2081
3751
|
async applyConfig(input) {
|
|
2082
3752
|
this.endpoints = input.endpoints;
|
|
3753
|
+
this.userMessageQueue = input.userMessageQueue;
|
|
3754
|
+
this.concurrencyQueue = input.concurrencyQueue;
|
|
3755
|
+
this.voucherConfig = input.voucher;
|
|
2083
3756
|
const wantAddr = input.networkBinding ? LAN_ADDR : LOOPBACK_ADDR;
|
|
2084
3757
|
const wantPort = input.port ?? DEFAULT_OUTBOUND_PORT;
|
|
2085
3758
|
if (!input.enabled) {
|
|
2086
3759
|
await this.stop();
|
|
2087
3760
|
return;
|
|
2088
3761
|
}
|
|
3762
|
+
const missing = validateServerModelConfig({
|
|
3763
|
+
enabled: true,
|
|
3764
|
+
networkBinding: input.networkBinding,
|
|
3765
|
+
endpoints: input.endpoints,
|
|
3766
|
+
port: input.port
|
|
3767
|
+
});
|
|
3768
|
+
if (missing.length > 0) {
|
|
3769
|
+
await this.stop();
|
|
3770
|
+
throw new OutboundApiConfigError(missing);
|
|
3771
|
+
}
|
|
2089
3772
|
const running = this.server !== null;
|
|
2090
3773
|
const bindChanged = running && (this.boundAddr !== wantAddr || this.boundPort !== wantPort);
|
|
2091
3774
|
if (running && !bindChanged) {
|
|
@@ -2101,7 +3784,7 @@ var OutboundApiServer = class {
|
|
|
2101
3784
|
this.boundAddr = bindAddr;
|
|
2102
3785
|
this.boundPort = actualPort;
|
|
2103
3786
|
if (actualPort !== port) this.onPortChange?.(actualPort);
|
|
2104
|
-
|
|
3787
|
+
this.logInfo(`[OutboundApiServer] Listening on ${bindAddr}:${actualPort}`);
|
|
2105
3788
|
return actualPort;
|
|
2106
3789
|
}
|
|
2107
3790
|
/** Bind once; on EADDRINUSE retry with an ephemeral port (port 0). */
|
|
@@ -2123,7 +3806,7 @@ var OutboundApiServer = class {
|
|
|
2123
3806
|
const addr = server.address();
|
|
2124
3807
|
if (addr && typeof addr === "object") {
|
|
2125
3808
|
server.removeListener("error", onError);
|
|
2126
|
-
server.on("error", (e) =>
|
|
3809
|
+
server.on("error", (e) => this.logError("[OutboundApiServer] server error", serializeError(e)));
|
|
2127
3810
|
this.server = server;
|
|
2128
3811
|
resolve(addr.port);
|
|
2129
3812
|
} else {
|
|
@@ -2134,21 +3817,58 @@ var OutboundApiServer = class {
|
|
|
2134
3817
|
}
|
|
2135
3818
|
/** Per-request handler. Auth is enforced on EVERY request (incl. loopback). */
|
|
2136
3819
|
onRequest(req, res) {
|
|
3820
|
+
if (this.deps.healthReportProvider && this.tryServeHealth(req, res)) return;
|
|
2137
3821
|
handleOutboundRequest(
|
|
2138
3822
|
req,
|
|
2139
3823
|
res,
|
|
2140
3824
|
this.deps,
|
|
2141
|
-
{
|
|
2142
|
-
|
|
3825
|
+
{
|
|
3826
|
+
endpoints: this.endpoints,
|
|
3827
|
+
userMessageQueue: this.userMessageQueue,
|
|
3828
|
+
concurrencyQueue: this.concurrencyQueue,
|
|
3829
|
+
voucher: this.voucherConfig
|
|
3830
|
+
},
|
|
3831
|
+
this.rateLimiter,
|
|
3832
|
+
this.serialQueue,
|
|
3833
|
+
this.concurrencyGate,
|
|
3834
|
+
this.redeemLimiter,
|
|
3835
|
+
this.redeemMutex
|
|
2143
3836
|
).catch((err) => {
|
|
2144
3837
|
const message = serializeError(err);
|
|
2145
|
-
|
|
3838
|
+
this.logError("[OutboundApiServer] unhandled error:", message);
|
|
2146
3839
|
if (!res.headersSent) {
|
|
2147
3840
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2148
3841
|
res.end(JSON.stringify({ error: { type: "outbound_api_error", message } }));
|
|
2149
3842
|
}
|
|
2150
3843
|
});
|
|
2151
3844
|
}
|
|
3845
|
+
/**
|
|
3846
|
+
* Serve `GET|HEAD /health` (+ `/healthz`) from the injected provider, returning
|
|
3847
|
+
* true when it handled the request. 200 when `ok`, else 503; secret-free body.
|
|
3848
|
+
*/
|
|
3849
|
+
tryServeHealth(req, res) {
|
|
3850
|
+
const provider = this.deps.healthReportProvider;
|
|
3851
|
+
if (!provider) return false;
|
|
3852
|
+
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
|
3853
|
+
const path = (req.url ?? "/").split("?")[0]?.replace(/\/+$/, "") || "/";
|
|
3854
|
+
if (path !== "/health" && path !== "/healthz") return false;
|
|
3855
|
+
const report = provider();
|
|
3856
|
+
res.writeHead(healthHttpStatus(report.status), { "Content-Type": "application/json" });
|
|
3857
|
+
res.end(req.method === "HEAD" ? void 0 : JSON.stringify(report));
|
|
3858
|
+
return true;
|
|
3859
|
+
}
|
|
3860
|
+
/** Route an info lifecycle line through the injected logger, else `console.log`
|
|
3861
|
+
* (byte-identical legacy fallback when no logger is wired). */
|
|
3862
|
+
logInfo(message) {
|
|
3863
|
+
if (this.deps.logger) this.deps.logger.info(message);
|
|
3864
|
+
else console.log(message);
|
|
3865
|
+
}
|
|
3866
|
+
/** Route an error lifecycle line through the injected logger, else `console.error`. */
|
|
3867
|
+
logError(message, detail) {
|
|
3868
|
+
if (this.deps.logger) this.deps.logger.error(message, detail);
|
|
3869
|
+
else if (detail === void 0) console.error(message);
|
|
3870
|
+
else console.error(message, detail);
|
|
3871
|
+
}
|
|
2152
3872
|
/** Stop the listener and release the port. */
|
|
2153
3873
|
async stop() {
|
|
2154
3874
|
const server = this.server;
|
|
@@ -2157,7 +3877,7 @@ var OutboundApiServer = class {
|
|
|
2157
3877
|
this.boundPort = 0;
|
|
2158
3878
|
return new Promise((resolve) => {
|
|
2159
3879
|
server.close(() => {
|
|
2160
|
-
|
|
3880
|
+
this.logInfo("[OutboundApiServer] Stopped");
|
|
2161
3881
|
resolve();
|
|
2162
3882
|
});
|
|
2163
3883
|
});
|
|
@@ -2189,6 +3909,17 @@ var OutboundApiServer = class {
|
|
|
2189
3909
|
lanFormats: lanBase ? formatUrls(lanBase) : null
|
|
2190
3910
|
};
|
|
2191
3911
|
}
|
|
3912
|
+
/**
|
|
3913
|
+
* Live queue-occupancy snapshot (only active entries). This getter's name +
|
|
3914
|
+
* shape are FROZEN — `omnicross-uqc-daemon` spreads it into its `/status`
|
|
3915
|
+
* response; the existing {@link getStatus} shape is deliberately NOT changed.
|
|
3916
|
+
*/
|
|
3917
|
+
getQueueStatus() {
|
|
3918
|
+
return {
|
|
3919
|
+
serial: this.serialQueue.getStatus(),
|
|
3920
|
+
concurrency: this.concurrencyGate.getStatus()
|
|
3921
|
+
};
|
|
3922
|
+
}
|
|
2192
3923
|
};
|
|
2193
3924
|
function formatUrls(base) {
|
|
2194
3925
|
return {
|
|
@@ -2209,66 +3940,88 @@ function firstLanIPv4() {
|
|
|
2209
3940
|
return null;
|
|
2210
3941
|
}
|
|
2211
3942
|
|
|
2212
|
-
// src/outbound-api/
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
}
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
if (
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
3943
|
+
// src/outbound-api/keySpendTracker.ts
|
|
3944
|
+
function startOfLocalDay(ts) {
|
|
3945
|
+
const d = new Date(ts);
|
|
3946
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
3947
|
+
}
|
|
3948
|
+
function startOfLocalWeek(ts) {
|
|
3949
|
+
const d = new Date(ts);
|
|
3950
|
+
const day = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
3951
|
+
day.setDate(day.getDate() - day.getDay());
|
|
3952
|
+
return day.getTime();
|
|
3953
|
+
}
|
|
3954
|
+
var KeySpendTracker = class {
|
|
3955
|
+
constructor(seeder) {
|
|
3956
|
+
this.seeder = seeder;
|
|
3957
|
+
}
|
|
3958
|
+
seeder;
|
|
3959
|
+
/** apiKeyId → live accumulated spend (seeded lazily). */
|
|
3960
|
+
spend = /* @__PURE__ */ new Map();
|
|
3961
|
+
/**
|
|
3962
|
+
* Read the key's accumulated spend, seeding from the durable store on a cache
|
|
3963
|
+
* miss and rolling the daily/weekly windows on a hit.
|
|
3964
|
+
*/
|
|
3965
|
+
async getSpend(apiKeyId, now) {
|
|
3966
|
+
const existing = this.spend.get(apiKeyId);
|
|
3967
|
+
if (existing) {
|
|
3968
|
+
rollWindows(existing, now);
|
|
3969
|
+
return existing;
|
|
3970
|
+
}
|
|
3971
|
+
const seeded = await this.seed(apiKeyId, now);
|
|
3972
|
+
const existingAfterSeed = this.spend.get(apiKeyId);
|
|
3973
|
+
if (existingAfterSeed) {
|
|
3974
|
+
rollWindows(existingAfterSeed, now);
|
|
3975
|
+
return existingAfterSeed;
|
|
2245
3976
|
}
|
|
3977
|
+
this.spend.set(apiKeyId, seeded);
|
|
3978
|
+
return seeded;
|
|
3979
|
+
}
|
|
3980
|
+
/**
|
|
3981
|
+
* Increment the aggregate for one recorded request. No-op when the key has not
|
|
3982
|
+
* been seeded yet — a later `getSpend` will lazy-seed from the durable store,
|
|
3983
|
+
* which already includes this record, so it is neither lost nor double-counted.
|
|
3984
|
+
*/
|
|
3985
|
+
add(apiKeyId, costUsd, now) {
|
|
3986
|
+
const s = this.spend.get(apiKeyId);
|
|
3987
|
+
if (!s) return;
|
|
3988
|
+
rollWindows(s, now);
|
|
3989
|
+
s.dailyUsd += costUsd;
|
|
3990
|
+
s.weeklyUsd += costUsd;
|
|
3991
|
+
s.totalUsd += costUsd;
|
|
3992
|
+
}
|
|
3993
|
+
/** Drop all cached spend (tests / teardown). */
|
|
3994
|
+
reset() {
|
|
3995
|
+
this.spend.clear();
|
|
3996
|
+
}
|
|
3997
|
+
/**
|
|
3998
|
+
* Derive a fresh spend snapshot for `apiKeyId` from the durable store in a
|
|
3999
|
+
* SINGLE scan (all three windows at once). `endTs` is EXCLUSIVE, so `now + 1`
|
|
4000
|
+
* includes a record stamped at exactly `now`.
|
|
4001
|
+
*/
|
|
4002
|
+
async seed(apiKeyId, now) {
|
|
4003
|
+
const dailyWindowStart = startOfLocalDay(now);
|
|
4004
|
+
const weeklyWindowStart = startOfLocalWeek(now);
|
|
4005
|
+
const { totalUsd, dailyUsd, weeklyUsd } = await this.seeder.getSpendByKey({
|
|
4006
|
+
apiKeyId,
|
|
4007
|
+
dayStartTs: dailyWindowStart,
|
|
4008
|
+
weekStartTs: weeklyWindowStart,
|
|
4009
|
+
endTs: now + 1
|
|
4010
|
+
});
|
|
4011
|
+
return { dailyUsd, dailyWindowStart, weeklyUsd, weeklyWindowStart, totalUsd };
|
|
4012
|
+
}
|
|
4013
|
+
};
|
|
4014
|
+
function rollWindows(s, now) {
|
|
4015
|
+
const day = startOfLocalDay(now);
|
|
4016
|
+
if (s.dailyWindowStart !== day) {
|
|
4017
|
+
s.dailyUsd = 0;
|
|
4018
|
+
s.dailyWindowStart = day;
|
|
4019
|
+
}
|
|
4020
|
+
const week = startOfLocalWeek(now);
|
|
4021
|
+
if (s.weeklyWindowStart !== week) {
|
|
4022
|
+
s.weeklyUsd = 0;
|
|
4023
|
+
s.weeklyWindowStart = week;
|
|
2246
4024
|
}
|
|
2247
|
-
return {
|
|
2248
|
-
enabled: raw.enabled === true,
|
|
2249
|
-
networkBinding: raw.networkBinding === true,
|
|
2250
|
-
endpoints: ALL_ENDPOINTS.map(
|
|
2251
|
-
(ep) => byEndpoint.get(ep) ?? defaultEndpointConfig(ep)
|
|
2252
|
-
),
|
|
2253
|
-
port: raw.port ?? base.port
|
|
2254
|
-
};
|
|
2255
|
-
}
|
|
2256
|
-
async function loadServerConfig(store) {
|
|
2257
|
-
const raw = await store.get(
|
|
2258
|
-
OUTBOUND_API_SERVER_CONFIG_KEY
|
|
2259
|
-
);
|
|
2260
|
-
return normalizeServerConfig(raw);
|
|
2261
|
-
}
|
|
2262
|
-
async function saveServerConfig(store, config) {
|
|
2263
|
-
await store.set(OUTBOUND_API_SERVER_CONFIG_KEY, config);
|
|
2264
|
-
}
|
|
2265
|
-
function mergeServerConfig(current, patch) {
|
|
2266
|
-
return normalizeServerConfig({
|
|
2267
|
-
enabled: patch.enabled ?? current.enabled,
|
|
2268
|
-
networkBinding: patch.networkBinding ?? current.networkBinding,
|
|
2269
|
-
endpoints: patch.endpoints ?? current.endpoints,
|
|
2270
|
-
port: patch.port ?? current.port
|
|
2271
|
-
});
|
|
2272
4025
|
}
|
|
2273
4026
|
|
|
2274
4027
|
// src/outbound-api/index.ts
|
|
@@ -3893,8 +5646,8 @@ var CompletionService = class {
|
|
|
3893
5646
|
* emission is unconditional in production. Optional — when unset (unit-test
|
|
3894
5647
|
* constructors), the emit calls no-op, identical to `usageRecorder`.
|
|
3895
5648
|
*/
|
|
3896
|
-
setUsageEventSink(
|
|
3897
|
-
this.usageEventSink =
|
|
5649
|
+
setUsageEventSink(sink2) {
|
|
5650
|
+
this.usageEventSink = sink2;
|
|
3898
5651
|
}
|
|
3899
5652
|
/**
|
|
3900
5653
|
* Set the vision-fallback provider used by `applyVisionFallback` to describe
|
|
@@ -4380,21 +6133,64 @@ export {
|
|
|
4380
6133
|
ProviderProxy,
|
|
4381
6134
|
getProviderProxy,
|
|
4382
6135
|
__resetProviderProxyForTests,
|
|
6136
|
+
KeyedMutex,
|
|
6137
|
+
OUTBOUND_API_SERVER_CONFIG_KEY,
|
|
6138
|
+
DEFAULT_USER_MESSAGE_QUEUE,
|
|
6139
|
+
DEFAULT_CONCURRENCY_QUEUE,
|
|
6140
|
+
DEFAULT_ACCOUNT_PROBE,
|
|
6141
|
+
normalizeAccountProbe,
|
|
6142
|
+
normalizeAudit,
|
|
6143
|
+
normalizeBilling,
|
|
6144
|
+
DEFAULT_FINGERPRINT,
|
|
6145
|
+
normalizeFingerprint,
|
|
6146
|
+
normalizeVoucher,
|
|
6147
|
+
normalizeProxyConfig,
|
|
6148
|
+
normalizeProxySegment,
|
|
6149
|
+
normalizeWebhookDestination,
|
|
6150
|
+
normalizeWebhookSegment,
|
|
6151
|
+
normalizeQueueSegments,
|
|
6152
|
+
normalizePrefixTargets,
|
|
6153
|
+
defaultServerConfig,
|
|
6154
|
+
normalizeServerConfig,
|
|
6155
|
+
loadServerConfig,
|
|
6156
|
+
saveServerConfig,
|
|
6157
|
+
mergeServerConfig,
|
|
6158
|
+
AUDIT_REDACTED,
|
|
6159
|
+
redactAuditText,
|
|
6160
|
+
beginAuditCapture,
|
|
6161
|
+
beginBillingCapture,
|
|
6162
|
+
computeKeyExpiry,
|
|
6163
|
+
checkKeyQuota,
|
|
6164
|
+
randomBase62,
|
|
4383
6165
|
hashKey,
|
|
4384
6166
|
createNamedKey,
|
|
6167
|
+
verifyKey,
|
|
4385
6168
|
verifyPresentedKey,
|
|
4386
|
-
|
|
4387
|
-
|
|
6169
|
+
ConcurrencyQueueFullError,
|
|
6170
|
+
ConcurrencyWaitTimeoutError,
|
|
6171
|
+
ConcurrencyWaitCancelledError,
|
|
6172
|
+
isConcurrencyRejection,
|
|
6173
|
+
OutboundConcurrencyGate,
|
|
4388
6174
|
OutboundRateLimiter,
|
|
6175
|
+
isUserMessageRequest,
|
|
6176
|
+
SerialQueueTimeoutError,
|
|
6177
|
+
isSerialQueueTimeout,
|
|
6178
|
+
UserMessageSerialQueue,
|
|
6179
|
+
generateVoucherCode,
|
|
6180
|
+
hashVoucherCode,
|
|
6181
|
+
voucherCodePrefix,
|
|
6182
|
+
newVoucherId,
|
|
6183
|
+
computeVoucherGrant,
|
|
6184
|
+
toVoucherInfo,
|
|
6185
|
+
isRedeemRequest,
|
|
6186
|
+
handleVoucherRedeem,
|
|
4389
6187
|
DEFAULT_OUTBOUND_PORT,
|
|
6188
|
+
OutboundApiConfigError,
|
|
4390
6189
|
OutboundApiServer,
|
|
4391
6190
|
formatUrls,
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
loadServerConfig,
|
|
4396
|
-
saveServerConfig,
|
|
4397
|
-
mergeServerConfig,
|
|
6191
|
+
startOfLocalDay,
|
|
6192
|
+
startOfLocalWeek,
|
|
6193
|
+
KeySpendTracker,
|
|
4398
6194
|
getOutboundApiServer,
|
|
4399
6195
|
__resetOutboundApiServerForTests
|
|
4400
6196
|
};
|