@zhivex-ai/gateway 1.2.2 → 1.3.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,4 +1,8 @@
1
- import { ConflictError, GuardrailTriggeredError, ProviderHTTPError, ValidationError, createAgent, createStructuredOutputPrompt, createTextMessage, generateObject, generateText, runAgent, streamAgent, streamObject, streamText } from "@zhivex-ai/core";
1
+ import { ConflictError, GuardrailTriggeredError, ProviderHTTPError, ValidationError, createAgent, calculateModelCost, createStructuredOutputPrompt, createTextMessage, generateObject, generateText, runAgent, streamAgent, streamObject, streamText } from "@zhivex-ai/core";
2
+ import { GatewayCircuitOpenError } from "./circuit-breaker.js";
3
+ import { scoreAdaptiveTarget, validateAdaptivePolicy } from "./adaptive-routing.js";
4
+ export { createGatewayCircuitBreaker, GatewayCircuitOpenError } from "./circuit-breaker.js";
5
+ export { createGatewayMetrics } from "./metrics.js";
2
6
  import { createRouteDecision, gatewayMessagesToModelMessages } from "./compat.js";
3
7
  import { hasToolHistory, validateGatewayMessages } from "./history.js";
4
8
  import { GatewayError } from "./types.js";
@@ -424,6 +428,7 @@ const normalizeUsage = (usage, inputText, outputText) => {
424
428
  const outputTokens = usage?.outputTokens ?? estimateTokens(outputText);
425
429
  const totalTokens = usage?.totalTokens ?? inputTokens + outputTokens;
426
430
  return {
431
+ ...usage,
427
432
  inputTokens,
428
433
  outputTokens,
429
434
  totalTokens,
@@ -494,11 +499,41 @@ const historyAbortSignal = (signal) => {
494
499
  historySignals.set(signal, controller.signal);
495
500
  return controller.signal;
496
501
  };
497
- const assertLegacyAgentMessages = (request) => {
498
- if (request.messages?.some((message) => "parts" in message)) {
499
- throw new GatewayError("Canonical gateway history is not supported by agent operations.", false);
502
+ const prepareAgentHistoryRequest = (request) => {
503
+ if (request.prompt !== undefined && request.messages !== undefined)
504
+ throw new GatewayError("Pass prompt or messages, not both.", false);
505
+ if (!request.messages?.some(message => "parts" in message))
506
+ return request;
507
+ validateGatewayMessages(request.messages);
508
+ if (request.state || request.runId || request.handoff || request.approvals || request.idempotencyKey) {
509
+ throw new GatewayError("Import canonical agent history as a fresh run; resume durable state using state or runId without messages or an import idempotency key.", false);
500
510
  }
511
+ return { ...request, metadata: { ...request.metadata, gatewayPortableHistory: true }, abortSignal: historyAbortSignal(request.abortSignal) };
501
512
  };
513
+ const agentHistoryStore = (store, context, binding) => store ? new Proxy(store, {
514
+ get(target, key) {
515
+ const value = Reflect.get(target, key);
516
+ if (key === "load")
517
+ return async (...args) => {
518
+ const state = await target.load(...args);
519
+ if (state && binding !== undefined && state.metadata?.gatewayAgentRouteBinding !== binding)
520
+ throw new ConflictError("Agent state belongs to a different gateway route binding.");
521
+ if (state?.metadata?.gatewayPortableHistory === true)
522
+ context.toolHistory = true;
523
+ return state;
524
+ };
525
+ if (key === "claimIdempotencyKey" && target.claimIdempotencyKey)
526
+ return async (...args) => {
527
+ const claim = await target.claimIdempotencyKey(...args);
528
+ if (binding !== undefined && claim.state.metadata?.gatewayAgentRouteBinding !== binding)
529
+ throw new ConflictError("Agent idempotency key belongs to a different gateway route binding.");
530
+ if (claim.state.metadata?.gatewayPortableHistory === true)
531
+ context.toolHistory = true;
532
+ return claim;
533
+ };
534
+ return typeof value === "function" ? value.bind(target) : value;
535
+ }
536
+ }) : undefined;
502
537
  const buildRequiredCapabilities = (request, extra = {}) => ({
503
538
  ...(request.requiredCapabilities ?? {}),
504
539
  ...(request.tools || requestHasToolHistory(request) ? { tools: true } : {}),
@@ -595,6 +630,9 @@ const createAgentRunInput = (request) => {
595
630
  providerOptions: request.providerOptions,
596
631
  policy: request.policy,
597
632
  metadata: request.metadata,
633
+ context: request.context,
634
+ compaction: request.compaction,
635
+ executionEnvironment: request.executionEnvironment,
598
636
  abortSignal: request.abortSignal
599
637
  };
600
638
  };
@@ -611,12 +649,82 @@ const enrichAgentResult = (target, attempts, routeDecision, startedAt, result) =
611
649
  }
612
650
  });
613
651
  export const createGateway = (config) => {
652
+ if (config.adaptiveRouting) {
653
+ validateAdaptivePolicy(config.adaptiveRouting);
654
+ if (config.scoreTarget)
655
+ throw new GatewayError("Choose adaptiveRouting or scoreTarget, not both.", false);
656
+ }
657
+ const configuredAgentRequest = (request) => {
658
+ const definition = request.agent;
659
+ if (!definition)
660
+ return request;
661
+ const defaults = {
662
+ agentId: definition.id, instructions: definition.instructions, tools: Array.isArray(definition.tools) ? Object.fromEntries(definition.tools.map(tool => [tool.name, tool])) : definition.tools,
663
+ maxSteps: definition.maxSteps, temperature: definition.temperature, maxTokens: definition.maxTokens,
664
+ reasoning: definition.reasoning, toolExecution: definition.toolExecution, toolApprovalPolicy: definition.toolApprovalPolicy,
665
+ providerOptions: definition.providerOptions, store: definition.store, memory: definition.memory,
666
+ onTelemetryEvent: definition.onTelemetryEvent, hookFailurePolicy: definition.hookFailurePolicy,
667
+ compaction: definition.compaction, executionEnvironment: definition.executionEnvironment
668
+ };
669
+ const merged = { ...defaults, ...Object.fromEntries(Object.entries(request).filter(([, value]) => value !== undefined)),
670
+ policy: { ...definition.policy, ...request.policy }, metadata: { ...definition.metadata, ...request.metadata }
671
+ };
672
+ const binding = JSON.stringify([1, merged.agentId ?? null, request.primary, request.fallbacks ?? [], config.adaptiveRouting?.version ?? "legacy", definition.harness?.fingerprint ?? null]);
673
+ merged.metadata = { ...merged.metadata, gatewayAgentRouteBinding: binding };
674
+ if (request.state && request.state.metadata?.gatewayAgentRouteBinding !== binding)
675
+ throw new ConflictError("Agent state belongs to a different gateway route binding.");
676
+ return merged;
677
+ };
678
+ const beginMetrics = (target, signal) => {
679
+ let handle;
680
+ try {
681
+ handle = config.metrics?.begin(target);
682
+ }
683
+ catch { /* Metrics cannot affect execution. */ }
684
+ let ended = false;
685
+ const end = (outcome) => {
686
+ if (ended)
687
+ return;
688
+ ended = true;
689
+ signal?.removeEventListener("abort", onAbort);
690
+ try {
691
+ handle?.end(outcome);
692
+ }
693
+ catch { /* Best effort. */ }
694
+ };
695
+ const onAbort = () => end("cancelled");
696
+ signal?.addEventListener("abort", onAbort, { once: true });
697
+ if (signal?.aborted)
698
+ onAbort();
699
+ return { end, firstText: () => { try {
700
+ handle?.firstText();
701
+ }
702
+ catch { /* Best effort. */ } } };
703
+ };
704
+ if (config.costAccounting && !config.modelCatalog)
705
+ throw new GatewayError("costAccounting requires a modelCatalog snapshot.", false);
706
+ if (config.costAccounting?.unknownCostPolicy !== undefined && !["allow", "reject"].includes(config.costAccounting.unknownCostPolicy))
707
+ throw new GatewayError("Invalid detailed unknownCostPolicy.", false);
708
+ if (config.costAccounting?.cacheAssumption !== undefined && !["reported", "none"].includes(config.costAccounting.cacheAssumption))
709
+ throw new GatewayError("Invalid cacheAssumption.", false);
614
710
  const createRouteContext = (request, options = {}) => {
615
711
  validateRouteRequest(config, request);
616
712
  const mode = request.routingMode ?? "balanced";
617
713
  const intent = request.taskIntent ?? options.defaultIntent ?? "chat";
618
- const orderedTargets = orderTargets(mode, intent, request.primary, request.fallbacks ?? [], config);
714
+ 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)
716
+ : orderTargets(mode, intent, request.primary, request.fallbacks ?? [], config);
619
717
  const routeDecision = createRouteDecision(mode, intent, orderedTargets);
718
+ if (config.costAccounting) {
719
+ routeDecision.estimatedCosts = orderedTargets.map(target => calculateModelCost({
720
+ catalog: config.modelCatalog, ...target,
721
+ usage: {
722
+ inputTokens: estimateTokens([request.systemPrompt, request.system, request.instructions, request.prompt, JSON.stringify(request.messages ?? [])].filter(Boolean).join("\n")),
723
+ outputTokens: config.costAccounting.expectedOutputTokens ?? request.maxTokens
724
+ },
725
+ cacheAssumption: "none", estimated: true
726
+ }));
727
+ }
620
728
  const attempts = [];
621
729
  const candidates = [];
622
730
  let notificationChain = Promise.resolve();
@@ -670,6 +778,10 @@ export const createGateway = (config) => {
670
778
  }));
671
779
  continue;
672
780
  }
781
+ if (config.costAccounting?.unknownCostPolicy === "reject" && routeDecision.estimatedCosts?.[targetRank]?.status === "unknown") {
782
+ queueAttempt(createAttempt(target, false, 0, targetRank, { reasonCode: "cost-budget", errorMessage: "Skipped because detailed request cost is unknown." }));
783
+ continue;
784
+ }
673
785
  const skip = options.getSkipReason?.(model, target);
674
786
  if (skip) {
675
787
  queueAttempt(createAttempt(target, false, 0, targetRank, {
@@ -680,7 +792,36 @@ export const createGateway = (config) => {
680
792
  }
681
793
  candidates.push({ target, targetRank, model });
682
794
  }
795
+ if (config.adaptiveRouting) {
796
+ const policy = config.adaptiveRouting;
797
+ const evaluated = orderedTargets.map((target, index) => {
798
+ const candidate = candidates.find(x => x.target === target);
799
+ let snapshot;
800
+ try {
801
+ snapshot = config.metrics?.snapshot(target);
802
+ }
803
+ catch { /* Treat unavailable metrics as cold start. */ }
804
+ const evaluation = scoreAdaptiveTarget(policy, target, intent, snapshot, routeDecision.estimatedCosts?.[index]);
805
+ if (!candidate)
806
+ evaluation.exclusions.push(...attempts.filter(x => x.provider === target.provider && x.modelId === target.modelId).map(x => x.reasonCode ?? "operation-skip"));
807
+ if (config.circuitBreaker && !config.circuitBreaker.canAttempt(target))
808
+ evaluation.exclusions.push("circuit-open");
809
+ if (evaluation.exclusions.length && candidate)
810
+ queueAttempt(createAttempt(target, false, 0, index, { reasonCode: evaluation.exclusions.includes("circuit-open") ? "circuit-open" : "operation-skip", errorMessage: `Adaptive exclusion: ${evaluation.exclusions.join(", ")}.` }));
811
+ return evaluation;
812
+ });
813
+ routeDecision.reasonCode = "routing-adaptive";
814
+ 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);
817
+ candidates.splice(0, candidates.length, ...eligible);
818
+ candidates.forEach((candidate, index) => { candidate.targetRank = index; });
819
+ routeDecision.orderedTargets = candidates.map(x => x.target);
820
+ routeDecision.reason = `Ordered by adaptive policy ${policy.version}; ties preserve request order.`;
821
+ }
683
822
  if (!candidates.length) {
823
+ if (attempts.at(-1)?.reasonCode === "circuit-open")
824
+ throw new GatewayCircuitOpenError();
684
825
  throw new GatewayError(attempts.at(-1)?.errorMessage ?? "No gateway target satisfied the request.", false);
685
826
  }
686
827
  const context = {
@@ -691,6 +832,18 @@ export const createGateway = (config) => {
691
832
  startedAt: Date.now(),
692
833
  flushAttempts: () => notificationChain,
693
834
  recordAttempt: async (attempt) => {
835
+ if (config.costAccounting && ["provider-success", "provider-error", "request-aborted"].includes(attempt.reasonCode ?? "")) {
836
+ const costInput = { catalog: config.modelCatalog, provider: attempt.provider, modelId: attempt.modelId };
837
+ try {
838
+ attempt = { ...attempt, cost: calculateModelCost({ ...costInput, usage: attempt.usage, cacheAssumption: config.costAccounting.cacheAssumption, reasoningAccounting: config.costAccounting.reasoningAccounting?.[attempt.provider] }) };
839
+ }
840
+ catch {
841
+ // Accounting cannot turn a successful model invocation into a retry.
842
+ const cost = calculateModelCost(costInput);
843
+ cost.unknownReasons.push("invalid-reported-usage");
844
+ attempt = { ...attempt, cost };
845
+ }
846
+ }
694
847
  queueAttempt(attempt);
695
848
  await notificationChain;
696
849
  },
@@ -743,6 +896,8 @@ export const createGateway = (config) => {
743
896
  }));
744
897
  };
745
898
  const throwFinalError = () => {
899
+ if (context.attempts.at(-1)?.reasonCode === "circuit-open")
900
+ throw new GatewayCircuitOpenError();
746
901
  throw new GatewayError(context.attempts.at(-1)?.errorMessage ?? "All gateway attempts failed.", false);
747
902
  };
748
903
  const generate = async (input) => {
@@ -754,24 +909,40 @@ export const createGateway = (config) => {
754
909
  for (const candidate of candidates) {
755
910
  const candidateInput = prepareInput(candidate.model, input);
756
911
  const inputSkipReason = modelInputSkipReason(candidate.model, candidateInput) ??
757
- (context.toolHistory ? historyInputSkipReason(candidate.model, input) : undefined);
912
+ (context.toolHistory ? historySkipReason(candidate.model, hasToolHistory(input.messages)) ?? historyInputSkipReason(candidate.model, input) : undefined);
758
913
  if (inputSkipReason) {
759
914
  await recordInputSkip(candidate, inputSkipReason);
760
915
  continue;
761
916
  }
762
917
  for (let retry = 0; retry <= maxRetries; retry += 1) {
763
- reserveProviderAttempt();
918
+ const timeoutMs = getAttemptTimeoutMs(config, candidate.target.provider);
919
+ const permit = config.circuitBreaker?.acquire(candidate.target);
920
+ if (config.circuitBreaker && !permit) {
921
+ await context.recordAttempt(createAttempt(candidate.target, false, 0, candidate.targetRank, { reasonCode: "circuit-open", errorMessage: "Destination circuit is open or probe capacity is exhausted." }));
922
+ break;
923
+ }
924
+ try {
925
+ reserveProviderAttempt();
926
+ }
927
+ catch (error) {
928
+ permit?.end("neutral");
929
+ throw error;
930
+ }
764
931
  const attemptStartedAt = Date.now();
765
- const control = createAttemptControl(input.abortSignal, getAttemptTimeoutMs(config, candidate.target.provider));
932
+ const control = createAttemptControl(input.abortSignal, timeoutMs);
933
+ const metrics = beginMetrics(candidate.target, input.abortSignal);
766
934
  try {
767
935
  const result = await control.waitFor(candidate.model.generate({
768
936
  ...prepareHistoryInput(candidate.model, candidateInput, context.toolHistory),
769
937
  abortSignal: control.signal
770
938
  }));
771
939
  control.stopTimeout();
940
+ metrics.end("success");
941
+ permit?.end("success");
772
942
  await context.recordAttempt(createAttempt(candidate.target, true, Date.now() - attemptStartedAt, candidate.targetRank, {
773
943
  retry,
774
- reasonCode: "provider-success"
944
+ reasonCode: "provider-success",
945
+ ...(config.costAccounting ? { usage: result.usage } : {})
775
946
  }));
776
947
  await context.lock(candidate);
777
948
  control.dispose();
@@ -779,10 +950,12 @@ export const createGateway = (config) => {
779
950
  }
780
951
  catch (rawError) {
781
952
  const callerAborted = input.abortSignal?.aborted === true;
953
+ metrics.end(callerAborted ? "cancelled" : "error");
782
954
  const error = control.timedOut() ? control.timeoutError : rawError;
783
955
  control.abort(error);
784
956
  control.dispose();
785
957
  if (callerAborted) {
958
+ permit?.end("neutral");
786
959
  await context.recordAttempt(createAttempt(candidate.target, false, Date.now() - attemptStartedAt, candidate.targetRank, {
787
960
  retry,
788
961
  reasonCode: "request-aborted",
@@ -791,11 +964,14 @@ export const createGateway = (config) => {
791
964
  throw abortReason(input.abortSignal);
792
965
  }
793
966
  const disposition = dispositionFor(error);
967
+ permit?.end(disposition.retrySameTarget ? "retryable-error" : "neutral", disposition.retryAfterMs);
794
968
  await context.recordAttempt(createAttempt(candidate.target, false, Date.now() - attemptStartedAt, candidate.targetRank, {
795
969
  retry,
796
970
  reasonCode: "provider-error",
797
971
  errorMessage: disposition.error.message
798
972
  }));
973
+ if (config.circuitBreaker?.snapshot(candidate.target)?.state === "open")
974
+ break;
799
975
  if (retry < maxRetries && disposition.retrySameTarget) {
800
976
  await abortableSleep(retryBackoffMs(config, retry, disposition.retryAfterMs), input.abortSignal);
801
977
  continue;
@@ -818,7 +994,7 @@ export const createGateway = (config) => {
818
994
  for (const candidate of candidates) {
819
995
  const candidateInput = prepareInput(candidate.model, input);
820
996
  const inputSkipReason = modelInputSkipReason(candidate.model, candidateInput) ??
821
- (context.toolHistory ? historyInputSkipReason(candidate.model, input) : undefined);
997
+ (context.toolHistory ? historySkipReason(candidate.model, hasToolHistory(input.messages)) ?? historyInputSkipReason(candidate.model, input) : undefined);
822
998
  if (inputSkipReason) {
823
999
  await recordInputSkip(candidate, inputSkipReason);
824
1000
  continue;
@@ -828,10 +1004,23 @@ export const createGateway = (config) => {
828
1004
  continue;
829
1005
  }
830
1006
  for (let retry = 0; retry <= maxRetries; retry += 1) {
831
- reserveProviderAttempt();
1007
+ const timeoutMs = getAttemptTimeoutMs(config, candidate.target.provider);
1008
+ const permit = config.circuitBreaker?.acquire(candidate.target);
1009
+ if (config.circuitBreaker && !permit) {
1010
+ await context.recordAttempt(createAttempt(candidate.target, false, 0, candidate.targetRank, { reasonCode: "circuit-open", errorMessage: "Destination circuit is open or probe capacity is exhausted." }));
1011
+ break;
1012
+ }
1013
+ try {
1014
+ reserveProviderAttempt();
1015
+ }
1016
+ catch (error) {
1017
+ permit?.end("neutral");
1018
+ throw error;
1019
+ }
832
1020
  const attemptStartedAt = Date.now();
833
- const control = createAttemptControl(input.abortSignal, getAttemptTimeoutMs(config, candidate.target.provider));
1021
+ const control = createAttemptControl(input.abortSignal, timeoutMs);
834
1022
  let iterator;
1023
+ const metrics = beginMetrics(candidate.target, input.abortSignal);
835
1024
  try {
836
1025
  const providerStream = await control.waitFor(candidate.model.stream({
837
1026
  ...prepareHistoryInput(candidate.model, candidateInput, context.toolHistory),
@@ -845,6 +1034,8 @@ export const createGateway = (config) => {
845
1034
  if (firstEvent.value.type === "error") {
846
1035
  throw firstEvent.value.error;
847
1036
  }
1037
+ if (firstEvent.value.type === "text-delta")
1038
+ metrics.firstText();
848
1039
  control.stopTimeout();
849
1040
  await context.lock(candidate);
850
1041
  const streamIdleTimeoutMs = getStreamIdleTimeoutMs(config, candidate.target.provider);
@@ -863,28 +1054,39 @@ export const createGateway = (config) => {
863
1054
  };
864
1055
  return (async function* () {
865
1056
  let completed = false;
1057
+ let usage = firstEvent.value.type === "finish" ? firstEvent.value.usage : undefined;
866
1058
  try {
867
1059
  yield firstEvent.value;
868
1060
  for (;;) {
869
1061
  const next = await nextEvent();
870
1062
  if (next.done) {
871
1063
  completed = true;
1064
+ metrics.end("success");
1065
+ permit?.end("success");
872
1066
  await context.recordAttempt(createAttempt(candidate.target, true, Date.now() - attemptStartedAt, candidate.targetRank, {
873
- retry, reasonCode: "provider-success"
1067
+ retry, reasonCode: "provider-success", ...(config.costAccounting ? { usage } : {})
874
1068
  }));
875
1069
  return;
876
1070
  }
877
1071
  if (next.value.type === "error")
878
1072
  throw next.value.error;
1073
+ if (next.value.type === "text-delta")
1074
+ metrics.firstText();
1075
+ if (next.value.type === "finish" && next.value.usage)
1076
+ usage = { ...usage, ...next.value.usage };
879
1077
  yield next.value;
880
1078
  }
881
1079
  }
882
1080
  catch (error) {
883
1081
  const aborted = input.abortSignal?.aborted === true;
884
- const diagnostic = dispositionFor(error).error;
1082
+ metrics.end(aborted ? "cancelled" : "error");
1083
+ const disposition = dispositionFor(error);
1084
+ permit?.end(aborted ? "neutral" : disposition.retrySameTarget ? "retryable-error" : "neutral", disposition.retryAfterMs);
1085
+ const diagnostic = disposition.error;
885
1086
  const failure = aborted ? abortReason(input.abortSignal) : context.toolHistory ? diagnostic : error;
886
1087
  await context.recordAttempt(createAttempt(candidate.target, false, Date.now() - attemptStartedAt, candidate.targetRank, {
887
1088
  retry,
1089
+ ...(config.costAccounting ? { usage } : {}),
888
1090
  reasonCode: aborted ? "request-aborted" : "provider-error",
889
1091
  errorMessage: aborted ? abortReason(input.abortSignal).message : diagnostic.message
890
1092
  }));
@@ -892,6 +1094,8 @@ export const createGateway = (config) => {
892
1094
  }
893
1095
  finally {
894
1096
  if (!completed) {
1097
+ metrics.end("cancelled");
1098
+ permit?.end("neutral");
895
1099
  control.abort(new DOMException("Gateway stream consumer closed.", "AbortError"));
896
1100
  }
897
1101
  control.dispose();
@@ -908,6 +1112,7 @@ export const createGateway = (config) => {
908
1112
  }
909
1113
  catch (rawError) {
910
1114
  const callerAborted = input.abortSignal?.aborted === true;
1115
+ metrics.end(callerAborted ? "cancelled" : "error");
911
1116
  const error = control.timedOut() ? control.timeoutError : rawError;
912
1117
  control.abort(error);
913
1118
  control.dispose();
@@ -918,6 +1123,7 @@ export const createGateway = (config) => {
918
1123
  catch { /* Cleanup must not prevent fallback. */ }
919
1124
  }
920
1125
  if (callerAborted) {
1126
+ permit?.end("neutral");
921
1127
  await context.recordAttempt(createAttempt(candidate.target, false, Date.now() - attemptStartedAt, candidate.targetRank, {
922
1128
  retry,
923
1129
  reasonCode: "request-aborted",
@@ -926,11 +1132,14 @@ export const createGateway = (config) => {
926
1132
  throw abortReason(input.abortSignal);
927
1133
  }
928
1134
  const disposition = dispositionFor(error);
1135
+ permit?.end(disposition.retrySameTarget ? "retryable-error" : "neutral", disposition.retryAfterMs);
929
1136
  await context.recordAttempt(createAttempt(candidate.target, false, Date.now() - attemptStartedAt, candidate.targetRank, {
930
1137
  retry,
931
1138
  reasonCode: "provider-error",
932
1139
  errorMessage: disposition.error.message
933
1140
  }));
1141
+ if (config.circuitBreaker?.snapshot(candidate.target)?.state === "open")
1142
+ break;
934
1143
  if (retry < maxRetries && disposition.retrySameTarget) {
935
1144
  await abortableSleep(retryBackoffMs(config, retry, disposition.retryAfterMs), input.abortSignal);
936
1145
  continue;
@@ -968,7 +1177,7 @@ export const createGateway = (config) => {
968
1177
  model: createRoutedLanguageModel(context)
969
1178
  };
970
1179
  };
971
- return {
1180
+ const gateway = {
972
1181
  async generate(request) {
973
1182
  const route = createStandardRoute(request);
974
1183
  const result = await generateText(createTextOptions(route.model, request));
@@ -1036,7 +1245,7 @@ export const createGateway = (config) => {
1036
1245
  };
1037
1246
  },
1038
1247
  async runAgent(request) {
1039
- assertLegacyAgentMessages(request);
1248
+ request = prepareAgentHistoryRequest(configuredAgentRequest(request));
1040
1249
  const route = createStandardRoute(request, {
1041
1250
  defaultIntent: "tool-heavy",
1042
1251
  getSkipReason: (model) => supportsRequiredAgentCapabilities(model, request.requiredAgentCapabilities)
@@ -1056,7 +1265,10 @@ export const createGateway = (config) => {
1056
1265
  });
1057
1266
  }
1058
1267
  });
1268
+ if (request.state?.metadata?.gatewayPortableHistory === true)
1269
+ route.context.toolHistory = true;
1059
1270
  const agent = createAgent({
1271
+ ...request.agent,
1060
1272
  id: request.agentId,
1061
1273
  model: route.model,
1062
1274
  instructions: request.instructions,
@@ -1070,7 +1282,7 @@ export const createGateway = (config) => {
1070
1282
  providerOptions: request.providerOptions,
1071
1283
  policy: request.policy,
1072
1284
  metadata: request.metadata,
1073
- store: request.store,
1285
+ store: agentHistoryStore(request.store, route.context, request.agent ? request.metadata?.gatewayAgentRouteBinding : undefined),
1074
1286
  memory: request.memory,
1075
1287
  onTelemetryEvent: request.onTelemetryEvent,
1076
1288
  hookFailurePolicy: request.hookFailurePolicy
@@ -1079,7 +1291,7 @@ export const createGateway = (config) => {
1079
1291
  return enrichAgentResult(targetForResult(route.context), route.context.attempts, route.context.routeDecision, route.context.startedAt, result);
1080
1292
  },
1081
1293
  streamAgent(request) {
1082
- assertLegacyAgentMessages(request);
1294
+ request = prepareAgentHistoryRequest(configuredAgentRequest(request));
1083
1295
  const route = createStandardRoute(request, {
1084
1296
  defaultIntent: "tool-heavy",
1085
1297
  extraRequiredCapabilities: { streaming: true },
@@ -1100,7 +1312,10 @@ export const createGateway = (config) => {
1100
1312
  });
1101
1313
  }
1102
1314
  });
1315
+ if (request.state?.metadata?.gatewayPortableHistory === true)
1316
+ route.context.toolHistory = true;
1103
1317
  const agent = createAgent({
1318
+ ...request.agent,
1104
1319
  id: request.agentId,
1105
1320
  model: route.model,
1106
1321
  instructions: request.instructions,
@@ -1114,7 +1329,7 @@ export const createGateway = (config) => {
1114
1329
  providerOptions: request.providerOptions,
1115
1330
  policy: request.policy,
1116
1331
  metadata: request.metadata,
1117
- store: request.store,
1332
+ store: agentHistoryStore(request.store, route.context, request.agent ? request.metadata?.gatewayAgentRouteBinding : undefined),
1118
1333
  memory: request.memory,
1119
1334
  onTelemetryEvent: request.onTelemetryEvent,
1120
1335
  hookFailurePolicy: request.hookFailurePolicy
@@ -1130,5 +1345,55 @@ export const createGateway = (config) => {
1130
1345
  };
1131
1346
  }
1132
1347
  };
1348
+ const managedStream = (request, start) => {
1349
+ if (!config.metrics && !config.circuitBreaker)
1350
+ 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);
1357
+ try {
1358
+ const result = start({ ...request, abortSignal: controller.signal });
1359
+ const completion = result.collect().finally(cleanup);
1360
+ void completion.catch(() => undefined);
1361
+ const wrapped = { ...result, collect: () => completion };
1362
+ for (const key of ["eventStream", "textStream", "partialObjectStream"]) {
1363
+ const source = result[key];
1364
+ if (!source)
1365
+ continue;
1366
+ wrapped[key] = {
1367
+ [Symbol.asyncIterator]() {
1368
+ const iterator = source[Symbol.asyncIterator]();
1369
+ return {
1370
+ next: () => iterator.next(),
1371
+ return: async () => {
1372
+ abort();
1373
+ cleanup();
1374
+ try {
1375
+ void Promise.resolve(iterator.return?.()).catch(() => undefined);
1376
+ }
1377
+ catch { /* Best effort. */ }
1378
+ return { done: true, value: undefined };
1379
+ }
1380
+ };
1381
+ }
1382
+ };
1383
+ }
1384
+ return wrapped;
1385
+ }
1386
+ catch (error) {
1387
+ abort();
1388
+ cleanup();
1389
+ throw error;
1390
+ }
1391
+ };
1392
+ return {
1393
+ ...gateway,
1394
+ streamText: (request) => managedStream(request, gateway.streamText),
1395
+ streamObject: (request) => managedStream(request, gateway.streamObject),
1396
+ streamAgent: (request) => managedStream(request, gateway.streamAgent)
1397
+ };
1133
1398
  };
1134
1399
  //# sourceMappingURL=index.js.map