@zhivex-ai/gateway 1.3.0 → 1.4.0
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/README.md +209 -4
- package/dist/adaptive-routing.d.ts +12 -0
- package/dist/adaptive-routing.d.ts.map +1 -1
- package/dist/adaptive-routing.js +45 -8
- package/dist/adaptive-routing.js.map +1 -1
- package/dist/admission.d.ts +26 -0
- package/dist/admission.d.ts.map +1 -0
- package/dist/admission.js +88 -0
- package/dist/admission.js.map +1 -0
- package/dist/budget.d.ts +33 -0
- package/dist/budget.d.ts.map +1 -0
- package/dist/budget.js +53 -0
- package/dist/budget.js.map +1 -0
- package/dist/circuit-breaker.d.ts.map +1 -1
- package/dist/circuit-breaker.js +2 -1
- package/dist/circuit-breaker.js.map +1 -1
- package/dist/execution.d.ts +17 -0
- package/dist/execution.d.ts.map +1 -0
- package/dist/execution.js +223 -0
- package/dist/execution.js.map +1 -0
- package/dist/index.d.ts +21 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +159 -40
- package/dist/index.js.map +1 -1
- package/dist/metrics.d.ts +3 -2
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +14 -8
- package/dist/metrics.js.map +1 -1
- package/dist/operation.d.ts +11 -0
- package/dist/operation.d.ts.map +1 -0
- package/dist/operation.js +29 -0
- package/dist/operation.js.map +1 -0
- package/dist/target.d.ts +4 -0
- package/dist/target.d.ts.map +1 -0
- package/dist/target.js +3 -0
- package/dist/target.js.map +1 -0
- package/dist/types.d.ts +42 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import { targetKey, sameTarget } from "./target.js";
|
|
2
|
+
import { operationControl, GatewayDeadlineError } from "./operation.js";
|
|
3
|
+
import { createGatewayExecutor, cachedResults } from "./execution.js";
|
|
4
|
+
import { GatewayBudgetError } from "./budget.js";
|
|
5
|
+
import { GatewayAdmissionError } from "./admission.js";
|
|
6
|
+
export { GatewayDeadlineError } from "./operation.js";
|
|
7
|
+
export { createGatewayAdmissionController, GatewayAdmissionError } from "./admission.js";
|
|
8
|
+
export { createGatewayBudgetStore, GatewayBudgetError } from "./budget.js";
|
|
9
|
+
export { createGatewayRoutingPolicy } from "./adaptive-routing.js";
|
|
1
10
|
import { ConflictError, GuardrailTriggeredError, ProviderHTTPError, ValidationError, createAgent, calculateModelCost, createStructuredOutputPrompt, createTextMessage, generateObject, generateText, runAgent, streamAgent, streamObject, streamText } from "@zhivex-ai/core";
|
|
2
11
|
import { GatewayCircuitOpenError } from "./circuit-breaker.js";
|
|
3
12
|
import { scoreAdaptiveTarget, validateAdaptivePolicy } from "./adaptive-routing.js";
|
|
@@ -63,7 +72,7 @@ const scoreTarget = (mode, intent, target, primary, config) => {
|
|
|
63
72
|
mode,
|
|
64
73
|
intent,
|
|
65
74
|
target,
|
|
66
|
-
isPrimary: target
|
|
75
|
+
isPrimary: sameTarget(target, primary),
|
|
67
76
|
configuredCostPer1kTokens: config.providerCostsPer1kTokens?.[target.provider],
|
|
68
77
|
catalogCostPer1kTokens: config.modelCatalog?.find(target.provider, target.modelId)?.costPer1kTokens,
|
|
69
78
|
latencyBiasMs: config.latencyBiasMs?.[target.provider]
|
|
@@ -111,11 +120,21 @@ const validateTarget = (target, label) => {
|
|
|
111
120
|
const validateRouteRequest = (config, request) => {
|
|
112
121
|
if (request.messages !== undefined)
|
|
113
122
|
validateGatewayMessages(request.messages);
|
|
123
|
+
if (config.budget && !request.budgetScope)
|
|
124
|
+
throw new GatewayBudgetError();
|
|
114
125
|
const fallbacks = request.fallbacks ?? [];
|
|
126
|
+
for (const scope of [request.budgetScope, request.cacheScope, request.affinityKey]) {
|
|
127
|
+
if (scope !== undefined && (typeof scope !== "string" || !scope.trim() || scope.length > 256))
|
|
128
|
+
throw new GatewayError("Invalid gateway scope.", false);
|
|
129
|
+
}
|
|
115
130
|
const maxFallbacks = getMaxFallbacks(config);
|
|
116
131
|
if (fallbacks.length > maxFallbacks) {
|
|
117
132
|
throw new GatewayError(`Gateway request contains ${fallbacks.length} fallback targets; the configured maximum is ${maxFallbacks}.`, false);
|
|
118
133
|
}
|
|
134
|
+
for (const target of [request.primary, ...fallbacks]) {
|
|
135
|
+
if (target.deploymentId !== undefined && (typeof target.deploymentId !== "string" || !/^[a-zA-Z0-9_.-]{1,128}$/.test(target.deploymentId)))
|
|
136
|
+
throw new GatewayError("Invalid deploymentId.", false);
|
|
137
|
+
}
|
|
119
138
|
validateTarget(request.primary, "Gateway primary target");
|
|
120
139
|
fallbacks.forEach((target, index) => validateTarget(target, `Gateway fallback target at index ${index}`));
|
|
121
140
|
if (request.maxCostPer1kTokens != null &&
|
|
@@ -125,7 +144,7 @@ const validateRouteRequest = (config, request) => {
|
|
|
125
144
|
}
|
|
126
145
|
};
|
|
127
146
|
const orderTargets = (mode, intent, primary, fallbacks, config) => [primary, ...fallbacks]
|
|
128
|
-
.filter((target, index, list) => list.findIndex((candidate) => candidate
|
|
147
|
+
.filter((target, index, list) => list.findIndex((candidate) => sameTarget(candidate, target)) === index)
|
|
129
148
|
.map((target, index) => ({
|
|
130
149
|
target,
|
|
131
150
|
index,
|
|
@@ -231,6 +250,7 @@ const createAttemptControl = (parentSignal, timeoutMs) => {
|
|
|
231
250
|
reject(controller.signal.reason instanceof Error ? controller.signal.reason : timeoutError);
|
|
232
251
|
};
|
|
233
252
|
if (controller.signal.aborted) {
|
|
253
|
+
void promise.catch(() => undefined);
|
|
234
254
|
onAbort();
|
|
235
255
|
return;
|
|
236
256
|
}
|
|
@@ -280,6 +300,8 @@ const redactSensitiveErrorMessage = (message) => message
|
|
|
280
300
|
.replace(/([?&](?:api[-_]?key|key|token|access[-_]?token|secret)=)[^&#\s]*/gi, "$1[REDACTED]")
|
|
281
301
|
.replace(/\b(Bearer)\s+[a-z\d._~+/=-]+/gi, "$1 [REDACTED]");
|
|
282
302
|
const normalizeError = (error) => {
|
|
303
|
+
if (error instanceof GatewayBudgetError || error instanceof GatewayDeadlineError)
|
|
304
|
+
return { error, retrySameTarget: false, fallbackNextTarget: false };
|
|
283
305
|
if (error instanceof ValidationError || error instanceof ConflictError || error instanceof GuardrailTriggeredError) {
|
|
284
306
|
return {
|
|
285
307
|
error: new GatewayError(redactSensitiveErrorMessage(error.message), false),
|
|
@@ -371,6 +393,7 @@ const retryBackoffMs = (config, retry, retryAfterMs) => {
|
|
|
371
393
|
const createAttempt = (target, ok, latencyMs, targetRank, options = {}) => ({
|
|
372
394
|
provider: target.provider,
|
|
373
395
|
modelId: target.modelId,
|
|
396
|
+
...(target.deploymentId ? { deploymentId: target.deploymentId } : {}),
|
|
374
397
|
ok,
|
|
375
398
|
latencyMs,
|
|
376
399
|
targetRank,
|
|
@@ -410,18 +433,20 @@ const runBoundedObserver = (config, parentSignal, observer) => {
|
|
|
410
433
|
return;
|
|
411
434
|
}
|
|
412
435
|
const completion = Promise.resolve(observerResult).catch(() => undefined);
|
|
413
|
-
|
|
436
|
+
return Promise.race([completion, boundary]).finally(cleanup);
|
|
414
437
|
};
|
|
415
438
|
const notifyAttempt = async (config, attempt, parentSignal) => {
|
|
416
439
|
if (!config.onAttempt) {
|
|
417
440
|
return;
|
|
418
441
|
}
|
|
419
|
-
runBoundedObserver(config, parentSignal, (abortSignal) => config.onAttempt?.({
|
|
442
|
+
const completion = runBoundedObserver(config, parentSignal, (abortSignal) => config.onAttempt?.({
|
|
420
443
|
...attempt,
|
|
421
444
|
retry: attempt.retry ?? 0,
|
|
422
445
|
targetRank: attempt.targetRank ?? 0,
|
|
423
446
|
abortSignal
|
|
424
447
|
}));
|
|
448
|
+
if (config.observerMode === "await" || config.observerMode === "background")
|
|
449
|
+
await completion;
|
|
425
450
|
};
|
|
426
451
|
const normalizeUsage = (usage, inputText, outputText) => {
|
|
427
452
|
const inputTokens = usage?.inputTokens ?? estimateTokens(inputText);
|
|
@@ -649,6 +674,37 @@ const enrichAgentResult = (target, attempts, routeDecision, startedAt, result) =
|
|
|
649
674
|
}
|
|
650
675
|
});
|
|
651
676
|
export const createGateway = (config) => {
|
|
677
|
+
for (const value of [config.resourceTimeoutMs, config.cache?.timeoutMs]) {
|
|
678
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 1 || value > 60000))
|
|
679
|
+
throw new GatewayError("Invalid resource/cache timeout.", false);
|
|
680
|
+
}
|
|
681
|
+
const executor = createGatewayExecutor(config);
|
|
682
|
+
if (config.budget && (!config.modelCatalog || !config.budget.currency.trim() || !Number.isFinite(config.budget.reserveAmount) || config.budget.reserveAmount <= 0))
|
|
683
|
+
throw new GatewayError("Budget requires a catalog, currency and positive reservation amount.", false);
|
|
684
|
+
if (config.cache && !config.cache.scope.trim())
|
|
685
|
+
throw new GatewayError("Cache requires an authentication scope.", false);
|
|
686
|
+
if (config.observerMode !== undefined && !["legacy", "await", "background"].includes(config.observerMode))
|
|
687
|
+
throw new GatewayError("Invalid observer mode.", false);
|
|
688
|
+
const observerCapacity = config.observerQueueCapacity ?? 256;
|
|
689
|
+
if (!Number.isSafeInteger(observerCapacity) || observerCapacity < 1 || observerCapacity > 10000)
|
|
690
|
+
throw new GatewayError("Invalid observer queue capacity.", false);
|
|
691
|
+
const affinityTtl = config.affinity?.ttlMs ?? 300000, affinityCapacity = config.affinity?.maxEntries ?? 1000, affinityLoss = config.affinity?.maxScoreLoss ?? 0;
|
|
692
|
+
if (config.affinity && (!config.adaptiveRouting || !Number.isSafeInteger(affinityTtl) || affinityTtl < 1 || affinityTtl > 86400000 || !Number.isSafeInteger(affinityCapacity) || affinityCapacity < 1 || affinityCapacity > 100000 || !Number.isFinite(affinityLoss) || affinityLoss < 0))
|
|
693
|
+
throw new GatewayError("Affinity requires adaptive routing and valid bounded limits.", false);
|
|
694
|
+
const affinities = new Map();
|
|
695
|
+
let routingOperations = 0, explorationCursor = 0;
|
|
696
|
+
const pendingObservers = new Set();
|
|
697
|
+
let droppedObservers = 0, observerTail = Promise.resolve();
|
|
698
|
+
const backgroundObserver = (action) => {
|
|
699
|
+
if (pendingObservers.size >= observerCapacity) {
|
|
700
|
+
droppedObservers++;
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const task = observerTail.then(action).catch(() => undefined);
|
|
704
|
+
observerTail = task;
|
|
705
|
+
pendingObservers.add(task);
|
|
706
|
+
void task.finally(() => pendingObservers.delete(task));
|
|
707
|
+
};
|
|
652
708
|
if (config.adaptiveRouting) {
|
|
653
709
|
validateAdaptivePolicy(config.adaptiveRouting);
|
|
654
710
|
if (config.scoreTarget)
|
|
@@ -682,13 +738,13 @@ export const createGateway = (config) => {
|
|
|
682
738
|
}
|
|
683
739
|
catch { /* Metrics cannot affect execution. */ }
|
|
684
740
|
let ended = false;
|
|
685
|
-
const end = (outcome) => {
|
|
741
|
+
const end = (outcome, outputTokens) => {
|
|
686
742
|
if (ended)
|
|
687
743
|
return;
|
|
688
744
|
ended = true;
|
|
689
745
|
signal?.removeEventListener("abort", onAbort);
|
|
690
746
|
try {
|
|
691
|
-
handle?.end(outcome);
|
|
747
|
+
handle?.end(outcome, outputTokens);
|
|
692
748
|
}
|
|
693
749
|
catch { /* Best effort. */ }
|
|
694
750
|
};
|
|
@@ -712,9 +768,11 @@ export const createGateway = (config) => {
|
|
|
712
768
|
const mode = request.routingMode ?? "balanced";
|
|
713
769
|
const intent = request.taskIntent ?? options.defaultIntent ?? "chat";
|
|
714
770
|
const orderedTargets = config.adaptiveRouting
|
|
715
|
-
? [request.primary, ...(request.fallbacks ?? [])].filter((target, index, all) => all.findIndex(x => x
|
|
771
|
+
? [request.primary, ...(request.fallbacks ?? [])].filter((target, index, all) => all.findIndex(x => sameTarget(x, target)) === index)
|
|
716
772
|
: orderTargets(mode, intent, request.primary, request.fallbacks ?? [], config);
|
|
717
773
|
const routeDecision = createRouteDecision(mode, intent, orderedTargets);
|
|
774
|
+
const affinityId = config.affinity && request.affinityKey && request.cacheScope
|
|
775
|
+
? JSON.stringify([request.budgetScope ?? null, request.cacheScope, request.affinityKey]) : undefined;
|
|
718
776
|
if (config.costAccounting) {
|
|
719
777
|
routeDecision.estimatedCosts = orderedTargets.map(target => calculateModelCost({
|
|
720
778
|
catalog: config.modelCatalog, ...target,
|
|
@@ -732,11 +790,16 @@ export const createGateway = (config) => {
|
|
|
732
790
|
attempt = { ...attempt, ...(attempt.errorMessage !== undefined
|
|
733
791
|
? { errorMessage: redactSensitiveErrorMessage(attempt.errorMessage) } : {}) };
|
|
734
792
|
attempts.push(attempt);
|
|
735
|
-
|
|
793
|
+
if (config.observerMode === "background")
|
|
794
|
+
backgroundObserver(() => notifyAttempt(config, attempt));
|
|
795
|
+
else
|
|
796
|
+
notificationChain = notificationChain.then(() => notifyAttempt(config, attempt, request.abortSignal));
|
|
736
797
|
};
|
|
737
798
|
const requiredCapabilities = buildRequiredCapabilities(request, options.extraRequiredCapabilities ?? {});
|
|
738
799
|
for (const [targetRank, target] of orderedTargets.entries()) {
|
|
739
|
-
const
|
|
800
|
+
const deployment = target.deploymentId === undefined ? undefined : config.deployments?.[target.deploymentId];
|
|
801
|
+
const adapter = target.deploymentId === undefined ? config.adapters[target.provider]
|
|
802
|
+
: deployment?.provider === target.provider ? deployment.adapter : undefined;
|
|
740
803
|
if (!adapter) {
|
|
741
804
|
queueAttempt(createAttempt(target, false, 0, targetRank, {
|
|
742
805
|
reasonCode: "operation-skip",
|
|
@@ -803,7 +866,7 @@ export const createGateway = (config) => {
|
|
|
803
866
|
catch { /* Treat unavailable metrics as cold start. */ }
|
|
804
867
|
const evaluation = scoreAdaptiveTarget(policy, target, intent, snapshot, routeDecision.estimatedCosts?.[index]);
|
|
805
868
|
if (!candidate)
|
|
806
|
-
evaluation.exclusions.push(...attempts.filter(x => x
|
|
869
|
+
evaluation.exclusions.push(...attempts.filter(x => sameTarget(x, target)).map(x => x.reasonCode ?? "operation-skip"));
|
|
807
870
|
if (config.circuitBreaker && !config.circuitBreaker.canAttempt(target))
|
|
808
871
|
evaluation.exclusions.push("circuit-open");
|
|
809
872
|
if (evaluation.exclusions.length && candidate)
|
|
@@ -812,8 +875,29 @@ export const createGateway = (config) => {
|
|
|
812
875
|
});
|
|
813
876
|
routeDecision.reasonCode = "routing-adaptive";
|
|
814
877
|
routeDecision.adaptive = { policyVersion: policy.version, candidates: evaluated };
|
|
815
|
-
const
|
|
816
|
-
eligible
|
|
878
|
+
const byTarget = new Map(evaluated.map(value => [targetKey(value.target), value]));
|
|
879
|
+
const eligible = candidates.filter(candidate => !byTarget.get(targetKey(candidate.target)).exclusions.length);
|
|
880
|
+
eligible.sort((left, right) => byTarget.get(targetKey(right.target)).score - byTarget.get(targetKey(left.target)).score || left.targetRank - right.targetRank);
|
|
881
|
+
const every = policy.explorationEvery;
|
|
882
|
+
if (every && ++routingOperations % every === 0) {
|
|
883
|
+
const cold = eligible.filter(candidate => byTarget.get(targetKey(candidate.target)).missingSignals.includes("health"));
|
|
884
|
+
if (cold.length) {
|
|
885
|
+
const probe = cold[explorationCursor++ % cold.length];
|
|
886
|
+
eligible.splice(eligible.indexOf(probe), 1);
|
|
887
|
+
eligible.unshift(probe);
|
|
888
|
+
routeDecision.adaptive.exploration = true;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
const sticky = affinityId ? affinities.get(affinityId) : undefined;
|
|
892
|
+
if (sticky && sticky.expires <= Date.now())
|
|
893
|
+
affinities.delete(affinityId);
|
|
894
|
+
if (sticky && sticky.expires > Date.now() && !routeDecision.adaptive.exploration && eligible.length) {
|
|
895
|
+
const index = eligible.findIndex(candidate => targetKey(candidate.target) === sticky.target);
|
|
896
|
+
if (index > 0 && byTarget.get(targetKey(eligible[0].target)).score - byTarget.get(sticky.target).score <= affinityLoss) {
|
|
897
|
+
eligible.unshift(eligible.splice(index, 1)[0]);
|
|
898
|
+
routeDecision.adaptive.affinity = true;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
817
901
|
candidates.splice(0, candidates.length, ...eligible);
|
|
818
902
|
candidates.forEach((candidate, index) => { candidate.targetRank = index; });
|
|
819
903
|
routeDecision.orderedTargets = candidates.map(x => x.target);
|
|
@@ -825,6 +909,8 @@ export const createGateway = (config) => {
|
|
|
825
909
|
throw new GatewayError(attempts.at(-1)?.errorMessage ?? "No gateway target satisfied the request.", false);
|
|
826
910
|
}
|
|
827
911
|
const context = {
|
|
912
|
+
budgetScope: request.budgetScope,
|
|
913
|
+
cacheScope: request.cacheScope,
|
|
828
914
|
toolHistory: requestHasToolHistory(request),
|
|
829
915
|
attempts,
|
|
830
916
|
candidates,
|
|
@@ -832,10 +918,16 @@ export const createGateway = (config) => {
|
|
|
832
918
|
startedAt: Date.now(),
|
|
833
919
|
flushAttempts: () => notificationChain,
|
|
834
920
|
recordAttempt: async (attempt) => {
|
|
921
|
+
if (affinityId && attempt.ok && attempt.reasonCode === "provider-success") {
|
|
922
|
+
affinities.delete(affinityId);
|
|
923
|
+
if (affinities.size >= affinityCapacity)
|
|
924
|
+
affinities.delete(affinities.keys().next().value);
|
|
925
|
+
affinities.set(affinityId, { target: targetKey(attempt), expires: Date.now() + affinityTtl });
|
|
926
|
+
}
|
|
835
927
|
if (config.costAccounting && ["provider-success", "provider-error", "request-aborted"].includes(attempt.reasonCode ?? "")) {
|
|
836
928
|
const costInput = { catalog: config.modelCatalog, provider: attempt.provider, modelId: attempt.modelId };
|
|
837
929
|
try {
|
|
838
|
-
attempt = { ...attempt, cost: calculateModelCost({ ...costInput, usage: attempt.usage, cacheAssumption: config.costAccounting.cacheAssumption, reasoningAccounting: config.costAccounting.reasoningAccounting?.[attempt.provider] }) };
|
|
930
|
+
attempt = { ...attempt, cost: calculateModelCost({ ...costInput, usage: attempt.cacheHit ? { inputTokens: 0, outputTokens: 0, totalTokens: 0, cachedInputTokens: 0, cacheWriteTokens: 0 } : attempt.usage, cacheAssumption: config.costAccounting.cacheAssumption, reasoningAccounting: config.costAccounting.reasoningAccounting?.[attempt.provider] }) };
|
|
839
931
|
}
|
|
840
932
|
catch {
|
|
841
933
|
// Accounting cannot turn a successful model invocation into a retry.
|
|
@@ -848,8 +940,7 @@ export const createGateway = (config) => {
|
|
|
848
940
|
await notificationChain;
|
|
849
941
|
},
|
|
850
942
|
lock: async (candidate) => {
|
|
851
|
-
if (context.winner
|
|
852
|
-
context.winner.target.modelId === candidate.target.modelId) {
|
|
943
|
+
if (context.winner && sameTarget(context.winner.target, candidate.target)) {
|
|
853
944
|
return;
|
|
854
945
|
}
|
|
855
946
|
context.winner = candidate;
|
|
@@ -898,6 +989,8 @@ export const createGateway = (config) => {
|
|
|
898
989
|
const throwFinalError = () => {
|
|
899
990
|
if (context.attempts.at(-1)?.reasonCode === "circuit-open")
|
|
900
991
|
throw new GatewayCircuitOpenError();
|
|
992
|
+
if (context.attempts.at(-1)?.reasonCode === "admission-denied")
|
|
993
|
+
throw new GatewayAdmissionError();
|
|
901
994
|
throw new GatewayError(context.attempts.at(-1)?.errorMessage ?? "All gateway attempts failed.", false);
|
|
902
995
|
};
|
|
903
996
|
const generate = async (input) => {
|
|
@@ -932,16 +1025,17 @@ export const createGateway = (config) => {
|
|
|
932
1025
|
const control = createAttemptControl(input.abortSignal, timeoutMs);
|
|
933
1026
|
const metrics = beginMetrics(candidate.target, input.abortSignal);
|
|
934
1027
|
try {
|
|
935
|
-
const result = await control.waitFor(candidate.model.
|
|
1028
|
+
const result = await control.waitFor(executor.generate(candidate.model, candidate.target, {
|
|
936
1029
|
...prepareHistoryInput(candidate.model, candidateInput, context.toolHistory),
|
|
937
1030
|
abortSignal: control.signal
|
|
938
|
-
}));
|
|
1031
|
+
}, context.budgetScope, context.cacheScope));
|
|
939
1032
|
control.stopTimeout();
|
|
940
|
-
metrics.end("success");
|
|
941
|
-
permit?.end("success");
|
|
1033
|
+
metrics.end(cachedResults.has(result) ? "cache" : "success", result.usage?.outputTokens);
|
|
1034
|
+
permit?.end(cachedResults.has(result) ? "neutral" : "success");
|
|
942
1035
|
await context.recordAttempt(createAttempt(candidate.target, true, Date.now() - attemptStartedAt, candidate.targetRank, {
|
|
943
1036
|
retry,
|
|
944
1037
|
reasonCode: "provider-success",
|
|
1038
|
+
...(cachedResults.has(result) ? { cacheHit: true } : {}),
|
|
945
1039
|
...(config.costAccounting ? { usage: result.usage } : {})
|
|
946
1040
|
}));
|
|
947
1041
|
await context.lock(candidate);
|
|
@@ -950,7 +1044,7 @@ export const createGateway = (config) => {
|
|
|
950
1044
|
}
|
|
951
1045
|
catch (rawError) {
|
|
952
1046
|
const callerAborted = input.abortSignal?.aborted === true;
|
|
953
|
-
metrics.end(callerAborted ? "cancelled" : "error");
|
|
1047
|
+
metrics.end(callerAborted || rawError instanceof GatewayAdmissionError || rawError instanceof GatewayBudgetError ? "cancelled" : "error");
|
|
954
1048
|
const error = control.timedOut() ? control.timeoutError : rawError;
|
|
955
1049
|
control.abort(error);
|
|
956
1050
|
control.dispose();
|
|
@@ -967,7 +1061,7 @@ export const createGateway = (config) => {
|
|
|
967
1061
|
permit?.end(disposition.retrySameTarget ? "retryable-error" : "neutral", disposition.retryAfterMs);
|
|
968
1062
|
await context.recordAttempt(createAttempt(candidate.target, false, Date.now() - attemptStartedAt, candidate.targetRank, {
|
|
969
1063
|
retry,
|
|
970
|
-
reasonCode: "provider-error",
|
|
1064
|
+
reasonCode: error instanceof GatewayAdmissionError ? "admission-denied" : error instanceof GatewayBudgetError ? "budget-denied" : "provider-error",
|
|
971
1065
|
errorMessage: disposition.error.message
|
|
972
1066
|
}));
|
|
973
1067
|
if (config.circuitBreaker?.snapshot(candidate.target)?.state === "open")
|
|
@@ -1022,10 +1116,10 @@ export const createGateway = (config) => {
|
|
|
1022
1116
|
let iterator;
|
|
1023
1117
|
const metrics = beginMetrics(candidate.target, input.abortSignal);
|
|
1024
1118
|
try {
|
|
1025
|
-
const providerStream = await control.waitFor(candidate.model.
|
|
1119
|
+
const providerStream = await control.waitFor(executor.stream(candidate.model, candidate.target, {
|
|
1026
1120
|
...prepareHistoryInput(candidate.model, candidateInput, context.toolHistory),
|
|
1027
1121
|
abortSignal: control.signal
|
|
1028
|
-
}));
|
|
1122
|
+
}, context.budgetScope));
|
|
1029
1123
|
iterator = providerStream[Symbol.asyncIterator]();
|
|
1030
1124
|
const firstEvent = await control.waitFor(iterator.next());
|
|
1031
1125
|
if (firstEvent.done) {
|
|
@@ -1061,7 +1155,7 @@ export const createGateway = (config) => {
|
|
|
1061
1155
|
const next = await nextEvent();
|
|
1062
1156
|
if (next.done) {
|
|
1063
1157
|
completed = true;
|
|
1064
|
-
metrics.end("success");
|
|
1158
|
+
metrics.end("success", usage?.outputTokens);
|
|
1065
1159
|
permit?.end("success");
|
|
1066
1160
|
await context.recordAttempt(createAttempt(candidate.target, true, Date.now() - attemptStartedAt, candidate.targetRank, {
|
|
1067
1161
|
retry, reasonCode: "provider-success", ...(config.costAccounting ? { usage } : {})
|
|
@@ -1112,7 +1206,7 @@ export const createGateway = (config) => {
|
|
|
1112
1206
|
}
|
|
1113
1207
|
catch (rawError) {
|
|
1114
1208
|
const callerAborted = input.abortSignal?.aborted === true;
|
|
1115
|
-
metrics.end(callerAborted ? "cancelled" : "error");
|
|
1209
|
+
metrics.end(callerAborted || rawError instanceof GatewayAdmissionError || rawError instanceof GatewayBudgetError ? "cancelled" : "error");
|
|
1116
1210
|
const error = control.timedOut() ? control.timeoutError : rawError;
|
|
1117
1211
|
control.abort(error);
|
|
1118
1212
|
control.dispose();
|
|
@@ -1135,7 +1229,7 @@ export const createGateway = (config) => {
|
|
|
1135
1229
|
permit?.end(disposition.retrySameTarget ? "retryable-error" : "neutral", disposition.retryAfterMs);
|
|
1136
1230
|
await context.recordAttempt(createAttempt(candidate.target, false, Date.now() - attemptStartedAt, candidate.targetRank, {
|
|
1137
1231
|
retry,
|
|
1138
|
-
reasonCode: "provider-error",
|
|
1232
|
+
reasonCode: error instanceof GatewayAdmissionError ? "admission-denied" : error instanceof GatewayBudgetError ? "budget-denied" : "provider-error",
|
|
1139
1233
|
errorMessage: disposition.error.message
|
|
1140
1234
|
}));
|
|
1141
1235
|
if (config.circuitBreaker?.snapshot(candidate.target)?.state === "open")
|
|
@@ -1345,18 +1439,37 @@ export const createGateway = (config) => {
|
|
|
1345
1439
|
};
|
|
1346
1440
|
}
|
|
1347
1441
|
};
|
|
1442
|
+
const operationSignal = (request) => {
|
|
1443
|
+
const candidate = request;
|
|
1444
|
+
return requestHasToolHistory(candidate) || candidate.state?.metadata?.gatewayPortableHistory === true
|
|
1445
|
+
? historyAbortSignal(request.abortSignal) : request.abortSignal;
|
|
1446
|
+
};
|
|
1447
|
+
const timeoutFor = (request) => {
|
|
1448
|
+
for (const value of [request.timeoutMs, config.timeoutMs]) {
|
|
1449
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 1 || value > 2147483647))
|
|
1450
|
+
throw new GatewayError("Invalid operation timeoutMs.", false);
|
|
1451
|
+
}
|
|
1452
|
+
return request.timeoutMs === undefined ? config.timeoutMs : config.timeoutMs === undefined ? request.timeoutMs : Math.min(request.timeoutMs, config.timeoutMs);
|
|
1453
|
+
};
|
|
1454
|
+
const managedCall = async (request, start) => {
|
|
1455
|
+
const control = operationControl(operationSignal(request), timeoutFor(request));
|
|
1456
|
+
try {
|
|
1457
|
+
control.signal.throwIfAborted();
|
|
1458
|
+
return await control.wait(start({ ...request, abortSignal: control.signal }));
|
|
1459
|
+
}
|
|
1460
|
+
finally {
|
|
1461
|
+
control.dispose();
|
|
1462
|
+
}
|
|
1463
|
+
};
|
|
1348
1464
|
const managedStream = (request, start) => {
|
|
1349
|
-
|
|
1465
|
+
const timeoutMs = timeoutFor(request);
|
|
1466
|
+
if (!config.metrics && !config.circuitBreaker && !config.admission && !config.budget && timeoutMs === undefined)
|
|
1350
1467
|
return start(request);
|
|
1351
|
-
const
|
|
1352
|
-
const abort = () => controller.abort(new DOMException("Gateway stream consumer closed.", "AbortError"));
|
|
1353
|
-
request.abortSignal?.addEventListener("abort", abort, { once: true });
|
|
1354
|
-
if (request.abortSignal?.aborted)
|
|
1355
|
-
abort();
|
|
1356
|
-
const cleanup = () => request.abortSignal?.removeEventListener("abort", abort);
|
|
1468
|
+
const control = operationControl(operationSignal(request), timeoutMs);
|
|
1357
1469
|
try {
|
|
1358
|
-
|
|
1359
|
-
const
|
|
1470
|
+
control.signal.throwIfAborted();
|
|
1471
|
+
const result = start({ ...request, abortSignal: control.signal });
|
|
1472
|
+
const completion = control.wait(result.collect()).finally(control.dispose);
|
|
1360
1473
|
void completion.catch(() => undefined);
|
|
1361
1474
|
const wrapped = { ...result, collect: () => completion };
|
|
1362
1475
|
for (const key of ["eventStream", "textStream", "partialObjectStream"]) {
|
|
@@ -1367,10 +1480,10 @@ export const createGateway = (config) => {
|
|
|
1367
1480
|
[Symbol.asyncIterator]() {
|
|
1368
1481
|
const iterator = source[Symbol.asyncIterator]();
|
|
1369
1482
|
return {
|
|
1370
|
-
next: () => iterator.next(),
|
|
1483
|
+
next: () => control.wait(Promise.resolve(iterator.next())),
|
|
1371
1484
|
return: async () => {
|
|
1372
|
-
|
|
1373
|
-
|
|
1485
|
+
control.cancel();
|
|
1486
|
+
control.dispose();
|
|
1374
1487
|
try {
|
|
1375
1488
|
void Promise.resolve(iterator.return?.()).catch(() => undefined);
|
|
1376
1489
|
}
|
|
@@ -1384,13 +1497,19 @@ export const createGateway = (config) => {
|
|
|
1384
1497
|
return wrapped;
|
|
1385
1498
|
}
|
|
1386
1499
|
catch (error) {
|
|
1387
|
-
|
|
1388
|
-
|
|
1500
|
+
control.cancel();
|
|
1501
|
+
control.dispose();
|
|
1389
1502
|
throw error;
|
|
1390
1503
|
}
|
|
1391
1504
|
};
|
|
1392
1505
|
return {
|
|
1393
1506
|
...gateway,
|
|
1507
|
+
flushControls: executor.flush,
|
|
1508
|
+
flushObservers: async () => { await Promise.all([...pendingObservers]); },
|
|
1509
|
+
diagnostics: () => ({ pendingObservers: pendingObservers.size, droppedObservers, affinityEntries: affinities.size, ...executor.diagnostics() }),
|
|
1510
|
+
generate: (request) => managedCall(request, gateway.generate),
|
|
1511
|
+
generateObject: (request) => managedCall(request, gateway.generateObject),
|
|
1512
|
+
runAgent: (request) => managedCall(request, gateway.runAgent),
|
|
1394
1513
|
streamText: (request) => managedStream(request, gateway.streamText),
|
|
1395
1514
|
streamObject: (request) => managedStream(request, gateway.streamObject),
|
|
1396
1515
|
streamAgent: (request) => managedStream(request, gateway.streamAgent)
|