@zhivex-ai/gateway 1.3.0 → 1.4.1-next.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/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.provider === primary.provider && target.modelId === primary.modelId,
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.provider === target.provider && candidate.modelId === target.modelId) === index)
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
- void Promise.race([completion, boundary]).finally(cleanup);
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.provider === target.provider && x.modelId === target.modelId) === index)
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
- notificationChain = notificationChain.then(() => notifyAttempt(config, attempt, request.abortSignal));
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 adapter = config.adapters[target.provider];
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.provider === target.provider && x.modelId === target.modelId).map(x => x.reasonCode ?? "operation-skip"));
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 eligible = candidates.filter(candidate => !evaluated.find(x => x.target.provider === candidate.target.provider && x.target.modelId === candidate.target.modelId).exclusions.length);
816
- eligible.sort((left, right) => evaluated.find(x => x.target.provider === right.target.provider && x.target.modelId === right.target.modelId).score - evaluated.find(x => x.target.provider === left.target.provider && x.target.modelId === left.target.modelId).score || left.targetRank - right.targetRank);
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?.target.provider === candidate.target.provider &&
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.generate({
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.stream({
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
- if (!config.metrics && !config.circuitBreaker)
1465
+ const timeoutMs = timeoutFor(request);
1466
+ if (!config.metrics && !config.circuitBreaker && !config.admission && !config.budget && timeoutMs === undefined)
1350
1467
  return start(request);
1351
- const controller = new AbortController();
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
- const result = start({ ...request, abortSignal: controller.signal });
1359
- const completion = result.collect().finally(cleanup);
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
- abort();
1373
- cleanup();
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
- abort();
1388
- cleanup();
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)