@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/README.md +41 -1
- package/dist/adaptive-routing.d.ts +40 -0
- package/dist/adaptive-routing.d.ts.map +1 -0
- package/dist/adaptive-routing.js +47 -0
- package/dist/adaptive-routing.js.map +1 -0
- package/dist/circuit-breaker.d.ts +36 -0
- package/dist/circuit-breaker.d.ts.map +1 -0
- package/dist/circuit-breaker.js +85 -0
- package/dist/circuit-breaker.js.map +1 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +284 -19
- package/dist/index.js.map +1 -1
- package/dist/metrics.d.ts +30 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +63 -0
- package/dist/metrics.js.map +1 -0
- package/dist/types.d.ts +30 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,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
|
|
498
|
-
if (request.
|
|
499
|
-
throw new GatewayError("
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|