@zhivex-ai/gateway 1.2.2 → 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/dist/index.js CHANGED
@@ -1,4 +1,17 @@
1
- import { ConflictError, GuardrailTriggeredError, ProviderHTTPError, ValidationError, createAgent, createStructuredOutputPrompt, createTextMessage, generateObject, generateText, runAgent, streamAgent, streamObject, streamText } from "@zhivex-ai/core";
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";
10
+ import { ConflictError, GuardrailTriggeredError, ProviderHTTPError, ValidationError, createAgent, calculateModelCost, createStructuredOutputPrompt, createTextMessage, generateObject, generateText, runAgent, streamAgent, streamObject, streamText } from "@zhivex-ai/core";
11
+ import { GatewayCircuitOpenError } from "./circuit-breaker.js";
12
+ import { scoreAdaptiveTarget, validateAdaptivePolicy } from "./adaptive-routing.js";
13
+ export { createGatewayCircuitBreaker, GatewayCircuitOpenError } from "./circuit-breaker.js";
14
+ export { createGatewayMetrics } from "./metrics.js";
2
15
  import { createRouteDecision, gatewayMessagesToModelMessages } from "./compat.js";
3
16
  import { hasToolHistory, validateGatewayMessages } from "./history.js";
4
17
  import { GatewayError } from "./types.js";
@@ -59,7 +72,7 @@ const scoreTarget = (mode, intent, target, primary, config) => {
59
72
  mode,
60
73
  intent,
61
74
  target,
62
- isPrimary: target.provider === primary.provider && target.modelId === primary.modelId,
75
+ isPrimary: sameTarget(target, primary),
63
76
  configuredCostPer1kTokens: config.providerCostsPer1kTokens?.[target.provider],
64
77
  catalogCostPer1kTokens: config.modelCatalog?.find(target.provider, target.modelId)?.costPer1kTokens,
65
78
  latencyBiasMs: config.latencyBiasMs?.[target.provider]
@@ -107,11 +120,21 @@ const validateTarget = (target, label) => {
107
120
  const validateRouteRequest = (config, request) => {
108
121
  if (request.messages !== undefined)
109
122
  validateGatewayMessages(request.messages);
123
+ if (config.budget && !request.budgetScope)
124
+ throw new GatewayBudgetError();
110
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
+ }
111
130
  const maxFallbacks = getMaxFallbacks(config);
112
131
  if (fallbacks.length > maxFallbacks) {
113
132
  throw new GatewayError(`Gateway request contains ${fallbacks.length} fallback targets; the configured maximum is ${maxFallbacks}.`, false);
114
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
+ }
115
138
  validateTarget(request.primary, "Gateway primary target");
116
139
  fallbacks.forEach((target, index) => validateTarget(target, `Gateway fallback target at index ${index}`));
117
140
  if (request.maxCostPer1kTokens != null &&
@@ -121,7 +144,7 @@ const validateRouteRequest = (config, request) => {
121
144
  }
122
145
  };
123
146
  const orderTargets = (mode, intent, primary, fallbacks, config) => [primary, ...fallbacks]
124
- .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)
125
148
  .map((target, index) => ({
126
149
  target,
127
150
  index,
@@ -227,6 +250,7 @@ const createAttemptControl = (parentSignal, timeoutMs) => {
227
250
  reject(controller.signal.reason instanceof Error ? controller.signal.reason : timeoutError);
228
251
  };
229
252
  if (controller.signal.aborted) {
253
+ void promise.catch(() => undefined);
230
254
  onAbort();
231
255
  return;
232
256
  }
@@ -276,6 +300,8 @@ const redactSensitiveErrorMessage = (message) => message
276
300
  .replace(/([?&](?:api[-_]?key|key|token|access[-_]?token|secret)=)[^&#\s]*/gi, "$1[REDACTED]")
277
301
  .replace(/\b(Bearer)\s+[a-z\d._~+/=-]+/gi, "$1 [REDACTED]");
278
302
  const normalizeError = (error) => {
303
+ if (error instanceof GatewayBudgetError || error instanceof GatewayDeadlineError)
304
+ return { error, retrySameTarget: false, fallbackNextTarget: false };
279
305
  if (error instanceof ValidationError || error instanceof ConflictError || error instanceof GuardrailTriggeredError) {
280
306
  return {
281
307
  error: new GatewayError(redactSensitiveErrorMessage(error.message), false),
@@ -367,6 +393,7 @@ const retryBackoffMs = (config, retry, retryAfterMs) => {
367
393
  const createAttempt = (target, ok, latencyMs, targetRank, options = {}) => ({
368
394
  provider: target.provider,
369
395
  modelId: target.modelId,
396
+ ...(target.deploymentId ? { deploymentId: target.deploymentId } : {}),
370
397
  ok,
371
398
  latencyMs,
372
399
  targetRank,
@@ -406,24 +433,27 @@ const runBoundedObserver = (config, parentSignal, observer) => {
406
433
  return;
407
434
  }
408
435
  const completion = Promise.resolve(observerResult).catch(() => undefined);
409
- void Promise.race([completion, boundary]).finally(cleanup);
436
+ return Promise.race([completion, boundary]).finally(cleanup);
410
437
  };
411
438
  const notifyAttempt = async (config, attempt, parentSignal) => {
412
439
  if (!config.onAttempt) {
413
440
  return;
414
441
  }
415
- runBoundedObserver(config, parentSignal, (abortSignal) => config.onAttempt?.({
442
+ const completion = runBoundedObserver(config, parentSignal, (abortSignal) => config.onAttempt?.({
416
443
  ...attempt,
417
444
  retry: attempt.retry ?? 0,
418
445
  targetRank: attempt.targetRank ?? 0,
419
446
  abortSignal
420
447
  }));
448
+ if (config.observerMode === "await" || config.observerMode === "background")
449
+ await completion;
421
450
  };
422
451
  const normalizeUsage = (usage, inputText, outputText) => {
423
452
  const inputTokens = usage?.inputTokens ?? estimateTokens(inputText);
424
453
  const outputTokens = usage?.outputTokens ?? estimateTokens(outputText);
425
454
  const totalTokens = usage?.totalTokens ?? inputTokens + outputTokens;
426
455
  return {
456
+ ...usage,
427
457
  inputTokens,
428
458
  outputTokens,
429
459
  totalTokens,
@@ -494,11 +524,41 @@ const historyAbortSignal = (signal) => {
494
524
  historySignals.set(signal, controller.signal);
495
525
  return controller.signal;
496
526
  };
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);
527
+ const prepareAgentHistoryRequest = (request) => {
528
+ if (request.prompt !== undefined && request.messages !== undefined)
529
+ throw new GatewayError("Pass prompt or messages, not both.", false);
530
+ if (!request.messages?.some(message => "parts" in message))
531
+ return request;
532
+ validateGatewayMessages(request.messages);
533
+ if (request.state || request.runId || request.handoff || request.approvals || request.idempotencyKey) {
534
+ 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
535
  }
536
+ return { ...request, metadata: { ...request.metadata, gatewayPortableHistory: true }, abortSignal: historyAbortSignal(request.abortSignal) };
501
537
  };
538
+ const agentHistoryStore = (store, context, binding) => store ? new Proxy(store, {
539
+ get(target, key) {
540
+ const value = Reflect.get(target, key);
541
+ if (key === "load")
542
+ return async (...args) => {
543
+ const state = await target.load(...args);
544
+ if (state && binding !== undefined && state.metadata?.gatewayAgentRouteBinding !== binding)
545
+ throw new ConflictError("Agent state belongs to a different gateway route binding.");
546
+ if (state?.metadata?.gatewayPortableHistory === true)
547
+ context.toolHistory = true;
548
+ return state;
549
+ };
550
+ if (key === "claimIdempotencyKey" && target.claimIdempotencyKey)
551
+ return async (...args) => {
552
+ const claim = await target.claimIdempotencyKey(...args);
553
+ if (binding !== undefined && claim.state.metadata?.gatewayAgentRouteBinding !== binding)
554
+ throw new ConflictError("Agent idempotency key belongs to a different gateway route binding.");
555
+ if (claim.state.metadata?.gatewayPortableHistory === true)
556
+ context.toolHistory = true;
557
+ return claim;
558
+ };
559
+ return typeof value === "function" ? value.bind(target) : value;
560
+ }
561
+ }) : undefined;
502
562
  const buildRequiredCapabilities = (request, extra = {}) => ({
503
563
  ...(request.requiredCapabilities ?? {}),
504
564
  ...(request.tools || requestHasToolHistory(request) ? { tools: true } : {}),
@@ -595,6 +655,9 @@ const createAgentRunInput = (request) => {
595
655
  providerOptions: request.providerOptions,
596
656
  policy: request.policy,
597
657
  metadata: request.metadata,
658
+ context: request.context,
659
+ compaction: request.compaction,
660
+ executionEnvironment: request.executionEnvironment,
598
661
  abortSignal: request.abortSignal
599
662
  };
600
663
  };
@@ -611,12 +674,115 @@ const enrichAgentResult = (target, attempts, routeDecision, startedAt, result) =
611
674
  }
612
675
  });
613
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
+ };
708
+ if (config.adaptiveRouting) {
709
+ validateAdaptivePolicy(config.adaptiveRouting);
710
+ if (config.scoreTarget)
711
+ throw new GatewayError("Choose adaptiveRouting or scoreTarget, not both.", false);
712
+ }
713
+ const configuredAgentRequest = (request) => {
714
+ const definition = request.agent;
715
+ if (!definition)
716
+ return request;
717
+ const defaults = {
718
+ agentId: definition.id, instructions: definition.instructions, tools: Array.isArray(definition.tools) ? Object.fromEntries(definition.tools.map(tool => [tool.name, tool])) : definition.tools,
719
+ maxSteps: definition.maxSteps, temperature: definition.temperature, maxTokens: definition.maxTokens,
720
+ reasoning: definition.reasoning, toolExecution: definition.toolExecution, toolApprovalPolicy: definition.toolApprovalPolicy,
721
+ providerOptions: definition.providerOptions, store: definition.store, memory: definition.memory,
722
+ onTelemetryEvent: definition.onTelemetryEvent, hookFailurePolicy: definition.hookFailurePolicy,
723
+ compaction: definition.compaction, executionEnvironment: definition.executionEnvironment
724
+ };
725
+ const merged = { ...defaults, ...Object.fromEntries(Object.entries(request).filter(([, value]) => value !== undefined)),
726
+ policy: { ...definition.policy, ...request.policy }, metadata: { ...definition.metadata, ...request.metadata }
727
+ };
728
+ const binding = JSON.stringify([1, merged.agentId ?? null, request.primary, request.fallbacks ?? [], config.adaptiveRouting?.version ?? "legacy", definition.harness?.fingerprint ?? null]);
729
+ merged.metadata = { ...merged.metadata, gatewayAgentRouteBinding: binding };
730
+ if (request.state && request.state.metadata?.gatewayAgentRouteBinding !== binding)
731
+ throw new ConflictError("Agent state belongs to a different gateway route binding.");
732
+ return merged;
733
+ };
734
+ const beginMetrics = (target, signal) => {
735
+ let handle;
736
+ try {
737
+ handle = config.metrics?.begin(target);
738
+ }
739
+ catch { /* Metrics cannot affect execution. */ }
740
+ let ended = false;
741
+ const end = (outcome, outputTokens) => {
742
+ if (ended)
743
+ return;
744
+ ended = true;
745
+ signal?.removeEventListener("abort", onAbort);
746
+ try {
747
+ handle?.end(outcome, outputTokens);
748
+ }
749
+ catch { /* Best effort. */ }
750
+ };
751
+ const onAbort = () => end("cancelled");
752
+ signal?.addEventListener("abort", onAbort, { once: true });
753
+ if (signal?.aborted)
754
+ onAbort();
755
+ return { end, firstText: () => { try {
756
+ handle?.firstText();
757
+ }
758
+ catch { /* Best effort. */ } } };
759
+ };
760
+ if (config.costAccounting && !config.modelCatalog)
761
+ throw new GatewayError("costAccounting requires a modelCatalog snapshot.", false);
762
+ if (config.costAccounting?.unknownCostPolicy !== undefined && !["allow", "reject"].includes(config.costAccounting.unknownCostPolicy))
763
+ throw new GatewayError("Invalid detailed unknownCostPolicy.", false);
764
+ if (config.costAccounting?.cacheAssumption !== undefined && !["reported", "none"].includes(config.costAccounting.cacheAssumption))
765
+ throw new GatewayError("Invalid cacheAssumption.", false);
614
766
  const createRouteContext = (request, options = {}) => {
615
767
  validateRouteRequest(config, request);
616
768
  const mode = request.routingMode ?? "balanced";
617
769
  const intent = request.taskIntent ?? options.defaultIntent ?? "chat";
618
- const orderedTargets = orderTargets(mode, intent, request.primary, request.fallbacks ?? [], config);
770
+ const orderedTargets = config.adaptiveRouting
771
+ ? [request.primary, ...(request.fallbacks ?? [])].filter((target, index, all) => all.findIndex(x => sameTarget(x, target)) === index)
772
+ : orderTargets(mode, intent, request.primary, request.fallbacks ?? [], config);
619
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;
776
+ if (config.costAccounting) {
777
+ routeDecision.estimatedCosts = orderedTargets.map(target => calculateModelCost({
778
+ catalog: config.modelCatalog, ...target,
779
+ usage: {
780
+ inputTokens: estimateTokens([request.systemPrompt, request.system, request.instructions, request.prompt, JSON.stringify(request.messages ?? [])].filter(Boolean).join("\n")),
781
+ outputTokens: config.costAccounting.expectedOutputTokens ?? request.maxTokens
782
+ },
783
+ cacheAssumption: "none", estimated: true
784
+ }));
785
+ }
620
786
  const attempts = [];
621
787
  const candidates = [];
622
788
  let notificationChain = Promise.resolve();
@@ -624,11 +790,16 @@ export const createGateway = (config) => {
624
790
  attempt = { ...attempt, ...(attempt.errorMessage !== undefined
625
791
  ? { errorMessage: redactSensitiveErrorMessage(attempt.errorMessage) } : {}) };
626
792
  attempts.push(attempt);
627
- 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));
628
797
  };
629
798
  const requiredCapabilities = buildRequiredCapabilities(request, options.extraRequiredCapabilities ?? {});
630
799
  for (const [targetRank, target] of orderedTargets.entries()) {
631
- 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;
632
803
  if (!adapter) {
633
804
  queueAttempt(createAttempt(target, false, 0, targetRank, {
634
805
  reasonCode: "operation-skip",
@@ -670,6 +841,10 @@ export const createGateway = (config) => {
670
841
  }));
671
842
  continue;
672
843
  }
844
+ if (config.costAccounting?.unknownCostPolicy === "reject" && routeDecision.estimatedCosts?.[targetRank]?.status === "unknown") {
845
+ queueAttempt(createAttempt(target, false, 0, targetRank, { reasonCode: "cost-budget", errorMessage: "Skipped because detailed request cost is unknown." }));
846
+ continue;
847
+ }
673
848
  const skip = options.getSkipReason?.(model, target);
674
849
  if (skip) {
675
850
  queueAttempt(createAttempt(target, false, 0, targetRank, {
@@ -680,10 +855,62 @@ export const createGateway = (config) => {
680
855
  }
681
856
  candidates.push({ target, targetRank, model });
682
857
  }
858
+ if (config.adaptiveRouting) {
859
+ const policy = config.adaptiveRouting;
860
+ const evaluated = orderedTargets.map((target, index) => {
861
+ const candidate = candidates.find(x => x.target === target);
862
+ let snapshot;
863
+ try {
864
+ snapshot = config.metrics?.snapshot(target);
865
+ }
866
+ catch { /* Treat unavailable metrics as cold start. */ }
867
+ const evaluation = scoreAdaptiveTarget(policy, target, intent, snapshot, routeDecision.estimatedCosts?.[index]);
868
+ if (!candidate)
869
+ evaluation.exclusions.push(...attempts.filter(x => sameTarget(x, target)).map(x => x.reasonCode ?? "operation-skip"));
870
+ if (config.circuitBreaker && !config.circuitBreaker.canAttempt(target))
871
+ evaluation.exclusions.push("circuit-open");
872
+ if (evaluation.exclusions.length && candidate)
873
+ queueAttempt(createAttempt(target, false, 0, index, { reasonCode: evaluation.exclusions.includes("circuit-open") ? "circuit-open" : "operation-skip", errorMessage: `Adaptive exclusion: ${evaluation.exclusions.join(", ")}.` }));
874
+ return evaluation;
875
+ });
876
+ routeDecision.reasonCode = "routing-adaptive";
877
+ routeDecision.adaptive = { policyVersion: policy.version, candidates: evaluated };
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
+ }
901
+ candidates.splice(0, candidates.length, ...eligible);
902
+ candidates.forEach((candidate, index) => { candidate.targetRank = index; });
903
+ routeDecision.orderedTargets = candidates.map(x => x.target);
904
+ routeDecision.reason = `Ordered by adaptive policy ${policy.version}; ties preserve request order.`;
905
+ }
683
906
  if (!candidates.length) {
907
+ if (attempts.at(-1)?.reasonCode === "circuit-open")
908
+ throw new GatewayCircuitOpenError();
684
909
  throw new GatewayError(attempts.at(-1)?.errorMessage ?? "No gateway target satisfied the request.", false);
685
910
  }
686
911
  const context = {
912
+ budgetScope: request.budgetScope,
913
+ cacheScope: request.cacheScope,
687
914
  toolHistory: requestHasToolHistory(request),
688
915
  attempts,
689
916
  candidates,
@@ -691,12 +918,29 @@ export const createGateway = (config) => {
691
918
  startedAt: Date.now(),
692
919
  flushAttempts: () => notificationChain,
693
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
+ }
927
+ if (config.costAccounting && ["provider-success", "provider-error", "request-aborted"].includes(attempt.reasonCode ?? "")) {
928
+ const costInput = { catalog: config.modelCatalog, provider: attempt.provider, modelId: attempt.modelId };
929
+ try {
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] }) };
931
+ }
932
+ catch {
933
+ // Accounting cannot turn a successful model invocation into a retry.
934
+ const cost = calculateModelCost(costInput);
935
+ cost.unknownReasons.push("invalid-reported-usage");
936
+ attempt = { ...attempt, cost };
937
+ }
938
+ }
694
939
  queueAttempt(attempt);
695
940
  await notificationChain;
696
941
  },
697
942
  lock: async (candidate) => {
698
- if (context.winner?.target.provider === candidate.target.provider &&
699
- context.winner.target.modelId === candidate.target.modelId) {
943
+ if (context.winner && sameTarget(context.winner.target, candidate.target)) {
700
944
  return;
701
945
  }
702
946
  context.winner = candidate;
@@ -743,6 +987,10 @@ export const createGateway = (config) => {
743
987
  }));
744
988
  };
745
989
  const throwFinalError = () => {
990
+ if (context.attempts.at(-1)?.reasonCode === "circuit-open")
991
+ throw new GatewayCircuitOpenError();
992
+ if (context.attempts.at(-1)?.reasonCode === "admission-denied")
993
+ throw new GatewayAdmissionError();
746
994
  throw new GatewayError(context.attempts.at(-1)?.errorMessage ?? "All gateway attempts failed.", false);
747
995
  };
748
996
  const generate = async (input) => {
@@ -754,24 +1002,41 @@ export const createGateway = (config) => {
754
1002
  for (const candidate of candidates) {
755
1003
  const candidateInput = prepareInput(candidate.model, input);
756
1004
  const inputSkipReason = modelInputSkipReason(candidate.model, candidateInput) ??
757
- (context.toolHistory ? historyInputSkipReason(candidate.model, input) : undefined);
1005
+ (context.toolHistory ? historySkipReason(candidate.model, hasToolHistory(input.messages)) ?? historyInputSkipReason(candidate.model, input) : undefined);
758
1006
  if (inputSkipReason) {
759
1007
  await recordInputSkip(candidate, inputSkipReason);
760
1008
  continue;
761
1009
  }
762
1010
  for (let retry = 0; retry <= maxRetries; retry += 1) {
763
- reserveProviderAttempt();
1011
+ const timeoutMs = getAttemptTimeoutMs(config, candidate.target.provider);
1012
+ const permit = config.circuitBreaker?.acquire(candidate.target);
1013
+ if (config.circuitBreaker && !permit) {
1014
+ await context.recordAttempt(createAttempt(candidate.target, false, 0, candidate.targetRank, { reasonCode: "circuit-open", errorMessage: "Destination circuit is open or probe capacity is exhausted." }));
1015
+ break;
1016
+ }
1017
+ try {
1018
+ reserveProviderAttempt();
1019
+ }
1020
+ catch (error) {
1021
+ permit?.end("neutral");
1022
+ throw error;
1023
+ }
764
1024
  const attemptStartedAt = Date.now();
765
- const control = createAttemptControl(input.abortSignal, getAttemptTimeoutMs(config, candidate.target.provider));
1025
+ const control = createAttemptControl(input.abortSignal, timeoutMs);
1026
+ const metrics = beginMetrics(candidate.target, input.abortSignal);
766
1027
  try {
767
- const result = await control.waitFor(candidate.model.generate({
1028
+ const result = await control.waitFor(executor.generate(candidate.model, candidate.target, {
768
1029
  ...prepareHistoryInput(candidate.model, candidateInput, context.toolHistory),
769
1030
  abortSignal: control.signal
770
- }));
1031
+ }, context.budgetScope, context.cacheScope));
771
1032
  control.stopTimeout();
1033
+ metrics.end(cachedResults.has(result) ? "cache" : "success", result.usage?.outputTokens);
1034
+ permit?.end(cachedResults.has(result) ? "neutral" : "success");
772
1035
  await context.recordAttempt(createAttempt(candidate.target, true, Date.now() - attemptStartedAt, candidate.targetRank, {
773
1036
  retry,
774
- reasonCode: "provider-success"
1037
+ reasonCode: "provider-success",
1038
+ ...(cachedResults.has(result) ? { cacheHit: true } : {}),
1039
+ ...(config.costAccounting ? { usage: result.usage } : {})
775
1040
  }));
776
1041
  await context.lock(candidate);
777
1042
  control.dispose();
@@ -779,10 +1044,12 @@ export const createGateway = (config) => {
779
1044
  }
780
1045
  catch (rawError) {
781
1046
  const callerAborted = input.abortSignal?.aborted === true;
1047
+ metrics.end(callerAborted || rawError instanceof GatewayAdmissionError || rawError instanceof GatewayBudgetError ? "cancelled" : "error");
782
1048
  const error = control.timedOut() ? control.timeoutError : rawError;
783
1049
  control.abort(error);
784
1050
  control.dispose();
785
1051
  if (callerAborted) {
1052
+ permit?.end("neutral");
786
1053
  await context.recordAttempt(createAttempt(candidate.target, false, Date.now() - attemptStartedAt, candidate.targetRank, {
787
1054
  retry,
788
1055
  reasonCode: "request-aborted",
@@ -791,11 +1058,14 @@ export const createGateway = (config) => {
791
1058
  throw abortReason(input.abortSignal);
792
1059
  }
793
1060
  const disposition = dispositionFor(error);
1061
+ permit?.end(disposition.retrySameTarget ? "retryable-error" : "neutral", disposition.retryAfterMs);
794
1062
  await context.recordAttempt(createAttempt(candidate.target, false, Date.now() - attemptStartedAt, candidate.targetRank, {
795
1063
  retry,
796
- reasonCode: "provider-error",
1064
+ reasonCode: error instanceof GatewayAdmissionError ? "admission-denied" : error instanceof GatewayBudgetError ? "budget-denied" : "provider-error",
797
1065
  errorMessage: disposition.error.message
798
1066
  }));
1067
+ if (config.circuitBreaker?.snapshot(candidate.target)?.state === "open")
1068
+ break;
799
1069
  if (retry < maxRetries && disposition.retrySameTarget) {
800
1070
  await abortableSleep(retryBackoffMs(config, retry, disposition.retryAfterMs), input.abortSignal);
801
1071
  continue;
@@ -818,7 +1088,7 @@ export const createGateway = (config) => {
818
1088
  for (const candidate of candidates) {
819
1089
  const candidateInput = prepareInput(candidate.model, input);
820
1090
  const inputSkipReason = modelInputSkipReason(candidate.model, candidateInput) ??
821
- (context.toolHistory ? historyInputSkipReason(candidate.model, input) : undefined);
1091
+ (context.toolHistory ? historySkipReason(candidate.model, hasToolHistory(input.messages)) ?? historyInputSkipReason(candidate.model, input) : undefined);
822
1092
  if (inputSkipReason) {
823
1093
  await recordInputSkip(candidate, inputSkipReason);
824
1094
  continue;
@@ -828,15 +1098,28 @@ export const createGateway = (config) => {
828
1098
  continue;
829
1099
  }
830
1100
  for (let retry = 0; retry <= maxRetries; retry += 1) {
831
- reserveProviderAttempt();
1101
+ const timeoutMs = getAttemptTimeoutMs(config, candidate.target.provider);
1102
+ const permit = config.circuitBreaker?.acquire(candidate.target);
1103
+ if (config.circuitBreaker && !permit) {
1104
+ await context.recordAttempt(createAttempt(candidate.target, false, 0, candidate.targetRank, { reasonCode: "circuit-open", errorMessage: "Destination circuit is open or probe capacity is exhausted." }));
1105
+ break;
1106
+ }
1107
+ try {
1108
+ reserveProviderAttempt();
1109
+ }
1110
+ catch (error) {
1111
+ permit?.end("neutral");
1112
+ throw error;
1113
+ }
832
1114
  const attemptStartedAt = Date.now();
833
- const control = createAttemptControl(input.abortSignal, getAttemptTimeoutMs(config, candidate.target.provider));
1115
+ const control = createAttemptControl(input.abortSignal, timeoutMs);
834
1116
  let iterator;
1117
+ const metrics = beginMetrics(candidate.target, input.abortSignal);
835
1118
  try {
836
- const providerStream = await control.waitFor(candidate.model.stream({
1119
+ const providerStream = await control.waitFor(executor.stream(candidate.model, candidate.target, {
837
1120
  ...prepareHistoryInput(candidate.model, candidateInput, context.toolHistory),
838
1121
  abortSignal: control.signal
839
- }));
1122
+ }, context.budgetScope));
840
1123
  iterator = providerStream[Symbol.asyncIterator]();
841
1124
  const firstEvent = await control.waitFor(iterator.next());
842
1125
  if (firstEvent.done) {
@@ -845,6 +1128,8 @@ export const createGateway = (config) => {
845
1128
  if (firstEvent.value.type === "error") {
846
1129
  throw firstEvent.value.error;
847
1130
  }
1131
+ if (firstEvent.value.type === "text-delta")
1132
+ metrics.firstText();
848
1133
  control.stopTimeout();
849
1134
  await context.lock(candidate);
850
1135
  const streamIdleTimeoutMs = getStreamIdleTimeoutMs(config, candidate.target.provider);
@@ -863,28 +1148,39 @@ export const createGateway = (config) => {
863
1148
  };
864
1149
  return (async function* () {
865
1150
  let completed = false;
1151
+ let usage = firstEvent.value.type === "finish" ? firstEvent.value.usage : undefined;
866
1152
  try {
867
1153
  yield firstEvent.value;
868
1154
  for (;;) {
869
1155
  const next = await nextEvent();
870
1156
  if (next.done) {
871
1157
  completed = true;
1158
+ metrics.end("success", usage?.outputTokens);
1159
+ permit?.end("success");
872
1160
  await context.recordAttempt(createAttempt(candidate.target, true, Date.now() - attemptStartedAt, candidate.targetRank, {
873
- retry, reasonCode: "provider-success"
1161
+ retry, reasonCode: "provider-success", ...(config.costAccounting ? { usage } : {})
874
1162
  }));
875
1163
  return;
876
1164
  }
877
1165
  if (next.value.type === "error")
878
1166
  throw next.value.error;
1167
+ if (next.value.type === "text-delta")
1168
+ metrics.firstText();
1169
+ if (next.value.type === "finish" && next.value.usage)
1170
+ usage = { ...usage, ...next.value.usage };
879
1171
  yield next.value;
880
1172
  }
881
1173
  }
882
1174
  catch (error) {
883
1175
  const aborted = input.abortSignal?.aborted === true;
884
- const diagnostic = dispositionFor(error).error;
1176
+ metrics.end(aborted ? "cancelled" : "error");
1177
+ const disposition = dispositionFor(error);
1178
+ permit?.end(aborted ? "neutral" : disposition.retrySameTarget ? "retryable-error" : "neutral", disposition.retryAfterMs);
1179
+ const diagnostic = disposition.error;
885
1180
  const failure = aborted ? abortReason(input.abortSignal) : context.toolHistory ? diagnostic : error;
886
1181
  await context.recordAttempt(createAttempt(candidate.target, false, Date.now() - attemptStartedAt, candidate.targetRank, {
887
1182
  retry,
1183
+ ...(config.costAccounting ? { usage } : {}),
888
1184
  reasonCode: aborted ? "request-aborted" : "provider-error",
889
1185
  errorMessage: aborted ? abortReason(input.abortSignal).message : diagnostic.message
890
1186
  }));
@@ -892,6 +1188,8 @@ export const createGateway = (config) => {
892
1188
  }
893
1189
  finally {
894
1190
  if (!completed) {
1191
+ metrics.end("cancelled");
1192
+ permit?.end("neutral");
895
1193
  control.abort(new DOMException("Gateway stream consumer closed.", "AbortError"));
896
1194
  }
897
1195
  control.dispose();
@@ -908,6 +1206,7 @@ export const createGateway = (config) => {
908
1206
  }
909
1207
  catch (rawError) {
910
1208
  const callerAborted = input.abortSignal?.aborted === true;
1209
+ metrics.end(callerAborted || rawError instanceof GatewayAdmissionError || rawError instanceof GatewayBudgetError ? "cancelled" : "error");
911
1210
  const error = control.timedOut() ? control.timeoutError : rawError;
912
1211
  control.abort(error);
913
1212
  control.dispose();
@@ -918,6 +1217,7 @@ export const createGateway = (config) => {
918
1217
  catch { /* Cleanup must not prevent fallback. */ }
919
1218
  }
920
1219
  if (callerAborted) {
1220
+ permit?.end("neutral");
921
1221
  await context.recordAttempt(createAttempt(candidate.target, false, Date.now() - attemptStartedAt, candidate.targetRank, {
922
1222
  retry,
923
1223
  reasonCode: "request-aborted",
@@ -926,11 +1226,14 @@ export const createGateway = (config) => {
926
1226
  throw abortReason(input.abortSignal);
927
1227
  }
928
1228
  const disposition = dispositionFor(error);
1229
+ permit?.end(disposition.retrySameTarget ? "retryable-error" : "neutral", disposition.retryAfterMs);
929
1230
  await context.recordAttempt(createAttempt(candidate.target, false, Date.now() - attemptStartedAt, candidate.targetRank, {
930
1231
  retry,
931
- reasonCode: "provider-error",
1232
+ reasonCode: error instanceof GatewayAdmissionError ? "admission-denied" : error instanceof GatewayBudgetError ? "budget-denied" : "provider-error",
932
1233
  errorMessage: disposition.error.message
933
1234
  }));
1235
+ if (config.circuitBreaker?.snapshot(candidate.target)?.state === "open")
1236
+ break;
934
1237
  if (retry < maxRetries && disposition.retrySameTarget) {
935
1238
  await abortableSleep(retryBackoffMs(config, retry, disposition.retryAfterMs), input.abortSignal);
936
1239
  continue;
@@ -968,7 +1271,7 @@ export const createGateway = (config) => {
968
1271
  model: createRoutedLanguageModel(context)
969
1272
  };
970
1273
  };
971
- return {
1274
+ const gateway = {
972
1275
  async generate(request) {
973
1276
  const route = createStandardRoute(request);
974
1277
  const result = await generateText(createTextOptions(route.model, request));
@@ -1036,7 +1339,7 @@ export const createGateway = (config) => {
1036
1339
  };
1037
1340
  },
1038
1341
  async runAgent(request) {
1039
- assertLegacyAgentMessages(request);
1342
+ request = prepareAgentHistoryRequest(configuredAgentRequest(request));
1040
1343
  const route = createStandardRoute(request, {
1041
1344
  defaultIntent: "tool-heavy",
1042
1345
  getSkipReason: (model) => supportsRequiredAgentCapabilities(model, request.requiredAgentCapabilities)
@@ -1056,7 +1359,10 @@ export const createGateway = (config) => {
1056
1359
  });
1057
1360
  }
1058
1361
  });
1362
+ if (request.state?.metadata?.gatewayPortableHistory === true)
1363
+ route.context.toolHistory = true;
1059
1364
  const agent = createAgent({
1365
+ ...request.agent,
1060
1366
  id: request.agentId,
1061
1367
  model: route.model,
1062
1368
  instructions: request.instructions,
@@ -1070,7 +1376,7 @@ export const createGateway = (config) => {
1070
1376
  providerOptions: request.providerOptions,
1071
1377
  policy: request.policy,
1072
1378
  metadata: request.metadata,
1073
- store: request.store,
1379
+ store: agentHistoryStore(request.store, route.context, request.agent ? request.metadata?.gatewayAgentRouteBinding : undefined),
1074
1380
  memory: request.memory,
1075
1381
  onTelemetryEvent: request.onTelemetryEvent,
1076
1382
  hookFailurePolicy: request.hookFailurePolicy
@@ -1079,7 +1385,7 @@ export const createGateway = (config) => {
1079
1385
  return enrichAgentResult(targetForResult(route.context), route.context.attempts, route.context.routeDecision, route.context.startedAt, result);
1080
1386
  },
1081
1387
  streamAgent(request) {
1082
- assertLegacyAgentMessages(request);
1388
+ request = prepareAgentHistoryRequest(configuredAgentRequest(request));
1083
1389
  const route = createStandardRoute(request, {
1084
1390
  defaultIntent: "tool-heavy",
1085
1391
  extraRequiredCapabilities: { streaming: true },
@@ -1100,7 +1406,10 @@ export const createGateway = (config) => {
1100
1406
  });
1101
1407
  }
1102
1408
  });
1409
+ if (request.state?.metadata?.gatewayPortableHistory === true)
1410
+ route.context.toolHistory = true;
1103
1411
  const agent = createAgent({
1412
+ ...request.agent,
1104
1413
  id: request.agentId,
1105
1414
  model: route.model,
1106
1415
  instructions: request.instructions,
@@ -1114,7 +1423,7 @@ export const createGateway = (config) => {
1114
1423
  providerOptions: request.providerOptions,
1115
1424
  policy: request.policy,
1116
1425
  metadata: request.metadata,
1117
- store: request.store,
1426
+ store: agentHistoryStore(request.store, route.context, request.agent ? request.metadata?.gatewayAgentRouteBinding : undefined),
1118
1427
  memory: request.memory,
1119
1428
  onTelemetryEvent: request.onTelemetryEvent,
1120
1429
  hookFailurePolicy: request.hookFailurePolicy
@@ -1130,5 +1439,80 @@ export const createGateway = (config) => {
1130
1439
  };
1131
1440
  }
1132
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
+ };
1464
+ const managedStream = (request, start) => {
1465
+ const timeoutMs = timeoutFor(request);
1466
+ if (!config.metrics && !config.circuitBreaker && !config.admission && !config.budget && timeoutMs === undefined)
1467
+ return start(request);
1468
+ const control = operationControl(operationSignal(request), timeoutMs);
1469
+ try {
1470
+ control.signal.throwIfAborted();
1471
+ const result = start({ ...request, abortSignal: control.signal });
1472
+ const completion = control.wait(result.collect()).finally(control.dispose);
1473
+ void completion.catch(() => undefined);
1474
+ const wrapped = { ...result, collect: () => completion };
1475
+ for (const key of ["eventStream", "textStream", "partialObjectStream"]) {
1476
+ const source = result[key];
1477
+ if (!source)
1478
+ continue;
1479
+ wrapped[key] = {
1480
+ [Symbol.asyncIterator]() {
1481
+ const iterator = source[Symbol.asyncIterator]();
1482
+ return {
1483
+ next: () => control.wait(Promise.resolve(iterator.next())),
1484
+ return: async () => {
1485
+ control.cancel();
1486
+ control.dispose();
1487
+ try {
1488
+ void Promise.resolve(iterator.return?.()).catch(() => undefined);
1489
+ }
1490
+ catch { /* Best effort. */ }
1491
+ return { done: true, value: undefined };
1492
+ }
1493
+ };
1494
+ }
1495
+ };
1496
+ }
1497
+ return wrapped;
1498
+ }
1499
+ catch (error) {
1500
+ control.cancel();
1501
+ control.dispose();
1502
+ throw error;
1503
+ }
1504
+ };
1505
+ return {
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),
1513
+ streamText: (request) => managedStream(request, gateway.streamText),
1514
+ streamObject: (request) => managedStream(request, gateway.streamObject),
1515
+ streamAgent: (request) => managedStream(request, gateway.streamAgent)
1516
+ };
1133
1517
  };
1134
1518
  //# sourceMappingURL=index.js.map