@zhivex-ai/gateway 0.9.2 → 1.0.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,8 +1,8 @@
1
- import { createAgent, generateObject, runAgent, generateText, streamAgent, streamObject, streamText } from "@zhivex-ai/core";
2
- import { createRouteDecision, gatewayMessagesToModelMessages, stripImagesForUnsupportedModel } from "./compat.js";
1
+ import { ConflictError, GuardrailTriggeredError, ProviderHTTPError, ValidationError, createAgent, generateObject, generateText, runAgent, streamAgent, streamObject, streamText } from "@zhivex-ai/core";
2
+ import { createRouteDecision, gatewayMessagesToModelMessages } from "./compat.js";
3
3
  import { GatewayError } from "./types.js";
4
4
  export { GatewayError } from "./types.js";
5
- const scoreTarget = (mode, intent, target, config) => {
5
+ const defaultScoreTarget = (mode, intent, target, config) => {
6
6
  const model = target.modelId.toLowerCase();
7
7
  const localBoost = target.provider === "ollama" ? -2 : 0;
8
8
  const qualityBoost = model.includes("pro") || model.includes("claude") ? 2 : 0;
@@ -19,22 +19,45 @@ const scoreTarget = (mode, intent, target, config) => {
19
19
  }
20
20
  return speedBoost + qualityBoost + localBoost + (intent === "reasoning" ? 1 : 0) - costPenalty - latencyPenalty;
21
21
  };
22
+ const scoreTarget = (mode, intent, target, primary, config) => {
23
+ if (!config.scoreTarget) {
24
+ return defaultScoreTarget(mode, intent, target, config);
25
+ }
26
+ const score = config.scoreTarget({
27
+ mode,
28
+ intent,
29
+ target,
30
+ isPrimary: target.provider === primary.provider && target.modelId === primary.modelId,
31
+ configuredCostPer1kTokens: config.providerCostsPer1kTokens?.[target.provider],
32
+ catalogCostPer1kTokens: config.modelCatalog?.find(target.provider, target.modelId)?.costPer1kTokens,
33
+ latencyBiasMs: config.latencyBiasMs?.[target.provider]
34
+ });
35
+ if (!Number.isFinite(score)) {
36
+ throw new GatewayError("Gateway scoreTarget() must return a finite number.", false);
37
+ }
38
+ return score;
39
+ };
22
40
  const orderTargets = (mode, intent, primary, fallbacks, config) => [primary, ...fallbacks]
23
41
  .filter((target, index, list) => list.findIndex((candidate) => candidate.provider === target.provider && candidate.modelId === target.modelId) === index)
24
- .sort((left, right) => scoreTarget(mode, intent, right, config) - scoreTarget(mode, intent, left, config));
25
- const supportsRequiredCapabilities = (adapter, target, requiredCapabilities) => {
42
+ .map((target, index) => ({
43
+ target,
44
+ index,
45
+ score: scoreTarget(mode, intent, target, primary, config)
46
+ }))
47
+ .sort((left, right) => right.score - left.score || left.index - right.index)
48
+ .map(({ target }) => target);
49
+ const supportsRequiredCapabilities = (model, requiredCapabilities) => {
26
50
  if (!requiredCapabilities) {
27
51
  return true;
28
52
  }
29
- const capabilities = adapter.languageModel(target.modelId).capabilities;
30
- return Object.entries(requiredCapabilities).every(([key, required]) => required !== true || capabilities[key] === true);
53
+ return Object.entries(requiredCapabilities).every(([key, required]) => required !== true || model.capabilities[key] === true);
31
54
  };
32
55
  const agentTierRank = (tier) => tier === "tier-a" ? 3 : tier === "tier-b" ? 2 : tier === "tier-c" ? 1 : 0;
33
- const supportsRequiredAgentCapabilities = (adapter, target, requiredAgentCapabilities) => {
56
+ const supportsRequiredAgentCapabilities = (model, requiredAgentCapabilities) => {
34
57
  if (!requiredAgentCapabilities) {
35
58
  return true;
36
59
  }
37
- const capabilities = adapter.languageModel(target.modelId).capabilities.agentCapabilities;
60
+ const capabilities = model.capabilities.agentCapabilities;
38
61
  if (!capabilities) {
39
62
  return false;
40
63
  }
@@ -48,50 +71,196 @@ const supportsRequiredAgentCapabilities = (adapter, target, requiredAgentCapabil
48
71
  return value !== true || capabilities[key] === true;
49
72
  });
50
73
  };
51
- const withinCostBudget = (config, request, target) => {
74
+ const costBudgetSkipReason = (config, request, target) => {
52
75
  if (request.maxCostPer1kTokens == null) {
53
- return true;
76
+ return undefined;
54
77
  }
55
78
  const configuredCost = config.providerCostsPer1kTokens?.[target.provider];
56
79
  const catalogCost = config.modelCatalog?.find(target.provider, target.modelId)?.costPer1kTokens;
57
80
  const effectiveCost = configuredCost ?? catalogCost;
58
81
  if (effectiveCost == null) {
59
- return true;
82
+ return config.unknownCostPolicy === "allow"
83
+ ? undefined
84
+ : "Skipped because model cost is unknown under the configured budget.";
60
85
  }
61
- return effectiveCost <= request.maxCostPer1kTokens;
86
+ return effectiveCost <= request.maxCostPer1kTokens
87
+ ? undefined
88
+ : "Skipped because provider cost exceeds the configured budget.";
62
89
  };
63
90
  const estimateTokens = (text) => Math.max(1, Math.ceil(text.trim().length / 4));
64
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
65
- const withTimeout = async (promise, timeoutMs) => new Promise((resolve, reject) => {
66
- const timer = setTimeout(() => reject(new GatewayError(`Provider timed out after ${timeoutMs}ms.`, true)), timeoutMs);
67
- promise
68
- .then((value) => {
69
- clearTimeout(timer);
70
- resolve(value);
71
- })
72
- .catch((error) => {
91
+ const abortReason = (signal) => {
92
+ if (signal.reason instanceof Error) {
93
+ return signal.reason;
94
+ }
95
+ return new DOMException("The gateway request was aborted.", "AbortError");
96
+ };
97
+ const abortableSleep = (ms, signal) => new Promise((resolve, reject) => {
98
+ if (signal?.aborted) {
99
+ reject(abortReason(signal));
100
+ return;
101
+ }
102
+ const timer = setTimeout(() => {
103
+ signal?.removeEventListener("abort", onAbort);
104
+ resolve();
105
+ }, Math.max(0, ms));
106
+ const onAbort = () => {
73
107
  clearTimeout(timer);
74
- reject(error);
75
- });
108
+ reject(signal ? abortReason(signal) : new DOMException("The gateway request was aborted.", "AbortError"));
109
+ };
110
+ signal?.addEventListener("abort", onAbort, { once: true });
76
111
  });
112
+ const createAttemptControl = (parentSignal, timeoutMs) => {
113
+ const controller = new AbortController();
114
+ let timedOut = false;
115
+ const timeoutError = new GatewayError(`Provider timed out after ${timeoutMs}ms.`, true);
116
+ const onParentAbort = () => controller.abort(parentSignal ? abortReason(parentSignal) : undefined);
117
+ if (parentSignal?.aborted) {
118
+ onParentAbort();
119
+ }
120
+ else {
121
+ parentSignal?.addEventListener("abort", onParentAbort, { once: true });
122
+ }
123
+ let timer = setTimeout(() => {
124
+ timedOut = true;
125
+ controller.abort(timeoutError);
126
+ }, timeoutMs);
127
+ const stopTimeout = () => {
128
+ if (timer) {
129
+ clearTimeout(timer);
130
+ timer = undefined;
131
+ }
132
+ };
133
+ const waitFor = (promise) => new Promise((resolve, reject) => {
134
+ let settled = false;
135
+ const onAbort = () => {
136
+ if (settled) {
137
+ return;
138
+ }
139
+ settled = true;
140
+ controller.signal.removeEventListener("abort", onAbort);
141
+ reject(controller.signal.reason instanceof Error ? controller.signal.reason : timeoutError);
142
+ };
143
+ if (controller.signal.aborted) {
144
+ onAbort();
145
+ return;
146
+ }
147
+ controller.signal.addEventListener("abort", onAbort, { once: true });
148
+ promise.then((value) => {
149
+ if (settled) {
150
+ return;
151
+ }
152
+ settled = true;
153
+ controller.signal.removeEventListener("abort", onAbort);
154
+ resolve(value);
155
+ }, (error) => {
156
+ if (settled) {
157
+ return;
158
+ }
159
+ settled = true;
160
+ controller.signal.removeEventListener("abort", onAbort);
161
+ reject(error);
162
+ });
163
+ });
164
+ return {
165
+ signal: controller.signal,
166
+ waitFor,
167
+ timedOut: () => timedOut,
168
+ timeoutError,
169
+ stopTimeout,
170
+ abort: (reason) => controller.abort(reason),
171
+ dispose: () => {
172
+ stopTimeout();
173
+ parentSignal?.removeEventListener("abort", onParentAbort);
174
+ }
175
+ };
176
+ };
177
+ const providerHTTPStatus = (error) => {
178
+ if (error instanceof ProviderHTTPError) {
179
+ return error.status;
180
+ }
181
+ if (error instanceof Error &&
182
+ error.name === "ProviderHTTPError" &&
183
+ "status" in error &&
184
+ typeof error.status === "number") {
185
+ return error.status;
186
+ }
187
+ return undefined;
188
+ };
77
189
  const normalizeError = (error) => {
190
+ if (error instanceof ValidationError || error instanceof ConflictError || error instanceof GuardrailTriggeredError) {
191
+ return {
192
+ error: new GatewayError(error.message, false),
193
+ retrySameTarget: false,
194
+ fallbackNextTarget: false
195
+ };
196
+ }
78
197
  if (error instanceof GatewayError) {
79
- return error;
198
+ return {
199
+ error,
200
+ retrySameTarget: error.retryable,
201
+ fallbackNextTarget: true
202
+ };
203
+ }
204
+ const status = providerHTTPStatus(error);
205
+ if (status != null) {
206
+ const retryable = status === 408 || status === 429 || status >= 500;
207
+ return {
208
+ error: new GatewayError(error instanceof Error ? error.message : `Provider HTTP ${status}.`, retryable),
209
+ retrySameTarget: retryable,
210
+ fallbackNextTarget: true
211
+ };
80
212
  }
81
213
  if (error instanceof Error) {
82
- const message = error.message.toLowerCase();
83
- if (message.includes("timed out") || message.includes("429") || message.includes("rate")) {
84
- return new GatewayError(error.message, true);
85
- }
86
- if (message.includes("connect") || message.includes("econnrefused") || message.includes("503")) {
87
- return new GatewayError(error.message, true);
214
+ if (error.name === "AbortError") {
215
+ return {
216
+ error: new GatewayError(error.message, false),
217
+ retrySameTarget: false,
218
+ fallbackNextTarget: false
219
+ };
88
220
  }
89
- return new GatewayError(error.message, false);
221
+ const message = error.message.toLowerCase();
222
+ const retryable = message.includes("timed out") ||
223
+ message.includes("timeout") ||
224
+ message.includes("429") ||
225
+ message.includes("rate limit") ||
226
+ message.includes("connect") ||
227
+ message.includes("econnrefused") ||
228
+ message.includes("enotfound") ||
229
+ message.includes("network") ||
230
+ /\b50[0234]\b/.test(message);
231
+ return {
232
+ error: new GatewayError(error.message, retryable),
233
+ retrySameTarget: retryable,
234
+ fallbackNextTarget: true
235
+ };
236
+ }
237
+ return {
238
+ error: new GatewayError("Unknown gateway error.", false),
239
+ retrySameTarget: false,
240
+ fallbackNextTarget: true
241
+ };
242
+ };
243
+ const getAttemptTimeoutMs = (config, provider) => {
244
+ const timeoutMs = config.attemptTimeoutsMs?.[provider] ?? config.attemptTimeoutMs ?? 20_000;
245
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
246
+ throw new GatewayError("Gateway attempt timeouts must be finite positive numbers.", false);
90
247
  }
91
- return new GatewayError("Unknown gateway error.", false);
248
+ return timeoutMs;
249
+ };
250
+ const getMaxRetries = (config) => {
251
+ const maxRetries = config.maxRetries ?? 2;
252
+ if (!Number.isFinite(maxRetries) || maxRetries < 0) {
253
+ throw new GatewayError("Gateway maxRetries must be a finite non-negative number.", false);
254
+ }
255
+ return Math.floor(maxRetries);
256
+ };
257
+ const retryBackoffMs = (config, retry) => {
258
+ const base = config.retryBackoffMs ?? 200;
259
+ if (!Number.isFinite(base) || base < 0) {
260
+ throw new GatewayError("Gateway retryBackoffMs must be a finite non-negative number.", false);
261
+ }
262
+ return base * (retry + 1);
92
263
  };
93
- const getAttemptTimeoutMs = (config, provider) => config.attemptTimeoutsMs?.[provider] ?? config.attemptTimeoutMs ?? 20_000;
94
- const retryBackoffMs = (config, retry) => (config.retryBackoffMs ?? 200) * (retry + 1);
95
264
  const createAttempt = (target, ok, latencyMs, targetRank, options = {}) => ({
96
265
  provider: target.provider,
97
266
  modelId: target.modelId,
@@ -100,13 +269,17 @@ const createAttempt = (target, ok, latencyMs, targetRank, options = {}) => ({
100
269
  targetRank,
101
270
  ...options
102
271
  });
103
- const recordAttempt = async (config, attempts, attempt) => {
104
- attempts.push(attempt);
105
- await config.onAttempt?.({
106
- ...attempt,
107
- retry: attempt.retry ?? 0,
108
- targetRank: attempt.targetRank ?? 0
109
- });
272
+ const notifyAttempt = async (config, attempt) => {
273
+ try {
274
+ await config.onAttempt?.({
275
+ ...attempt,
276
+ retry: attempt.retry ?? 0,
277
+ targetRank: attempt.targetRank ?? 0
278
+ });
279
+ }
280
+ catch {
281
+ // Observability is best-effort and must never retry or fail provider work.
282
+ }
110
283
  };
111
284
  const normalizeUsage = (usage, inputText, outputText) => {
112
285
  const inputTokens = usage?.inputTokens ?? estimateTokens(inputText);
@@ -119,31 +292,48 @@ const normalizeUsage = (usage, inputText, outputText) => {
119
292
  estimated: usage?.inputTokens == null || usage?.outputTokens == null || usage?.totalTokens == null
120
293
  };
121
294
  };
122
- const requestMessages = (request) => gatewayMessagesToModelMessages(stripImagesForUnsupportedModel(request.messages, request.primary.provider, request.primary.modelId), request.systemPrompt);
123
295
  const getInputText = (request) => `${request.systemPrompt ?? ""}\n${request.messages.map((message) => message.content).join("\n")}`.trim();
296
+ const requestHasImages = (request) => request.messages?.some((message) => (message.images?.length ?? 0) > 0) ?? false;
124
297
  const buildRequiredCapabilities = (request, extra = {}) => ({
125
298
  ...(request.requiredCapabilities ?? {}),
126
299
  ...(request.tools ? { tools: true } : {}),
300
+ ...(request.toolChoice ? { toolChoice: true } : {}),
127
301
  ...(request.reasoning ? { reasoning: true } : {}),
302
+ ...(requestHasImages(request) ? { vision: true } : {}),
128
303
  ...extra
129
304
  });
130
- const objectCapabilitySkipReason = (adapter, target, request) => {
131
- const capabilities = adapter.languageModel(target.modelId).capabilities;
305
+ const objectCapabilitySkipReason = (model, request) => {
132
306
  const mode = request.mode ?? "auto";
133
- if (mode === "native" && !capabilities.structuredOutput) {
307
+ if (mode === "native" && !model.capabilities.structuredOutput) {
134
308
  return "Skipped because model capabilities do not satisfy native structured output.";
135
309
  }
136
- if (mode === "prompted" && !capabilities.jsonMode) {
310
+ if (mode === "prompted" && !model.capabilities.jsonMode) {
137
311
  return "Skipped because model capabilities do not satisfy prompted JSON output.";
138
312
  }
139
- if (mode === "auto" && !capabilities.structuredOutput && !capabilities.jsonMode) {
313
+ if (mode === "auto" && !model.capabilities.structuredOutput && !model.capabilities.jsonMode) {
140
314
  return "Skipped because model capabilities do not satisfy object output.";
141
315
  }
142
316
  return undefined;
143
317
  };
144
- const createTextOptions = (adapter, target, request) => ({
145
- model: adapter.languageModel(target.modelId),
146
- messages: gatewayMessagesToModelMessages(stripImagesForUnsupportedModel(request.messages, target.provider, target.modelId), request.systemPrompt),
318
+ const modelInputSkipReason = (model, input) => {
319
+ if (input.tools && !model.capabilities.tools) {
320
+ return "Skipped because model does not support tools.";
321
+ }
322
+ if (input.reasoning && !model.capabilities.reasoning) {
323
+ return "Skipped because model does not support reasoning.";
324
+ }
325
+ if (input.messages.some((message) => message.parts.some((part) => part.type === "image")) &&
326
+ !model.capabilities.vision) {
327
+ return "Skipped because model does not support image input.";
328
+ }
329
+ if (input.structuredOutput?.mode === "native" && !model.capabilities.structuredOutput) {
330
+ return "Skipped because model does not support native structured output.";
331
+ }
332
+ return undefined;
333
+ };
334
+ const createTextOptions = (model, request) => ({
335
+ model,
336
+ messages: gatewayMessagesToModelMessages(request.messages, request.systemPrompt),
147
337
  tools: request.tools,
148
338
  toolChoice: request.toolChoice,
149
339
  toolExecution: request.toolExecution,
@@ -159,7 +349,7 @@ const enrichTextResult = (request, target, attempts, routeDecision, startedAt, r
159
349
  providerUsed: target.provider,
160
350
  modelUsed: target.modelId,
161
351
  latencyMs: Date.now() - startedAt,
162
- attempts,
352
+ attempts: [...attempts],
163
353
  usage: normalizeUsage(result.usage, getInputText(request), result.text),
164
354
  routeDecision
165
355
  });
@@ -168,21 +358,23 @@ const enrichObjectResult = (request, target, attempts, routeDecision, startedAt,
168
358
  providerUsed: target.provider,
169
359
  modelUsed: target.modelId,
170
360
  latencyMs: Date.now() - startedAt,
171
- attempts,
361
+ attempts: [...attempts],
172
362
  usage: normalizeUsage(result.usage, getInputText(request), result.text),
173
363
  routeDecision
174
364
  });
175
- const createAgentMessages = (request, target) => request.messages
176
- ? gatewayMessagesToModelMessages(stripImagesForUnsupportedModel(request.messages, target.provider, target.modelId), undefined)
177
- : undefined;
178
- const createAgentRunInput = (request, target) => {
365
+ const createAgentMessages = (request) => request.messages ? gatewayMessagesToModelMessages(request.messages, undefined) : undefined;
366
+ const createAgentRunInput = (request) => {
179
367
  const source = request.prompt !== undefined
180
368
  ? { prompt: request.prompt }
181
369
  : request.messages
182
- ? { messages: createAgentMessages(request, target) }
370
+ ? { messages: createAgentMessages(request) }
183
371
  : {};
184
372
  return {
185
373
  ...source,
374
+ runId: request.runId,
375
+ scope: request.scope,
376
+ idempotencyKey: request.idempotencyKey,
377
+ parentRunId: request.parentRunId,
186
378
  system: request.system,
187
379
  state: request.state,
188
380
  approvals: request.approvals,
@@ -190,11 +382,14 @@ const createAgentRunInput = (request, target) => {
190
382
  tools: request.tools,
191
383
  toolChoice: request.toolChoice,
192
384
  toolExecution: request.toolExecution,
385
+ toolApprovalPolicy: request.toolApprovalPolicy,
193
386
  maxSteps: request.maxSteps,
194
387
  temperature: request.temperature,
195
388
  maxTokens: request.maxTokens,
196
389
  reasoning: request.reasoning,
197
390
  providerOptions: request.providerOptions,
391
+ policy: request.policy,
392
+ metadata: request.metadata,
198
393
  abortSignal: request.abortSignal
199
394
  };
200
395
  };
@@ -203,7 +398,7 @@ const enrichAgentResult = (target, attempts, routeDecision, startedAt, result) =
203
398
  providerUsed: target.provider,
204
399
  modelUsed: target.modelId,
205
400
  latencyMs: Date.now() - startedAt,
206
- attempts,
401
+ attempts: [...attempts],
207
402
  routeDecision,
208
403
  state: {
209
404
  ...result.state,
@@ -211,307 +406,368 @@ const enrichAgentResult = (target, attempts, routeDecision, startedAt, result) =
211
406
  }
212
407
  });
213
408
  export const createGateway = (config) => {
214
- const getAdapter = (provider) => {
215
- const adapter = config.adapters[provider];
216
- if (!adapter) {
217
- throw new GatewayError(`No adapter registered for provider "${provider}".`, false);
218
- }
219
- return adapter;
220
- };
221
- const routeDecisionFor = (request) => {
222
- const mode = request.routingMode ?? "balanced";
223
- const intent = request.taskIntent ?? "chat";
224
- const orderedTargets = orderTargets(mode, intent, request.primary, request.fallbacks ?? [], config);
225
- return {
226
- mode,
227
- intent,
228
- orderedTargets,
229
- routeDecision: createRouteDecision(mode, intent, orderedTargets)
230
- };
231
- };
232
- const selectAgentTarget = async (request) => {
409
+ const createRouteContext = (request, options = {}) => {
233
410
  const mode = request.routingMode ?? "balanced";
234
- const intent = request.taskIntent ?? "tool-heavy";
411
+ const intent = request.taskIntent ?? options.defaultIntent ?? "chat";
235
412
  const orderedTargets = orderTargets(mode, intent, request.primary, request.fallbacks ?? [], config);
236
413
  const routeDecision = createRouteDecision(mode, intent, orderedTargets);
237
414
  const attempts = [];
238
- for (const [targetIndex, target] of orderedTargets.entries()) {
239
- const adapter = getAdapter(target.provider);
240
- if (!supportsRequiredCapabilities(adapter, target, buildRequiredCapabilities(request))) {
241
- await recordAttempt(config, attempts, createAttempt(target, false, 0, targetIndex, {
242
- reasonCode: "model-capabilities",
243
- errorMessage: "Skipped because model capabilities do not satisfy the request."
415
+ const candidates = [];
416
+ let notificationChain = Promise.resolve();
417
+ const queueAttempt = (attempt) => {
418
+ attempts.push(attempt);
419
+ notificationChain = notificationChain.then(() => notifyAttempt(config, attempt));
420
+ };
421
+ const requiredCapabilities = buildRequiredCapabilities(request, options.extraRequiredCapabilities ?? {});
422
+ for (const [targetRank, target] of orderedTargets.entries()) {
423
+ const adapter = config.adapters[target.provider];
424
+ if (!adapter) {
425
+ queueAttempt(createAttempt(target, false, 0, targetRank, {
426
+ reasonCode: "operation-skip",
427
+ errorMessage: `Skipped because no adapter is registered for provider "${target.provider}".`
244
428
  }));
245
429
  continue;
246
430
  }
247
- if (!supportsRequiredAgentCapabilities(adapter, target, request.requiredAgentCapabilities)) {
248
- await recordAttempt(config, attempts, createAttempt(target, false, 0, targetIndex, {
249
- reasonCode: "agent-capabilities",
250
- errorMessage: "Skipped because agent capabilities do not satisfy the request."
251
- }));
252
- continue;
431
+ let model;
432
+ try {
433
+ model = adapter.languageModel(target.modelId);
253
434
  }
254
- if (!withinCostBudget(config, request, target)) {
255
- await recordAttempt(config, attempts, createAttempt(target, false, 0, targetIndex, {
256
- reasonCode: "cost-budget",
257
- errorMessage: "Skipped because provider cost exceeds the configured budget."
435
+ catch (error) {
436
+ queueAttempt(createAttempt(target, false, 0, targetRank, {
437
+ reasonCode: "operation-skip",
438
+ errorMessage: error instanceof Error ? error.message : "Provider model construction failed."
258
439
  }));
259
440
  continue;
260
441
  }
261
- await config.onAgentRoute?.({
262
- provider: target.provider,
263
- modelId: target.modelId,
264
- routeDecision,
265
- attempts,
266
- targetRank: targetIndex
267
- });
268
- return {
269
- target,
270
- attempts,
271
- routeDecision,
272
- startedAt: Date.now(),
273
- targetRank: targetIndex
274
- };
275
- }
276
- throw new GatewayError(attempts.at(-1)?.errorMessage ?? "No gateway agent target satisfied the request.", false);
277
- };
278
- const runGenerate = async (request, operation, extraRequiredCapabilities = {}, getSkipReason) => {
279
- const attempts = [];
280
- const startedAt = Date.now();
281
- const { orderedTargets, routeDecision } = routeDecisionFor(request);
282
- const maxRetries = Math.max(0, config.maxRetries ?? 2);
283
- const requiredCapabilities = buildRequiredCapabilities(request, extraRequiredCapabilities);
284
- for (const [targetIndex, target] of orderedTargets.entries()) {
285
- const adapter = getAdapter(target.provider);
286
- if (!supportsRequiredCapabilities(adapter, target, requiredCapabilities)) {
287
- await recordAttempt(config, attempts, createAttempt(target, false, 0, targetIndex, {
442
+ if (!supportsRequiredCapabilities(model, requiredCapabilities)) {
443
+ queueAttempt(createAttempt(target, false, 0, targetRank, {
288
444
  reasonCode: "model-capabilities",
289
445
  errorMessage: "Skipped because model capabilities do not satisfy the request."
290
446
  }));
291
447
  continue;
292
448
  }
293
- if (!withinCostBudget(config, request, target)) {
294
- await recordAttempt(config, attempts, createAttempt(target, false, 0, targetIndex, {
449
+ const budgetReason = costBudgetSkipReason(config, request, target);
450
+ if (budgetReason) {
451
+ queueAttempt(createAttempt(target, false, 0, targetRank, {
295
452
  reasonCode: "cost-budget",
296
- errorMessage: "Skipped because provider cost exceeds the configured budget."
453
+ errorMessage: budgetReason
297
454
  }));
298
455
  continue;
299
456
  }
300
- const skipReason = getSkipReason?.(adapter, target);
301
- if (skipReason) {
302
- await recordAttempt(config, attempts, createAttempt(target, false, 0, targetIndex, {
303
- reasonCode: "operation-skip",
304
- errorMessage: skipReason
457
+ const skip = options.getSkipReason?.(model, target);
458
+ if (skip) {
459
+ queueAttempt(createAttempt(target, false, 0, targetRank, {
460
+ reasonCode: skip.reasonCode,
461
+ errorMessage: skip.message
305
462
  }));
306
463
  continue;
307
464
  }
308
- for (let retry = 0; retry <= maxRetries; retry += 1) {
309
- const attemptStartedAt = Date.now();
465
+ candidates.push({ target, targetRank, model });
466
+ }
467
+ if (!candidates.length) {
468
+ throw new GatewayError(attempts.at(-1)?.errorMessage ?? "No gateway target satisfied the request.", false);
469
+ }
470
+ const context = {
471
+ attempts,
472
+ candidates,
473
+ routeDecision,
474
+ startedAt: Date.now(),
475
+ flushAttempts: () => notificationChain,
476
+ recordAttempt: async (attempt) => {
477
+ queueAttempt(attempt);
478
+ await notificationChain;
479
+ },
480
+ lock: async (candidate) => {
481
+ if (context.winner?.target.provider === candidate.target.provider &&
482
+ context.winner.target.modelId === candidate.target.modelId) {
483
+ return;
484
+ }
485
+ context.winner = candidate;
310
486
  try {
311
- const result = await withTimeout(operation(adapter, target), getAttemptTimeoutMs(config, target.provider));
312
- await recordAttempt(config, attempts, createAttempt(target, true, Date.now() - attemptStartedAt, targetIndex, {
313
- retry,
314
- reasonCode: "provider-success"
315
- }));
316
- return {
317
- attempts,
318
- target,
319
- startedAt,
320
- routeDecision,
321
- result
322
- };
487
+ await options.onWinner?.(candidate, [...attempts]);
323
488
  }
324
- catch (error) {
325
- const normalized = normalizeError(error);
326
- await recordAttempt(config, attempts, createAttempt(target, false, Date.now() - attemptStartedAt, targetIndex, {
327
- reasonCode: "provider-error",
328
- errorMessage: normalized.message,
329
- retry
330
- }));
331
- if (retry < maxRetries && normalized.retryable) {
332
- await sleep(retryBackoffMs(config, retry));
333
- continue;
334
- }
335
- break;
489
+ catch {
490
+ // Routing observers are best-effort and cannot fail provider work.
336
491
  }
337
492
  }
338
- }
339
- const finalError = attempts.at(-1)?.errorMessage ?? "All gateway attempts failed.";
340
- throw new GatewayError(finalError, false);
493
+ };
494
+ return context;
341
495
  };
342
- const runStream = async (request, operation, extraRequiredCapabilities = {}, getSkipReason) => {
343
- const attempts = [];
344
- const startedAt = Date.now();
345
- const { orderedTargets, routeDecision } = routeDecisionFor(request);
346
- const maxRetries = Math.max(0, config.maxRetries ?? 2);
347
- const requiredCapabilities = buildRequiredCapabilities(request, { streaming: true, ...extraRequiredCapabilities });
348
- for (const [targetIndex, target] of orderedTargets.entries()) {
349
- const adapter = getAdapter(target.provider);
350
- if (!supportsRequiredCapabilities(adapter, target, requiredCapabilities)) {
351
- await recordAttempt(config, attempts, createAttempt(target, false, 0, targetIndex, {
352
- reasonCode: "model-capabilities",
353
- errorMessage: "Skipped because model capabilities do not satisfy the request."
354
- }));
355
- continue;
356
- }
357
- if (!withinCostBudget(config, request, target)) {
358
- await recordAttempt(config, attempts, createAttempt(target, false, 0, targetIndex, {
359
- reasonCode: "cost-budget",
360
- errorMessage: "Skipped because provider cost exceeds the configured budget."
361
- }));
362
- continue;
363
- }
364
- const skipReason = getSkipReason?.(adapter, target);
365
- if (skipReason) {
366
- await recordAttempt(config, attempts, createAttempt(target, false, 0, targetIndex, {
367
- reasonCode: "operation-skip",
368
- errorMessage: skipReason
369
- }));
370
- continue;
371
- }
372
- for (let retry = 0; retry <= maxRetries; retry += 1) {
373
- const attemptStartedAt = Date.now();
374
- try {
375
- const streamResult = operation(adapter, target);
376
- const iterator = streamResult.eventStream[Symbol.asyncIterator]();
377
- const first = await withTimeout(iterator.next(), getAttemptTimeoutMs(config, target.provider));
378
- if (!first.done && first.value.type === "error") {
379
- throw first.value.error;
496
+ const createRoutedLanguageModel = (context) => {
497
+ const first = context.candidates[0];
498
+ const recordInputSkip = async (candidate, message) => {
499
+ await context.recordAttempt(createAttempt(candidate.target, false, 0, candidate.targetRank, {
500
+ reasonCode: "operation-skip",
501
+ errorMessage: message
502
+ }));
503
+ };
504
+ const throwFinalError = () => {
505
+ throw new GatewayError(context.attempts.at(-1)?.errorMessage ?? "All gateway attempts failed.", false);
506
+ };
507
+ const generate = async (input) => {
508
+ await context.flushAttempts();
509
+ const candidates = context.winner
510
+ ? [context.winner, ...context.candidates.filter((candidate) => candidate !== context.winner)]
511
+ : context.candidates;
512
+ const maxRetries = getMaxRetries(config);
513
+ for (const candidate of candidates) {
514
+ const inputSkipReason = modelInputSkipReason(candidate.model, input);
515
+ if (inputSkipReason) {
516
+ await recordInputSkip(candidate, inputSkipReason);
517
+ continue;
518
+ }
519
+ for (let retry = 0; retry <= maxRetries; retry += 1) {
520
+ const attemptStartedAt = Date.now();
521
+ const control = createAttemptControl(input.abortSignal, getAttemptTimeoutMs(config, candidate.target.provider));
522
+ try {
523
+ const result = await control.waitFor(candidate.model.generate({
524
+ ...input,
525
+ abortSignal: control.signal
526
+ }));
527
+ control.stopTimeout();
528
+ await context.recordAttempt(createAttempt(candidate.target, true, Date.now() - attemptStartedAt, candidate.targetRank, {
529
+ retry,
530
+ reasonCode: "provider-success"
531
+ }));
532
+ await context.lock(candidate);
533
+ control.dispose();
534
+ return result;
380
535
  }
381
- const eventStream = (async function* () {
382
- if (!first.done) {
383
- yield first.value;
536
+ catch (rawError) {
537
+ const callerAborted = input.abortSignal?.aborted === true;
538
+ const error = control.timedOut() ? control.timeoutError : rawError;
539
+ control.abort(error);
540
+ control.dispose();
541
+ if (callerAborted) {
542
+ await context.recordAttempt(createAttempt(candidate.target, false, Date.now() - attemptStartedAt, candidate.targetRank, {
543
+ retry,
544
+ reasonCode: "request-aborted",
545
+ errorMessage: abortReason(input.abortSignal).message
546
+ }));
547
+ throw abortReason(input.abortSignal);
384
548
  }
385
- for (;;) {
386
- const next = await iterator.next();
387
- if (next.done) {
388
- return;
389
- }
390
- yield next.value;
549
+ const disposition = normalizeError(error);
550
+ await context.recordAttempt(createAttempt(candidate.target, false, Date.now() - attemptStartedAt, candidate.targetRank, {
551
+ retry,
552
+ reasonCode: "provider-error",
553
+ errorMessage: disposition.error.message
554
+ }));
555
+ if (retry < maxRetries && disposition.retrySameTarget) {
556
+ await abortableSleep(retryBackoffMs(config, retry), input.abortSignal);
557
+ continue;
391
558
  }
392
- })();
393
- await recordAttempt(config, attempts, createAttempt(target, true, Date.now() - attemptStartedAt, targetIndex, {
394
- retry,
395
- reasonCode: "provider-success"
396
- }));
397
- return {
398
- attempts,
399
- target,
400
- startedAt,
401
- routeDecision,
402
- streamResult: {
403
- ...streamResult,
404
- eventStream
559
+ if (!disposition.fallbackNextTarget) {
560
+ throw disposition.error;
405
561
  }
406
- };
562
+ break;
563
+ }
564
+ }
565
+ }
566
+ return throwFinalError();
567
+ };
568
+ const stream = async (input) => {
569
+ await context.flushAttempts();
570
+ const candidates = context.winner
571
+ ? [context.winner, ...context.candidates.filter((candidate) => candidate !== context.winner)]
572
+ : context.candidates;
573
+ const maxRetries = getMaxRetries(config);
574
+ for (const candidate of candidates) {
575
+ const inputSkipReason = modelInputSkipReason(candidate.model, input);
576
+ if (inputSkipReason) {
577
+ await recordInputSkip(candidate, inputSkipReason);
578
+ continue;
579
+ }
580
+ if (!candidate.model.stream) {
581
+ await recordInputSkip(candidate, "Skipped because model does not support streaming.");
582
+ continue;
407
583
  }
408
- catch (error) {
409
- const normalized = normalizeError(error);
410
- await recordAttempt(config, attempts, createAttempt(target, false, Date.now() - attemptStartedAt, targetIndex, {
411
- reasonCode: "provider-error",
412
- errorMessage: normalized.message,
413
- retry
414
- }));
415
- if (retry < maxRetries && normalized.retryable) {
416
- await sleep(retryBackoffMs(config, retry));
417
- continue;
584
+ for (let retry = 0; retry <= maxRetries; retry += 1) {
585
+ const attemptStartedAt = Date.now();
586
+ const control = createAttemptControl(input.abortSignal, getAttemptTimeoutMs(config, candidate.target.provider));
587
+ let iterator;
588
+ try {
589
+ const providerStream = await control.waitFor(candidate.model.stream({
590
+ ...input,
591
+ abortSignal: control.signal
592
+ }));
593
+ iterator = providerStream[Symbol.asyncIterator]();
594
+ const firstEvent = await control.waitFor(iterator.next());
595
+ if (firstEvent.done) {
596
+ throw new GatewayError("Provider stream ended before emitting an event.", false);
597
+ }
598
+ if (firstEvent.value.type === "error") {
599
+ throw firstEvent.value.error;
600
+ }
601
+ control.stopTimeout();
602
+ await context.recordAttempt(createAttempt(candidate.target, true, Date.now() - attemptStartedAt, candidate.targetRank, {
603
+ retry,
604
+ reasonCode: "provider-success"
605
+ }));
606
+ await context.lock(candidate);
607
+ return (async function* () {
608
+ let completed = false;
609
+ try {
610
+ yield firstEvent.value;
611
+ for (;;) {
612
+ const next = await control.waitFor(iterator.next());
613
+ if (next.done) {
614
+ completed = true;
615
+ return;
616
+ }
617
+ yield next.value;
618
+ }
619
+ }
620
+ finally {
621
+ if (!completed) {
622
+ control.abort(new DOMException("Gateway stream consumer closed.", "AbortError"));
623
+ }
624
+ control.dispose();
625
+ if (iterator?.return) {
626
+ await iterator.return();
627
+ }
628
+ }
629
+ })();
630
+ }
631
+ catch (rawError) {
632
+ const callerAborted = input.abortSignal?.aborted === true;
633
+ const error = control.timedOut() ? control.timeoutError : rawError;
634
+ control.abort(error);
635
+ control.dispose();
636
+ if (iterator?.return) {
637
+ void iterator.return().catch(() => undefined);
638
+ }
639
+ if (callerAborted) {
640
+ await context.recordAttempt(createAttempt(candidate.target, false, Date.now() - attemptStartedAt, candidate.targetRank, {
641
+ retry,
642
+ reasonCode: "request-aborted",
643
+ errorMessage: abortReason(input.abortSignal).message
644
+ }));
645
+ throw abortReason(input.abortSignal);
646
+ }
647
+ const disposition = normalizeError(error);
648
+ await context.recordAttempt(createAttempt(candidate.target, false, Date.now() - attemptStartedAt, candidate.targetRank, {
649
+ retry,
650
+ reasonCode: "provider-error",
651
+ errorMessage: disposition.error.message
652
+ }));
653
+ if (retry < maxRetries && disposition.retrySameTarget) {
654
+ await abortableSleep(retryBackoffMs(config, retry), input.abortSignal);
655
+ continue;
656
+ }
657
+ if (!disposition.fallbackNextTarget) {
658
+ throw disposition.error;
659
+ }
660
+ break;
418
661
  }
419
- break;
420
662
  }
421
663
  }
422
- }
423
- const finalError = attempts.at(-1)?.errorMessage ?? "All gateway attempts failed.";
424
- throw new GatewayError(finalError, false);
664
+ return throwFinalError();
665
+ };
666
+ return {
667
+ get provider() {
668
+ return context.winner?.model.provider ?? first.model.provider;
669
+ },
670
+ get modelId() {
671
+ return context.winner?.model.modelId ?? first.model.modelId;
672
+ },
673
+ get capabilities() {
674
+ return context.winner?.model.capabilities ?? first.model.capabilities;
675
+ },
676
+ generate,
677
+ stream
678
+ };
679
+ };
680
+ const targetForResult = (context) => (context.winner ?? context.candidates[0]).target;
681
+ const createStandardRoute = (request, options = {}) => {
682
+ const context = createRouteContext(request, options);
683
+ return {
684
+ context,
685
+ model: createRoutedLanguageModel(context)
686
+ };
425
687
  };
426
688
  return {
427
689
  async generate(request) {
428
- const routed = await runGenerate(request, (adapter, target) => generateText(createTextOptions(adapter, target, request)));
429
- return enrichTextResult(request, routed.target, routed.attempts, routed.routeDecision, routed.startedAt, routed.result);
690
+ const route = createStandardRoute(request);
691
+ const result = await generateText(createTextOptions(route.model, request));
692
+ return enrichTextResult(request, targetForResult(route.context), route.context.attempts, route.context.routeDecision, route.context.startedAt, result);
430
693
  },
431
694
  streamText(request) {
432
- const selected = runStream(request, (adapter, target) => streamText(createTextOptions(adapter, target, request)));
433
- let relayPromise;
434
- const ensureRelay = async () => {
435
- if (!relayPromise) {
436
- relayPromise = selected.then((routed) => ({
437
- collect: async () => enrichTextResult(request, routed.target, routed.attempts, routed.routeDecision, routed.startedAt, await routed.streamResult.collect())
438
- }));
439
- }
440
- return relayPromise;
441
- };
695
+ const route = createStandardRoute(request, {
696
+ extraRequiredCapabilities: { streaming: true }
697
+ });
698
+ const streamResult = streamText(createTextOptions(route.model, request));
442
699
  return {
443
- eventStream: (async function* () {
444
- const { streamResult } = await selected;
445
- for await (const event of streamResult.eventStream) {
446
- yield event;
447
- }
448
- })(),
449
- textStream: (async function* () {
450
- const { streamResult } = await selected;
451
- for await (const chunk of streamResult.textStream) {
452
- yield chunk;
453
- }
454
- })(),
455
- collect: async () => (await ensureRelay()).collect()
700
+ eventStream: streamResult.eventStream,
701
+ textStream: streamResult.textStream,
702
+ collect: async () => {
703
+ const result = await streamResult.collect();
704
+ return enrichTextResult(request, targetForResult(route.context), route.context.attempts, route.context.routeDecision, route.context.startedAt, result);
705
+ }
456
706
  };
457
707
  },
458
708
  async generateObject(request) {
459
- const routed = await runGenerate(request, (adapter, target) => generateObject({
460
- ...createTextOptions(adapter, target, request),
709
+ const route = createStandardRoute(request, {
710
+ getSkipReason: (model) => {
711
+ const message = objectCapabilitySkipReason(model, request);
712
+ return message ? { reasonCode: "operation-skip", message } : undefined;
713
+ }
714
+ });
715
+ const result = await generateObject({
716
+ ...createTextOptions(route.model, request),
461
717
  schema: request.schema,
462
718
  mode: request.mode,
463
719
  schemaName: request.schemaName,
464
720
  schemaDescription: request.schemaDescription
465
- }), {}, (adapter, target) => objectCapabilitySkipReason(adapter, target, request));
466
- return enrichObjectResult(request, routed.target, routed.attempts, routed.routeDecision, routed.startedAt, routed.result);
721
+ });
722
+ return enrichObjectResult(request, targetForResult(route.context), route.context.attempts, route.context.routeDecision, route.context.startedAt, result);
467
723
  },
468
724
  streamObject(request) {
469
- const selected = runStream(request, (adapter, target) => streamObject({
470
- ...createTextOptions(adapter, target, request),
725
+ const route = createStandardRoute(request, {
726
+ extraRequiredCapabilities: { streaming: true },
727
+ getSkipReason: (model) => {
728
+ const message = objectCapabilitySkipReason(model, request);
729
+ return message ? { reasonCode: "operation-skip", message } : undefined;
730
+ }
731
+ });
732
+ const streamResult = streamObject({
733
+ ...createTextOptions(route.model, request),
471
734
  schema: request.schema,
472
735
  mode: request.mode,
473
736
  schemaName: request.schemaName,
474
737
  schemaDescription: request.schemaDescription
475
- }), {}, (adapter, target) => objectCapabilitySkipReason(adapter, target, request));
476
- let relayPromise;
477
- const ensureRelay = async () => {
478
- if (!relayPromise) {
479
- relayPromise = selected.then((routed) => ({
480
- streamResult: routed.streamResult,
481
- collect: async () => enrichObjectResult(request, routed.target, routed.attempts, routed.routeDecision, routed.startedAt, await routed.streamResult.collect())
482
- }));
483
- }
484
- return relayPromise;
485
- };
738
+ });
486
739
  return {
487
- eventStream: (async function* () {
488
- const { streamResult } = await selected;
489
- for await (const event of streamResult.eventStream) {
490
- yield event;
491
- }
492
- })(),
493
- partialObjectStream: (async function* () {
494
- const relay = await ensureRelay();
495
- for await (const partial of relay.streamResult.partialObjectStream) {
496
- yield partial;
497
- }
498
- })(),
499
- textStream: (async function* () {
500
- const relay = await ensureRelay();
501
- for await (const chunk of relay.streamResult.textStream) {
502
- yield chunk;
503
- }
504
- })(),
505
- collect: async () => (await ensureRelay()).collect()
740
+ eventStream: streamResult.eventStream,
741
+ partialObjectStream: streamResult.partialObjectStream,
742
+ textStream: streamResult.textStream,
743
+ collect: async () => {
744
+ const result = await streamResult.collect();
745
+ return enrichObjectResult(request, targetForResult(route.context), route.context.attempts, route.context.routeDecision, route.context.startedAt, result);
746
+ }
506
747
  };
507
748
  },
508
749
  async runAgent(request) {
509
- const selection = await selectAgentTarget(request);
510
- const adapter = getAdapter(selection.target.provider);
511
- const model = adapter.languageModel(selection.target.modelId);
750
+ const route = createStandardRoute(request, {
751
+ defaultIntent: "tool-heavy",
752
+ getSkipReason: (model) => supportsRequiredAgentCapabilities(model, request.requiredAgentCapabilities)
753
+ ? undefined
754
+ : {
755
+ reasonCode: "agent-capabilities",
756
+ message: "Skipped because agent capabilities do not satisfy the request."
757
+ },
758
+ onWinner: async (candidate, attempts) => {
759
+ await config.onAgentRoute?.({
760
+ provider: candidate.target.provider,
761
+ modelId: candidate.target.modelId,
762
+ routeDecision: route.context.routeDecision,
763
+ attempts,
764
+ targetRank: candidate.targetRank
765
+ });
766
+ }
767
+ });
512
768
  const agent = createAgent({
513
769
  id: request.agentId,
514
- model,
770
+ model: route.model,
515
771
  instructions: request.instructions,
516
772
  tools: request.tools,
517
773
  maxSteps: request.maxSteps,
@@ -519,78 +775,65 @@ export const createGateway = (config) => {
519
775
  maxTokens: request.maxTokens,
520
776
  reasoning: request.reasoning,
521
777
  toolExecution: request.toolExecution,
778
+ toolApprovalPolicy: request.toolApprovalPolicy,
522
779
  providerOptions: request.providerOptions,
780
+ policy: request.policy,
523
781
  metadata: request.metadata,
524
782
  store: request.store,
525
783
  memory: request.memory,
526
- onTelemetryEvent: request.onTelemetryEvent
784
+ onTelemetryEvent: request.onTelemetryEvent,
785
+ hookFailurePolicy: request.hookFailurePolicy
527
786
  });
528
- const result = await runAgent(agent, {
529
- ...createAgentRunInput(request, selection.target)
530
- });
531
- return enrichAgentResult(selection.target, [
532
- ...selection.attempts,
533
- createAttempt(selection.target, true, Date.now() - selection.startedAt, selection.targetRank, {
534
- retry: 0,
535
- reasonCode: "provider-success"
536
- })
537
- ], selection.routeDecision, selection.startedAt, result);
787
+ const result = await runAgent(agent, createAgentRunInput(request));
788
+ return enrichAgentResult(targetForResult(route.context), route.context.attempts, route.context.routeDecision, route.context.startedAt, result);
538
789
  },
539
790
  streamAgent(request) {
540
- const selection = selectAgentTarget(request);
541
- let relayPromise;
542
- const ensureRelay = async () => {
543
- if (!relayPromise) {
544
- relayPromise = selection.then(async (selected) => {
545
- const adapter = getAdapter(selected.target.provider);
546
- const model = adapter.languageModel(selected.target.modelId);
547
- const agent = createAgent({
548
- id: request.agentId,
549
- model,
550
- instructions: request.instructions,
551
- tools: request.tools,
552
- maxSteps: request.maxSteps,
553
- temperature: request.temperature,
554
- maxTokens: request.maxTokens,
555
- reasoning: request.reasoning,
556
- toolExecution: request.toolExecution,
557
- providerOptions: request.providerOptions,
558
- metadata: request.metadata,
559
- store: request.store,
560
- memory: request.memory,
561
- onTelemetryEvent: request.onTelemetryEvent
562
- });
563
- const streamResult = streamAgent(agent, {
564
- ...createAgentRunInput(request, selected.target)
565
- });
566
- return {
567
- streamResult,
568
- collect: async () => enrichAgentResult(selected.target, [
569
- ...selected.attempts,
570
- createAttempt(selected.target, true, Date.now() - selected.startedAt, selected.targetRank, {
571
- retry: 0,
572
- reasonCode: "provider-success"
573
- })
574
- ], selected.routeDecision, selected.startedAt, await streamResult.collect())
575
- };
791
+ const route = createStandardRoute(request, {
792
+ defaultIntent: "tool-heavy",
793
+ extraRequiredCapabilities: { streaming: true },
794
+ getSkipReason: (model) => supportsRequiredAgentCapabilities(model, request.requiredAgentCapabilities)
795
+ ? undefined
796
+ : {
797
+ reasonCode: "agent-capabilities",
798
+ message: "Skipped because agent capabilities do not satisfy the request."
799
+ },
800
+ onWinner: async (candidate, attempts) => {
801
+ await config.onAgentRoute?.({
802
+ provider: candidate.target.provider,
803
+ modelId: candidate.target.modelId,
804
+ routeDecision: route.context.routeDecision,
805
+ attempts,
806
+ targetRank: candidate.targetRank
576
807
  });
577
808
  }
578
- return relayPromise;
579
- };
809
+ });
810
+ const agent = createAgent({
811
+ id: request.agentId,
812
+ model: route.model,
813
+ instructions: request.instructions,
814
+ tools: request.tools,
815
+ maxSteps: request.maxSteps,
816
+ temperature: request.temperature,
817
+ maxTokens: request.maxTokens,
818
+ reasoning: request.reasoning,
819
+ toolExecution: request.toolExecution,
820
+ toolApprovalPolicy: request.toolApprovalPolicy,
821
+ providerOptions: request.providerOptions,
822
+ policy: request.policy,
823
+ metadata: request.metadata,
824
+ store: request.store,
825
+ memory: request.memory,
826
+ onTelemetryEvent: request.onTelemetryEvent,
827
+ hookFailurePolicy: request.hookFailurePolicy
828
+ });
829
+ const streamResult = streamAgent(agent, createAgentRunInput(request));
580
830
  return {
581
- eventStream: (async function* () {
582
- const relay = await ensureRelay();
583
- for await (const event of relay.streamResult.eventStream) {
584
- yield event;
585
- }
586
- })(),
587
- textStream: (async function* () {
588
- const relay = await ensureRelay();
589
- for await (const chunk of relay.streamResult.textStream) {
590
- yield chunk;
591
- }
592
- })(),
593
- collect: async () => (await ensureRelay()).collect()
831
+ eventStream: streamResult.eventStream,
832
+ textStream: streamResult.textStream,
833
+ collect: async () => {
834
+ const result = await streamResult.collect();
835
+ return enrichAgentResult(targetForResult(route.context), route.context.attempts, route.context.routeDecision, route.context.startedAt, result);
836
+ }
594
837
  };
595
838
  }
596
839
  };