@vedmalex/ai-connect 0.8.0 → 0.10.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.
Files changed (38) hide show
  1. package/README.md +16 -4
  2. package/dist/browser/index.js +1030 -378
  3. package/dist/browser/index.js.map +3 -3
  4. package/dist/bun/index.js +1403 -535
  5. package/dist/bun/index.js.map +4 -4
  6. package/dist/bun/local.js +1354 -534
  7. package/dist/bun/local.js.map +4 -4
  8. package/dist/node/index.js +1403 -535
  9. package/dist/node/index.js.map +4 -4
  10. package/dist/node/local.js +1354 -534
  11. package/dist/node/local.js.map +4 -4
  12. package/dist/types/acp.d.ts.map +1 -1
  13. package/dist/types/cli-presets.d.ts.map +1 -1
  14. package/dist/types/cli.d.ts.map +1 -1
  15. package/dist/types/client.d.ts.map +1 -1
  16. package/dist/types/config.d.ts.map +1 -1
  17. package/dist/types/default-handlers.d.ts.map +1 -1
  18. package/dist/types/errors.d.ts +11 -0
  19. package/dist/types/errors.d.ts.map +1 -1
  20. package/dist/types/fanout.d.ts.map +1 -1
  21. package/dist/types/files.d.ts +47 -1
  22. package/dist/types/files.d.ts.map +1 -1
  23. package/dist/types/local-handlers.d.ts.map +1 -1
  24. package/dist/types/logging.d.ts +18 -1
  25. package/dist/types/logging.d.ts.map +1 -1
  26. package/dist/types/mock-gateway.d.ts +20 -0
  27. package/dist/types/mock-gateway.d.ts.map +1 -1
  28. package/dist/types/model-reference.d.ts +5 -2
  29. package/dist/types/model-reference.d.ts.map +1 -1
  30. package/dist/types/router.d.ts.map +1 -1
  31. package/dist/types/server-presets.d.ts +1 -1
  32. package/dist/types/server-presets.d.ts.map +1 -1
  33. package/dist/types/server.d.ts.map +1 -1
  34. package/dist/types/transport-runtime.d.ts +75 -0
  35. package/dist/types/transport-runtime.d.ts.map +1 -0
  36. package/dist/types/types.d.ts +40 -0
  37. package/dist/types/types.d.ts.map +1 -1
  38. package/package.json +1 -1
@@ -12,7 +12,10 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
12
12
  // pi takes file/image attachments as separate `@<path>` argv tokens (REQ-024);
13
13
  // {files} expands one `@<staged-path>` token per attachment. pi stages all
14
14
  // categories (PDFs as-is, no extraction guarantee).
15
- argsTemplate: ["--print", "--model", "{model}", "{files}", "{prompt}"],
15
+ // H10 (flag injection): the `--` end-of-options token precedes {files}/{prompt}
16
+ // — both are BARE positionals here — so an `@<path>` reference or a prompt that
17
+ // starts with `-` is parsed by pi as a positional, never as a flag.
18
+ argsTemplate: ["--print", "--model", "{model}", "--", "{files}", "{prompt}"],
16
19
  discovery: {
17
20
  via: "none"
18
21
  },
@@ -32,13 +35,22 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
32
35
  transportId: "claude-cli",
33
36
  options: {
34
37
  preset: "claude",
35
- argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "{prompt}"],
38
+ // H10 (flag injection): `--` precedes the trailing {prompt} positional so a
39
+ // prompt starting with `-` (e.g. "--dangerously-skip-permissions") is parsed
40
+ // by the claude CLI as a positional, not a flag.
41
+ argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "--", "{prompt}"],
36
42
  discovery: {
37
43
  via: "acp",
38
44
  acp: {
39
45
  transportId: "claude-code-acp"
40
46
  }
41
47
  },
48
+ // SENTINEL (documentation-only): the `__preset__:*` textPath/errorPath values
49
+ // are NOT resolved as live JSON paths. This `parser` block records which
50
+ // dedicated parser a built-in preset maps to (parseClaudeCli); dispatch for a
51
+ // preset route goes through the `cliOptions.preset` switch in cli.ts
52
+ // (parseCliResult), which only reads a parser config when a ROUTE sets its own
53
+ // `cli.parser`. See the mirrored note in cli.ts::parseCliResult.
42
54
  parser: {
43
55
  kind: "json",
44
56
  textPath: "__preset__:claude.result",
@@ -54,7 +66,11 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
54
66
  transportId: "openclaude-cli",
55
67
  options: {
56
68
  preset: "openclaude",
57
- argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "{prompt}"],
69
+ // H10 (flag injection): `--` precedes the trailing {prompt} positional (see
70
+ // the claude preset above for rationale).
71
+ argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "--", "{prompt}"],
72
+ // SENTINEL (documentation-only): see the claude preset note — `__preset__:*`
73
+ // paths are markers, not live JSON paths; dispatch is via the preset switch.
58
74
  parser: {
59
75
  kind: "json",
60
76
  textPath: "__preset__:claude.result",
@@ -73,6 +89,11 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
73
89
  // codex exec takes image attachments via `-i/--image <FILE>` flags
74
90
  // (REQ-024); {files} expands a `--image <staged-path>` pair per image.
75
91
  // Images only — a non-image attachment cleanly rejects pre-spawn.
92
+ // H10 (flag injection): `--` sits AFTER {files} and before the trailing
93
+ // {prompt} positional. Unlike pi, codex files are passed via the explicit
94
+ // `--image <path>` flag (the path is that flag's VALUE, not a positional), so
95
+ // `--` must NOT precede {files} or it would neutralize `--image`. It guards
96
+ // only the {prompt} positional against being parsed as a flag.
76
97
  argsTemplate: [
77
98
  "exec",
78
99
  "--json",
@@ -81,6 +102,7 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
81
102
  "--model",
82
103
  "{model}",
83
104
  "{files}",
105
+ "--",
84
106
  "{prompt}"
85
107
  ],
86
108
  discovery: {
@@ -94,6 +116,11 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
94
116
  perFileArgs: ["--image", "{path}"],
95
117
  categories: ["image"]
96
118
  },
119
+ // SENTINEL (documentation-only): the `__preset__:*` path values below are
120
+ // markers naming the dedicated parser this preset maps to (parseCodexCli), NOT
121
+ // live JSONL selectors. Dispatch for a preset route runs through the
122
+ // `cliOptions.preset` switch in cli.ts::parseCliResult; this block is only ever
123
+ // read as a live parser when a ROUTE overrides `cli.parser`.
97
124
  parser: {
98
125
  kind: "jsonl",
99
126
  text: {
@@ -130,6 +157,19 @@ var AiConnectError = class extends Error {
130
157
  this.code = code;
131
158
  this.details = details;
132
159
  }
160
+ /**
161
+ * `Error.prototype.message` is non-enumerable, so a bare `JSON.stringify(err)` silently drops
162
+ * it (LOW, review-report-seed.md). Provide an explicit projection so serializing an
163
+ * `AiConnectError` (logs, wide events, error-response bodies) always carries the message.
164
+ */
165
+ toJSON() {
166
+ return {
167
+ name: this.name,
168
+ code: this.code,
169
+ message: this.message,
170
+ details: this.details
171
+ };
172
+ }
133
173
  };
134
174
  function isAiConnectError(error) {
135
175
  return error instanceof AiConnectError;
@@ -160,6 +200,16 @@ function toAiConnectError(error, fallbackCode = "temporary_unavailable") {
160
200
  // src/config.ts
161
201
  var DEFAULT_OPERATIONS = ["text", "image", "file"];
162
202
  var DEFAULT_STRATEGY = "priority";
203
+ var CLI_TRANSPORT_ID_BY_PROVIDER = {
204
+ anthropic: "claude-cli",
205
+ openclaude: "openclaude-cli",
206
+ openai: "codex-cli",
207
+ pi: "pi-cli"
208
+ };
209
+ var ACP_TRANSPORT_ID_BY_PROVIDER = {
210
+ anthropic: "claude-code-acp",
211
+ openai: "codex-acp"
212
+ };
163
213
  var DEFAULT_FALLBACK_ACTIONS = {
164
214
  rate_limit: [
165
215
  "rotate-credential",
@@ -271,33 +321,64 @@ function assert(condition, message) {
271
321
  throw new AiConnectError("validation_error", message);
272
322
  }
273
323
  }
274
- function asPositiveInteger(value, fallback) {
324
+ function asPositiveInteger(value, fallback, fieldLabel) {
325
+ if (value === void 0) {
326
+ return fallback;
327
+ }
275
328
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
329
+ if (fieldLabel) {
330
+ console.warn(
331
+ `[ai-connect] ${fieldLabel} must be a positive finite number when provided; received ${JSON.stringify(value)}. Falling back to ${fallback}.`
332
+ );
333
+ }
276
334
  return fallback;
277
335
  }
278
336
  return Math.floor(value);
279
337
  }
280
- function asNonNegativeInteger(value, fallback) {
338
+ function asNonNegativeInteger(value, fallback, fieldLabel) {
339
+ if (value === void 0) {
340
+ return fallback;
341
+ }
281
342
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
343
+ if (fieldLabel) {
344
+ console.warn(
345
+ `[ai-connect] ${fieldLabel} must be a non-negative finite number when provided; received ${JSON.stringify(value)}. Falling back to ${fallback}.`
346
+ );
347
+ }
282
348
  return fallback;
283
349
  }
284
350
  return Math.floor(value);
285
351
  }
286
- function asOptionalContextWindow(value) {
352
+ function asOptionalContextWindow(value, fieldLabel) {
353
+ if (value === void 0) {
354
+ return void 0;
355
+ }
287
356
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
357
+ if (fieldLabel) {
358
+ console.warn(
359
+ `[ai-connect] ${fieldLabel} must be a positive finite number when provided; received ${JSON.stringify(value)}. Ignoring.`
360
+ );
361
+ }
288
362
  return void 0;
289
363
  }
290
364
  return Math.floor(value);
291
365
  }
292
- function normalizeModelInput(input, accountContextWindow) {
366
+ function normalizeModelInput(input, accountContextWindow, accountId, providerId) {
293
367
  if (typeof input === "string") {
294
368
  return {
295
369
  id: input.trim(),
296
370
  ...accountContextWindow !== void 0 ? { contextWindow: accountContextWindow } : {}
297
371
  };
298
372
  }
373
+ assert(
374
+ typeof input.id === "string" && input.id.trim().length > 0,
375
+ `Account "${accountId}" for provider "${providerId}" declared a model entry with a missing or empty "id".`
376
+ );
299
377
  const id = input.id.trim();
300
- const override = asOptionalContextWindow(input.contextWindow);
378
+ const override = asOptionalContextWindow(
379
+ input.contextWindow,
380
+ `Model "${id}" (account "${accountId}", provider "${providerId}") contextWindow`
381
+ );
301
382
  const contextWindow = override ?? accountContextWindow;
302
383
  return {
303
384
  id,
@@ -308,7 +389,8 @@ function asFanoutBound(value) {
308
389
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
309
390
  return Number.POSITIVE_INFINITY;
310
391
  }
311
- return Math.floor(value);
392
+ const floored = Math.floor(value);
393
+ return floored <= 0 ? Number.POSITIVE_INFINITY : floored;
312
394
  }
313
395
  function normalizeFanoutPolicy(policy) {
314
396
  return {
@@ -534,7 +616,7 @@ function normalizeTransport(providerId, input) {
534
616
  };
535
617
  }
536
618
  if (descriptor.kind === "cli") {
537
- const inferredId2 = providerId === "anthropic" ? "claude-cli" : providerId === "openclaude" ? "openclaude-cli" : providerId === "openai" ? "codex-cli" : providerId === "pi" ? "pi-cli" : "cli";
619
+ const inferredId2 = CLI_TRANSPORT_ID_BY_PROVIDER[providerId] ?? "cli";
538
620
  return {
539
621
  kind: "cli",
540
622
  id: descriptor.id?.trim() || inferredId2,
@@ -551,7 +633,7 @@ function normalizeTransport(providerId, input) {
551
633
  ...descriptor.baseUrl?.trim() ? { baseUrl: descriptor.baseUrl.trim() } : {}
552
634
  };
553
635
  }
554
- const inferredId = providerId === "anthropic" ? "claude-code-acp" : providerId === "openai" ? "codex-acp" : "acp";
636
+ const inferredId = ACP_TRANSPORT_ID_BY_PROVIDER[providerId] ?? "acp";
555
637
  const normalizedLaunch = descriptor.launch ? (() => {
556
638
  const contextMode = descriptor.launch?.contextMode ?? "workspace";
557
639
  const skillsMode = descriptor.launch?.skillsMode ?? "default";
@@ -648,20 +730,26 @@ function defaultCapabilities(transport, runtime) {
648
730
  }
649
731
  function normalizeCredential(providerId, accountId, transport, input, index) {
650
732
  const id = input.id?.trim() || `${accountId}-credential-${index + 1}`;
651
- const source = input.source?.trim() || (input.apiKey ? "literal" : input.apiKeyEnv ? "env" : transport.kind === "api" ? "env" : "session");
733
+ const trimmedApiKey = input.apiKey?.trim();
734
+ const trimmedApiKeyEnv = input.apiKeyEnv?.trim();
735
+ const source = input.source?.trim() || (trimmedApiKey ? "literal" : trimmedApiKeyEnv ? "env" : transport.kind === "api" ? "env" : "session");
652
736
  if (transport.kind === "api") {
653
737
  assert(
654
- Boolean(input.apiKey?.trim()) || Boolean(input.apiKeyEnv?.trim()),
738
+ Boolean(trimmedApiKey) || Boolean(trimmedApiKeyEnv),
655
739
  `API account "${accountId}" for provider "${providerId}" requires apiKey or apiKeyEnv credentials.`
656
740
  );
657
741
  }
658
742
  return {
659
743
  id,
660
744
  source,
661
- weight: asPositiveInteger(input.weight, 1),
745
+ weight: asPositiveInteger(
746
+ input.weight,
747
+ 1,
748
+ `Credential "${id}" (account "${accountId}", provider "${providerId}") weight`
749
+ ),
662
750
  metadata: input.metadata ?? {},
663
- ...input.apiKey?.trim() ? { apiKey: input.apiKey.trim() } : {},
664
- ...input.apiKeyEnv?.trim() ? { apiKeyEnv: input.apiKeyEnv.trim() } : {},
751
+ ...trimmedApiKey ? { apiKey: trimmedApiKey } : {},
752
+ ...trimmedApiKeyEnv ? { apiKeyEnv: trimmedApiKeyEnv } : {},
665
753
  ...input.apiKeyDelimiter ? { apiKeyDelimiter: input.apiKeyDelimiter } : {}
666
754
  };
667
755
  }
@@ -695,6 +783,31 @@ function buildRouteId(route) {
695
783
  slugify(route.model)
696
784
  ].join(":");
697
785
  }
786
+ function assertRouteIdUniqueness(routes) {
787
+ const byId = /* @__PURE__ */ new Map();
788
+ for (const route of routes) {
789
+ const group = byId.get(route.id);
790
+ if (group) {
791
+ group.push(route);
792
+ } else {
793
+ byId.set(route.id, [route]);
794
+ }
795
+ }
796
+ for (const [id, group] of byId) {
797
+ if (group.length <= 1) {
798
+ continue;
799
+ }
800
+ const distinctModels = [...new Set(group.map((route) => route.model))];
801
+ if (distinctModels.length <= 1) {
802
+ continue;
803
+ }
804
+ const first = group[0];
805
+ assert(
806
+ false,
807
+ `Route id "${id}" is ambiguous: model(s) ${distinctModels.map((model) => `"${model}"`).join(", ")} on account "${first.accountId}" (provider "${first.provider}") slugify to the same route id. Rename one of the models so they no longer collide after slugification.`
808
+ );
809
+ }
810
+ }
698
811
  function routeAliases(route) {
699
812
  return [
700
813
  route.provider,
@@ -819,7 +932,8 @@ function defineConfig(input) {
819
932
  const transport = normalizeTransport(providerId, accountInput.transport);
820
933
  const runtime = normalizeRuntime(transport, accountInput.runtime);
821
934
  const accountContextWindow = asOptionalContextWindow(
822
- accountInput.contextWindow
935
+ accountInput.contextWindow,
936
+ `Account "${accountId}" (provider "${providerId}") contextWindow`
823
937
  );
824
938
  const accountModelAllowlistMode = accountInput.modelAllowlistMode ?? "strict";
825
939
  const accountContextMode = accountInput.contextMode ?? "workspace";
@@ -829,7 +943,9 @@ function defineConfig(input) {
829
943
  Array.isArray(accountInput.models) && accountInput.models.length > 0,
830
944
  `Account "${accountId}" for provider "${providerId}" must declare at least one model.`
831
945
  );
832
- const models = accountInput.models.map((model) => normalizeModelInput(model, accountContextWindow)).filter((model) => model.id.length > 0);
946
+ const models = accountInput.models.map(
947
+ (model) => normalizeModelInput(model, accountContextWindow, accountId, providerId)
948
+ ).filter((model) => model.id.length > 0);
833
949
  assert(
834
950
  models.length > 0,
835
951
  `Account "${accountId}" for provider "${providerId}" must declare at least one model.`
@@ -859,8 +975,16 @@ function defineConfig(input) {
859
975
  credentials,
860
976
  capabilities,
861
977
  verify,
862
- weight: asPositiveInteger(accountInput.weight, 1),
863
- priority: asNonNegativeInteger(accountInput.priority, accountIndex),
978
+ weight: asPositiveInteger(
979
+ accountInput.weight,
980
+ 1,
981
+ `Account "${accountId}" (provider "${providerId}") weight`
982
+ ),
983
+ priority: asNonNegativeInteger(
984
+ accountInput.priority,
985
+ accountIndex,
986
+ `Account "${accountId}" (provider "${providerId}") priority`
987
+ ),
864
988
  enabled: accountInput.enabled ?? true
865
989
  };
866
990
  accounts.push(normalizedAccount);
@@ -907,6 +1031,7 @@ function defineConfig(input) {
907
1031
  };
908
1032
  }
909
1033
  assert(routes.length > 0, "At least one enabled route must be produced.");
1034
+ assertRouteIdUniqueness(routes);
910
1035
  finalizeRotationCapabilities(routes);
911
1036
  const strategy = input.routing?.strategy ?? DEFAULT_STRATEGY;
912
1037
  const resolutionInput = input.routing?.resolution;
@@ -980,6 +1105,10 @@ function resolveRouteSelectors(selectors, routes, policy) {
980
1105
  }
981
1106
 
982
1107
  // src/fanout.ts
1108
+ function abortedError() {
1109
+ return new AiConnectError("aborted", "Operation aborted.");
1110
+ }
1111
+ var MAX_DELAY_MS = 24 * 60 * 60 * 1e3;
983
1112
  function createFanoutLimiter(policy, runtime) {
984
1113
  const now = () => runtime.now?.() ?? Date.now();
985
1114
  const { maxConcurrency, requestsPerSecond, maxCalls } = policy;
@@ -1043,10 +1172,10 @@ function createFanoutLimiter(policy, runtime) {
1043
1172
  if (index >= 0) {
1044
1173
  concurrencyQueue.splice(index, 1);
1045
1174
  }
1046
- rejectWaiter(waiter, new AiConnectError("aborted", "Operation aborted."));
1175
+ rejectWaiter(waiter, abortedError());
1047
1176
  };
1048
1177
  if (signal.aborted) {
1049
- reject(new AiConnectError("aborted", "Operation aborted."));
1178
+ reject(abortedError());
1050
1179
  return;
1051
1180
  }
1052
1181
  waiter.signal = signal;
@@ -1062,7 +1191,7 @@ function createFanoutLimiter(policy, runtime) {
1062
1191
  }
1063
1192
  while (true) {
1064
1193
  if (signal?.aborted) {
1065
- throw new AiConnectError("aborted", "Operation aborted.");
1194
+ throw abortedError();
1066
1195
  }
1067
1196
  refill();
1068
1197
  if (tokens >= 1) {
@@ -1074,6 +1203,7 @@ function createFanoutLimiter(policy, runtime) {
1074
1203
  }
1075
1204
  }
1076
1205
  function delay(ms, signal) {
1206
+ const safeMs = Number.isFinite(ms) && ms >= 0 ? Math.min(ms, MAX_DELAY_MS) : MAX_DELAY_MS;
1077
1207
  return new Promise((resolve, reject) => {
1078
1208
  let cancelTimer;
1079
1209
  const onAbort = () => {
@@ -1081,11 +1211,11 @@ function createFanoutLimiter(policy, runtime) {
1081
1211
  if (signal) {
1082
1212
  signal.removeEventListener("abort", onAbort);
1083
1213
  }
1084
- reject(new AiConnectError("aborted", "Operation aborted."));
1214
+ reject(abortedError());
1085
1215
  };
1086
1216
  if (signal) {
1087
1217
  if (signal.aborted) {
1088
- reject(new AiConnectError("aborted", "Operation aborted."));
1218
+ reject(abortedError());
1089
1219
  return;
1090
1220
  }
1091
1221
  signal.addEventListener("abort", onAbort, { once: true });
@@ -1097,9 +1227,9 @@ function createFanoutLimiter(policy, runtime) {
1097
1227
  resolve();
1098
1228
  };
1099
1229
  if (runtime.setTimer) {
1100
- cancelTimer = runtime.setTimer(ms, done);
1230
+ cancelTimer = runtime.setTimer(safeMs, done);
1101
1231
  } else {
1102
- const handle = setTimeout(done, ms);
1232
+ const handle = setTimeout(done, safeMs);
1103
1233
  cancelTimer = () => clearTimeout(handle);
1104
1234
  }
1105
1235
  });
@@ -1113,8 +1243,13 @@ function createFanoutLimiter(policy, runtime) {
1113
1243
  );
1114
1244
  }
1115
1245
  callsStarted += 1;
1116
- await awaitRateToken(signal);
1117
- await awaitConcurrencySlot(signal);
1246
+ try {
1247
+ await awaitRateToken(signal);
1248
+ await awaitConcurrencySlot(signal);
1249
+ } catch (error) {
1250
+ callsStarted -= 1;
1251
+ throw error;
1252
+ }
1118
1253
  let released = false;
1119
1254
  return () => {
1120
1255
  if (released) {
@@ -1198,19 +1333,54 @@ function isRemoteUrl(value) {
1198
1333
  return false;
1199
1334
  }
1200
1335
  }
1336
+ function mimeTypeAndExtensionFromMediaType(mediaTypeAndParams, fallbackMimeType, fallbackExtension) {
1337
+ const mimeType = mediaTypeAndParams.split(";")[0]?.trim() || fallbackMimeType;
1338
+ const extension2 = mimeType.split("/")[1] ?? fallbackExtension;
1339
+ return { mimeType, extension: extension2 };
1340
+ }
1201
1341
  function parseDataUrl(value) {
1202
- const match = /^data:([^,]*?);base64,(.*)$/i.exec(value);
1203
- if (!match) {
1204
- return null;
1342
+ const base64Match = /^data:([^,]*?);base64,(.*)$/i.exec(value);
1343
+ if (base64Match) {
1344
+ const { mimeType, extension: extension2 } = mimeTypeAndExtensionFromMediaType(
1345
+ base64Match[1] ?? "",
1346
+ "application/octet-stream",
1347
+ "bin"
1348
+ );
1349
+ return {
1350
+ mimeType,
1351
+ extension: extension2,
1352
+ data: base64Match[2] ?? ""
1353
+ };
1205
1354
  }
1206
- const mediaTypeAndParams = match[1] ?? "";
1207
- const mimeType = mediaTypeAndParams.split(";")[0]?.trim() || "application/octet-stream";
1208
- const extension2 = mimeType.split("/")[1] ?? "bin";
1209
- return {
1210
- mimeType,
1211
- extension: extension2,
1212
- data: match[2] ?? ""
1213
- };
1355
+ const plainMatch = /^data:([^,]*?),(.*)$/i.exec(value);
1356
+ if (plainMatch) {
1357
+ const { mimeType, extension: extension2 } = mimeTypeAndExtensionFromMediaType(
1358
+ plainMatch[1] ?? "",
1359
+ "text/plain",
1360
+ "txt"
1361
+ );
1362
+ let decoded;
1363
+ try {
1364
+ decoded = decodeURIComponent(plainMatch[2] ?? "");
1365
+ } catch {
1366
+ throw new AiConnectError(
1367
+ "validation_error",
1368
+ "unsupported data URL encoding"
1369
+ );
1370
+ }
1371
+ return {
1372
+ mimeType,
1373
+ extension: extension2,
1374
+ data: encodeBase64(new TextEncoder().encode(decoded))
1375
+ };
1376
+ }
1377
+ if (/^data:/i.test(value)) {
1378
+ throw new AiConnectError(
1379
+ "validation_error",
1380
+ "unsupported data URL encoding"
1381
+ );
1382
+ }
1383
+ return null;
1214
1384
  }
1215
1385
  function mimeTypeFromName(name) {
1216
1386
  const ext = extension(name);
@@ -1424,9 +1594,61 @@ async function materializePortableFile(file) {
1424
1594
  payload.dataUrl = `data:${mimeType};base64,${base64}`;
1425
1595
  }
1426
1596
  }
1597
+ if (category === "other") {
1598
+ const base64 = await portableFileToBase64(file);
1599
+ if (base64 !== void 0) {
1600
+ payload.base64 = base64;
1601
+ payload.dataUrl = `data:${mimeType};base64,${base64}`;
1602
+ } else if (!uri && !providerFileId) {
1603
+ const droppedReason = `Portable file "${file.name}" (category "other", kind "${file.kind}") has no decodable byte source and no remote uri/providerFileId; its content was dropped and this payload carries metadata only.`;
1604
+ payload.droppedReason = droppedReason;
1605
+ payload.warnings = [...payload.warnings ?? [], droppedReason];
1606
+ }
1607
+ }
1427
1608
  return payload;
1428
1609
  }
1429
- function preparePortableFile(input) {
1610
+ function collapsePathSegments(value) {
1611
+ const normalized = value.replaceAll("\\", "/");
1612
+ const isAbsolute = normalized.startsWith("/");
1613
+ const segments = normalized.split("/");
1614
+ const resolved = [];
1615
+ for (const segment of segments) {
1616
+ if (segment === "" || segment === ".") {
1617
+ continue;
1618
+ }
1619
+ if (segment === "..") {
1620
+ if (resolved.length > 0 && resolved[resolved.length - 1] !== "..") {
1621
+ resolved.pop();
1622
+ } else if (!isAbsolute) {
1623
+ resolved.push("..");
1624
+ }
1625
+ continue;
1626
+ }
1627
+ resolved.push(segment);
1628
+ }
1629
+ return (isAbsolute ? "/" : "") + resolved.join("/");
1630
+ }
1631
+ function applyPathGuard(rawPath, guard) {
1632
+ if (!guard) {
1633
+ return rawPath;
1634
+ }
1635
+ if (guard === "basename") {
1636
+ return basename(rawPath);
1637
+ }
1638
+ const resolved = collapsePathSegments(rawPath);
1639
+ const withinAllowedRoot = guard.allowedRoots.some((root) => {
1640
+ const resolvedRoot = collapsePathSegments(root);
1641
+ return resolved === resolvedRoot || resolved.startsWith(`${resolvedRoot}/`);
1642
+ });
1643
+ if (!withinAllowedRoot) {
1644
+ throw new AiConnectError(
1645
+ "validation_error",
1646
+ `Path "${rawPath}" is outside the configured allowedRoots for portable file attachments.`
1647
+ );
1648
+ }
1649
+ return rawPath;
1650
+ }
1651
+ function preparePortableFile(input, options) {
1430
1652
  if (isPortableFile(input)) {
1431
1653
  return input;
1432
1654
  }
@@ -1452,11 +1674,12 @@ function preparePortableFile(input) {
1452
1674
  }
1453
1675
  };
1454
1676
  }
1677
+ const guardedPath = applyPathGuard(input, options?.pathGuard);
1455
1678
  return {
1456
- id: `path:${input}`,
1679
+ id: `path:${guardedPath}`,
1457
1680
  kind: "path",
1458
- name: basename(input),
1459
- source: input
1681
+ name: basename(guardedPath),
1682
+ source: guardedPath
1460
1683
  };
1461
1684
  }
1462
1685
  if (isRemoteReference(input)) {
@@ -1497,17 +1720,76 @@ function preparePortableFile(input) {
1497
1720
  }
1498
1721
 
1499
1722
  // src/logging.ts
1723
+ var REDACT_KEYS = /* @__PURE__ */ new Set([
1724
+ "apikey",
1725
+ "authorization",
1726
+ "password",
1727
+ "token",
1728
+ "x-api-key",
1729
+ "x-goog-api-key"
1730
+ ]);
1731
+ var REDACTED = "[REDACTED]";
1732
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1733
+ var SECRET_PREFIX_RE = /^(sk-|sk-ant-|xox[baprs]-|ghp_|gho_|AIza)/;
1734
+ function looksLikeSecretValue(value) {
1735
+ if (UUID_RE.test(value)) {
1736
+ return false;
1737
+ }
1738
+ if (/^Bearer\s+/i.test(value)) {
1739
+ return true;
1740
+ }
1741
+ if (SECRET_PREFIX_RE.test(value)) {
1742
+ return true;
1743
+ }
1744
+ return /^[A-Za-z0-9+/=_]{40,}$/.test(value);
1745
+ }
1746
+ function redactNode(node) {
1747
+ if (node === null || node === void 0) {
1748
+ return node;
1749
+ }
1750
+ if (typeof node === "string") {
1751
+ return looksLikeSecretValue(node) ? REDACTED : node;
1752
+ }
1753
+ if (typeof node !== "object") {
1754
+ return node;
1755
+ }
1756
+ if (Array.isArray(node)) {
1757
+ return node.map((item) => redactNode(item));
1758
+ }
1759
+ const result = {};
1760
+ for (const [key, value] of Object.entries(node)) {
1761
+ result[key] = REDACT_KEYS.has(key.toLowerCase()) ? REDACTED : redactNode(value);
1762
+ }
1763
+ return result;
1764
+ }
1765
+ function redactEvent(event) {
1766
+ return redactNode(event);
1767
+ }
1768
+ function redactBaseUrl(url) {
1769
+ try {
1770
+ const parsed = new URL(url);
1771
+ parsed.username = "";
1772
+ parsed.password = "";
1773
+ parsed.search = "";
1774
+ parsed.hash = "";
1775
+ return parsed.toString();
1776
+ } catch {
1777
+ const withoutHash = url.split("#")[0] ?? url;
1778
+ return withoutHash.split("?")[0] ?? withoutHash;
1779
+ }
1780
+ }
1500
1781
  function createConsoleWideEventLogger(options = {}) {
1501
1782
  const write = options.write ?? ((line) => {
1502
1783
  console.info(line);
1503
1784
  });
1504
1785
  return (event) => {
1786
+ const redacted = redactEvent(event);
1505
1787
  write(
1506
- options.pretty ? JSON.stringify(event, null, 2) : JSON.stringify(event)
1788
+ options.pretty ? JSON.stringify(redacted, null, 2) : JSON.stringify(redacted)
1507
1789
  );
1508
1790
  };
1509
1791
  }
1510
- async function shouldEmitWideEvent(event, options = {}) {
1792
+ async function shouldEmitWideEvent(event, options = {}, random = Math.random) {
1511
1793
  const sampleRate = options.sampleRate ?? 1;
1512
1794
  if ((options.keepErrors ?? true) && event.outcome === "error") {
1513
1795
  return true;
@@ -1541,7 +1823,7 @@ async function shouldEmitWideEvent(event, options = {}) {
1541
1823
  if (sampleRate >= 1) {
1542
1824
  return true;
1543
1825
  }
1544
- return Math.random() < sampleRate;
1826
+ return random() < sampleRate;
1545
1827
  }
1546
1828
 
1547
1829
  // src/router.ts
@@ -1606,6 +1888,19 @@ function buildWeightedOrder(routes) {
1606
1888
  }
1607
1889
  return expanded;
1608
1890
  }
1891
+ var ROUTING_STRATEGIES = {
1892
+ priority: (ordered) => ordered,
1893
+ failover: (ordered) => ordered,
1894
+ "round-robin": (ordered, cursor, cursorKey) => {
1895
+ const index = cursor(cursorKey, ordered.length);
1896
+ return rotate(ordered, index);
1897
+ },
1898
+ "weighted-round-robin": (ordered, cursor, cursorKey) => {
1899
+ const weighted = buildWeightedOrder(ordered);
1900
+ const index = cursor(cursorKey, weighted.length);
1901
+ return unique(rotate(weighted, index));
1902
+ }
1903
+ };
1609
1904
  var RouteRegistry = class {
1610
1905
  config;
1611
1906
  runtime;
@@ -1676,14 +1971,21 @@ var RouteRegistry = class {
1676
1971
  const routeIds = request.routeHints?.pool?.length ? resolveRouteSelectors(request.routeHints.pool, this.config.routes) : operationRouting.pool;
1677
1972
  const excludedIds = new Set(request.routeHints?.excludeRouteIds ?? []);
1678
1973
  const requestedModel = request.routeHints?.model;
1679
- const available = routeIds.map((routeId) => this.#routeMap.get(routeId)).filter((route) => Boolean(route)).filter((route) => !excludedIds.has(route.id)).filter((route) => runtimeEligible(route, this.runtime)).filter((route) => capabilityMatch(route, request.routeHints)).filter((route) => {
1680
- const state = this.getHealth(route).state;
1681
- return state !== "cooling_down" && state !== "unavailable";
1682
- }).map((route) => applyRequestedModel(route, requestedModel)).filter((route) => Boolean(route));
1974
+ const eligible = routeIds.map((routeId) => this.#routeMap.get(routeId)).filter((route) => Boolean(route)).filter((route) => !excludedIds.has(route.id)).filter((route) => runtimeEligible(route, this.runtime)).filter((route) => capabilityMatch(route, request.routeHints)).map((route) => applyRequestedModel(route, requestedModel)).filter((route) => Boolean(route));
1975
+ const strategy = request.routeHints?.strategy ?? operationRouting.strategy;
1976
+ const cursorKey = `${operation}:${strategy}:${eligible.map((route) => route.id).join("|")}`;
1977
+ const healthyIds = new Set(
1978
+ eligible.filter((route) => {
1979
+ const state = this.getHealth(route).state;
1980
+ return state !== "cooling_down" && state !== "unavailable";
1981
+ }).map((route) => route.id)
1982
+ );
1683
1983
  return this.#order(
1684
- request.routeHints?.strategy ?? operationRouting.strategy,
1685
- available,
1686
- request.routeHints?.preferredProviders
1984
+ strategy,
1985
+ eligible,
1986
+ healthyIds,
1987
+ request.routeHints?.preferredProviders,
1988
+ cursorKey
1687
1989
  );
1688
1990
  }
1689
1991
  recordSuccess(route) {
@@ -1716,20 +2018,12 @@ var RouteRegistry = class {
1716
2018
  failureCount
1717
2019
  });
1718
2020
  }
1719
- #order(strategy, routes, preferredProviders) {
1720
- const sorted = baseSort(routes, preferredProviders);
1721
- const cursorKey = `${strategy}:${sorted.map((route) => route.id).join("|")}`;
1722
- const ordered = this.config.routing.shuffleOnInit ? this.#shuffle(cursorKey, sorted) : sorted;
1723
- if (strategy === "priority" || strategy === "failover") {
1724
- return ordered;
1725
- }
1726
- if (strategy === "round-robin") {
1727
- const index2 = this.#cursor(cursorKey, ordered.length);
1728
- return rotate(ordered, index2);
1729
- }
1730
- const weighted = buildWeightedOrder(ordered);
1731
- const index = this.#cursor(cursorKey, weighted.length);
1732
- return unique(rotate(weighted, index));
2021
+ #order(strategy, eligible, healthyIds, preferredProviders, cursorKey) {
2022
+ const sorted = baseSort(eligible, preferredProviders);
2023
+ const orderedFull = this.config.routing.shuffleOnInit ? this.#shuffle(cursorKey, sorted) : sorted;
2024
+ const ordered = orderedFull.filter((route) => healthyIds.has(route.id));
2025
+ const strategyFn = ROUTING_STRATEGIES[strategy];
2026
+ return strategyFn(ordered, (key, length) => this.#cursor(key, length), cursorKey);
1733
2027
  }
1734
2028
  #shuffle(key, routes) {
1735
2029
  const cached = this.#shuffles.get(key);
@@ -1833,8 +2127,22 @@ var MODEL_REFERENCE = {
1833
2127
  "gemini-1.5-flash": { key: "gemini-1.5-flash", contextLength: 1048576 },
1834
2128
  "gemini-1.5-pro": { key: "gemini-1.5-pro", contextLength: 2097152 }
1835
2129
  };
2130
+ var REASONING_EFFORT_SUFFIXES = /* @__PURE__ */ new Set([
2131
+ "minimal",
2132
+ "low",
2133
+ "medium",
2134
+ "high",
2135
+ "xhigh"
2136
+ ]);
1836
2137
  function normalizeModelKey(model) {
1837
2138
  let key = model.trim().toLowerCase();
2139
+ const lastSlashIndex = key.lastIndexOf("/");
2140
+ if (lastSlashIndex > 0 && lastSlashIndex < key.length - 1) {
2141
+ const tail = key.slice(lastSlashIndex + 1);
2142
+ if (REASONING_EFFORT_SUFFIXES.has(tail)) {
2143
+ key = key.slice(0, lastSlashIndex);
2144
+ }
2145
+ }
1838
2146
  const slashIndex = key.indexOf("/");
1839
2147
  if (slashIndex > 0) {
1840
2148
  key = key.slice(slashIndex + 1);
@@ -1842,6 +2150,7 @@ function normalizeModelKey(model) {
1842
2150
  key = key.replace(/:(?:free|beta)$/i, "");
1843
2151
  return key.trim();
1844
2152
  }
2153
+ var MIN_SUBSTRING_MATCH_KEY_LENGTH = 4;
1845
2154
  function lookupModelRef(model) {
1846
2155
  const normalized = normalizeModelKey(model);
1847
2156
  if (!normalized) {
@@ -1864,7 +2173,7 @@ function lookupModelRef(model) {
1864
2173
  }
1865
2174
  let substringWinner;
1866
2175
  for (const [key, entry] of Object.entries(MODEL_REFERENCE)) {
1867
- if (normalized.includes(key)) {
2176
+ if (key.length >= MIN_SUBSTRING_MATCH_KEY_LENGTH && normalized.includes(key)) {
1868
2177
  if (!substringWinner || key.length > substringWinner.key.length) {
1869
2178
  substringWinner = entry;
1870
2179
  }
@@ -2096,6 +2405,14 @@ var DEFAULT_LOGGING_SERVICE = "ai-connect";
2096
2405
  function splitCredentialValues(value, delimiter) {
2097
2406
  return value.split(delimiter).map((item) => item.trim()).filter(Boolean);
2098
2407
  }
2408
+ var ClientToolExecutionError = class extends Error {
2409
+ aiError;
2410
+ constructor(aiError) {
2411
+ super(aiError.message);
2412
+ this.name = "ClientToolExecutionError";
2413
+ this.aiError = aiError;
2414
+ }
2415
+ };
2099
2416
  function materializeRuntimeConfig(config, runtime) {
2100
2417
  const routeExpansion = /* @__PURE__ */ new Map();
2101
2418
  const routes = [];
@@ -2110,13 +2427,14 @@ function materializeRuntimeConfig(config, runtime) {
2110
2427
  continue;
2111
2428
  }
2112
2429
  const expandedIds = [];
2430
+ const expandedWeight = Math.max(1, Math.floor(route.weight / values.length));
2113
2431
  values.forEach((apiKey, index) => {
2114
2432
  const credentialId = `${route.credentialId}#${index + 1}`;
2115
2433
  const expandedRoute = {
2116
2434
  ...route,
2117
2435
  id: `${route.id}#${index + 1}`,
2118
2436
  credentialId,
2119
- weight: 1,
2437
+ weight: expandedWeight,
2120
2438
  credential: {
2121
2439
  ...route.credential,
2122
2440
  id: credentialId,
@@ -2157,6 +2475,27 @@ async function sleep(delayMs) {
2157
2475
  }
2158
2476
  await new Promise((resolve) => setTimeout(resolve, delayMs));
2159
2477
  }
2478
+ async function runWithAbortRace(handlerPromise, abort) {
2479
+ if (abort.signal.aborted) {
2480
+ void Promise.resolve(handlerPromise).catch(() => {
2481
+ });
2482
+ throw mapAbortError(abort.reason);
2483
+ }
2484
+ let onAbort;
2485
+ const abortPromise = new Promise((_, reject) => {
2486
+ onAbort = () => reject(mapAbortError(abort.reason));
2487
+ abort.signal.addEventListener("abort", onAbort, { once: true });
2488
+ });
2489
+ try {
2490
+ return await Promise.race([handlerPromise, abortPromise]);
2491
+ } finally {
2492
+ if (onAbort) {
2493
+ abort.signal.removeEventListener("abort", onAbort);
2494
+ }
2495
+ void Promise.resolve(handlerPromise).catch(() => {
2496
+ });
2497
+ }
2498
+ }
2160
2499
  function normalizeWorkingDirectory(value) {
2161
2500
  if (value === void 0) {
2162
2501
  return void 0;
@@ -2198,6 +2537,12 @@ function normalizeClientToolRegistry(tools) {
2198
2537
  const registry = /* @__PURE__ */ new Map();
2199
2538
  for (const tool of tools ?? []) {
2200
2539
  const normalized = normalizeClientToolDefinition(tool);
2540
+ if (registry.has(normalized.function.name)) {
2541
+ throw new AiConnectError(
2542
+ "validation_error",
2543
+ `Duplicate client tool name "${normalized.function.name}" registered on this client.`
2544
+ );
2545
+ }
2201
2546
  registry.set(normalized.function.name, normalized);
2202
2547
  }
2203
2548
  return registry;
@@ -2223,10 +2568,22 @@ function resolveClientToolSelections(selections, registry) {
2223
2568
  `No client tool named "${name}" is registered on this client.`
2224
2569
  );
2225
2570
  }
2571
+ if (resolved.has(name)) {
2572
+ throw new AiConnectError(
2573
+ "validation_error",
2574
+ `Duplicate client tool "${name}" in the clientTools selection.`
2575
+ );
2576
+ }
2226
2577
  resolved.set(name, registered);
2227
2578
  continue;
2228
2579
  }
2229
2580
  const normalized = normalizeClientToolDefinition(selection);
2581
+ if (resolved.has(normalized.function.name)) {
2582
+ throw new AiConnectError(
2583
+ "validation_error",
2584
+ `Duplicate client tool "${normalized.function.name}" in the clientTools selection.`
2585
+ );
2586
+ }
2230
2587
  resolved.set(normalized.function.name, normalized);
2231
2588
  }
2232
2589
  return Array.from(resolved.values());
@@ -2265,6 +2622,73 @@ function normalizeToolResult(result) {
2265
2622
  ...result.isError !== void 0 ? { isError: result.isError } : {}
2266
2623
  };
2267
2624
  }
2625
+ var MAX_CLIENT_TOOL_LOOP_DEPTH = 8;
2626
+ async function* executeToolCallsRound(params) {
2627
+ const { toolCalls, registry, route, request, runtime, messages, abort } = params;
2628
+ const assistantMessage = {
2629
+ role: "assistant",
2630
+ content: params.assistantText,
2631
+ toolCalls
2632
+ };
2633
+ const toolMessages = [];
2634
+ for (const toolCall of toolCalls) {
2635
+ const tool = registry.get(toolCall.name);
2636
+ if (!tool) {
2637
+ throw new ClientToolExecutionError(
2638
+ new AiConnectError(
2639
+ "validation_error",
2640
+ `No client tool handler is registered for "${toolCall.name}".`
2641
+ )
2642
+ );
2643
+ }
2644
+ if (abort.signal.aborted) {
2645
+ throw mapAbortError(abort.reason);
2646
+ }
2647
+ const startEvent = params.onToolStart?.(toolCall);
2648
+ if (startEvent) {
2649
+ yield startEvent;
2650
+ }
2651
+ let execution;
2652
+ try {
2653
+ execution = normalizeToolResult(
2654
+ await tool.execute(toolCall.arguments, {
2655
+ route,
2656
+ request,
2657
+ runtime,
2658
+ toolCall,
2659
+ messages,
2660
+ ...request.workingDirectory ? { workingDirectory: request.workingDirectory } : {}
2661
+ })
2662
+ );
2663
+ } catch (error) {
2664
+ if (abort.signal.aborted) {
2665
+ throw mapAbortError(abort.reason);
2666
+ }
2667
+ throw new ClientToolExecutionError(
2668
+ new AiConnectError(
2669
+ "validation_error",
2670
+ `Client tool "${tool.function.name}" threw during execution: ${error instanceof Error ? error.message : String(error)}`,
2671
+ { cause: error }
2672
+ )
2673
+ );
2674
+ }
2675
+ toolMessages.push({
2676
+ role: "tool",
2677
+ content: execution.content,
2678
+ toolName: tool.function.name,
2679
+ toolCallId: toolCall.id,
2680
+ ...execution.data !== void 0 ? { data: execution.data } : {},
2681
+ ...execution.isError !== void 0 ? { isError: execution.isError } : {}
2682
+ });
2683
+ const finishEvent = params.onToolFinish?.(toolCall, {
2684
+ ...execution.isError !== void 0 ? { isError: execution.isError } : {}
2685
+ });
2686
+ if (finishEvent) {
2687
+ yield finishEvent;
2688
+ }
2689
+ }
2690
+ return { assistantMessage, toolMessages };
2691
+ }
2268
2692
  function sumOptionalNumbers(left, right) {
2269
2693
  if (left === void 0) {
2270
2694
  return right;
@@ -2339,6 +2763,34 @@ function requiresImageInput(request) {
2339
2763
  function requiresFileUpload(request) {
2340
2764
  return request.attachments.some((file) => isPortableDocumentFile(file));
2341
2765
  }
2766
+ function resolveRequiredCapabilities(request) {
2767
+ const requiresSchema = requiresToolSchema(request);
2768
+ const requiresClientTools = requiresClientToolExecution(request);
2769
+ const requiresImage = requiresImageInput(request);
2770
+ const requiresFile = requiresFileUpload(request);
2771
+ const requiresAnyCapability = requiresSchema || requiresClientTools || requiresImage || requiresFile;
2772
+ const projected = requiresAnyCapability ? {
2773
+ ...request,
2774
+ routeHints: {
2775
+ ...request.routeHints ?? {},
2776
+ requiredCapabilities: {
2777
+ ...request.routeHints?.requiredCapabilities ?? {},
2778
+ ...requiresSchema ? { supportsToolSchema: true } : {},
2779
+ ...requiresClientTools ? { supportsClientToolExecution: true } : {},
2780
+ ...requiresImage ? { supportsImageInput: true } : {},
2781
+ ...requiresFile ? { supportsFileUpload: true } : {}
2782
+ }
2783
+ }
2784
+ } : request;
2785
+ return {
2786
+ requiresSchema,
2787
+ requiresClientTools,
2788
+ requiresImage,
2789
+ requiresFile,
2790
+ requiresAnyCapability,
2791
+ request: projected
2792
+ };
2793
+ }
2342
2794
  function normalizeRequestBase(request) {
2343
2795
  if (request.messages.length === 0) {
2344
2796
  throw new AiConnectError("validation_error", "Unified generate/stream requests must include at least one message.");
@@ -2547,7 +2999,10 @@ function summarizeRoute(route) {
2547
2999
  model: route.model,
2548
3000
  handlerKey: route.handlerKey,
2549
3001
  profileId: route.profileId,
2550
- ...route.transport.baseUrl ? { baseUrl: route.transport.baseUrl } : {}
3002
+ // SYS-3 / C1a: never emit a raw baseUrl into a wide event — a token embedded in
3003
+ // userinfo (`https://user:secret@host`) or the query string would leak verbatim,
3004
+ // unlike the secret-safe toPublicRoute projection. Strip credentials first.
3005
+ ...route.transport.baseUrl ? { baseUrl: redactBaseUrl(route.transport.baseUrl) } : {}
2551
3006
  };
2552
3007
  }
2553
3008
  function summarizeFiles(files) {
@@ -2837,7 +3292,7 @@ function createClient(configOrInput, options = {}) {
2837
3292
  let output = initialOutput;
2838
3293
  let messages = [...request.messages];
2839
3294
  let transportSummary2;
2840
- for (let round = 0; round < 8; round += 1) {
3295
+ for (let round = 0; ; round += 1) {
2841
3296
  const toolCalls = output.toolCalls ?? [];
2842
3297
  if (toolCalls.length === 0) {
2843
3298
  return {
@@ -2849,66 +3304,58 @@ function createClient(configOrInput, options = {}) {
2849
3304
  ...transportSummary2 ? { transportSummary: transportSummary2 } : {}
2850
3305
  };
2851
3306
  }
2852
- const assistantMessage = {
2853
- role: "assistant",
2854
- content: output.text ?? "",
2855
- toolCalls
2856
- };
2857
- const toolMessages = [];
2858
- for (const toolCall of toolCalls) {
2859
- const tool = registry.get(toolCall.name);
2860
- if (!tool) {
2861
- throw new AiConnectError(
2862
- "validation_error",
2863
- `No client tool handler is registered for "${toolCall.name}".`
2864
- );
2865
- }
2866
- const execution = normalizeToolResult(
2867
- await tool.execute(toolCall.arguments, {
2868
- route,
2869
- request,
2870
- runtime,
2871
- toolCall,
2872
- messages,
2873
- ...request.workingDirectory ? { workingDirectory: request.workingDirectory } : {}
2874
- })
2875
- );
2876
- toolMessages.push({
2877
- role: "tool",
2878
- content: execution.content,
2879
- toolName: tool.function.name,
2880
- toolCallId: toolCall.id,
2881
- ...execution.data !== void 0 ? { data: execution.data } : {},
2882
- ...execution.isError !== void 0 ? { isError: execution.isError } : {}
2883
- });
3307
+ const roundIterator = executeToolCallsRound({
3308
+ toolCalls,
3309
+ assistantText: output.text ?? "",
3310
+ registry,
3311
+ route,
3312
+ request,
3313
+ runtime,
3314
+ messages,
3315
+ abort
3316
+ });
3317
+ let roundStep = await roundIterator.next();
3318
+ while (!roundStep.done) {
3319
+ roundStep = await roundIterator.next();
2884
3320
  }
2885
- messages = [...messages, assistantMessage, ...toolMessages];
3321
+ messages = [
3322
+ ...messages,
3323
+ roundStep.value.assistantMessage,
3324
+ ...roundStep.value.toolMessages
3325
+ ];
2886
3326
  if (abort.signal.aborted) {
2887
3327
  throw mapAbortError(abort.reason);
2888
3328
  }
2889
- output = await handler({
2890
- route,
2891
- request: {
2892
- ...request,
2893
- messages,
2894
- attachments: []
2895
- },
2896
- runtime,
2897
- attempt: baseAttempt,
2898
- abort,
2899
- telemetry: {
2900
- captureTransport(summary) {
2901
- transportSummary2 = summary;
3329
+ if (round + 1 >= MAX_CLIENT_TOOL_LOOP_DEPTH) {
3330
+ throw new ClientToolExecutionError(
3331
+ new AiConnectError(
3332
+ "validation_error",
3333
+ `clientTools exceeded the maximum tool-call loop depth of ${MAX_CLIENT_TOOL_LOOP_DEPTH}.`
3334
+ )
3335
+ );
3336
+ }
3337
+ output = await runWithAbortRace(
3338
+ handler({
3339
+ route,
3340
+ request: {
3341
+ ...request,
3342
+ messages,
3343
+ attachments: []
3344
+ },
3345
+ runtime,
3346
+ attempt: baseAttempt,
3347
+ abort,
3348
+ telemetry: {
3349
+ captureTransport(summary) {
3350
+ transportSummary2 = summary;
3351
+ }
2902
3352
  }
2903
- }
2904
- });
3353
+ }),
3354
+ abort
3355
+ );
2905
3356
  warnings.push(...output.warnings ?? []);
2906
3357
  usage = mergeUsage(usage, output.usage);
2907
3358
  }
2908
- throw new AiConnectError(
2909
- "validation_error",
2910
- "clientTools exceeded the maximum tool-call loop depth of 8."
2911
- );
2912
3359
  }
2913
3360
  async function emitWideEvent(event) {
2914
3361
  if (!logging?.logger) {
@@ -2938,26 +3385,8 @@ function createClient(configOrInput, options = {}) {
2938
3385
  async function executeGenerateOperation(normalizedRequest, operationName, abort, seed = {}) {
2939
3386
  const startedAt = seed.startMs ?? nowMs(runtime);
2940
3387
  const requestId = seed.requestId ?? resolveEventRequestId(normalizedRequest.logContext);
2941
- const requiresSchema = requiresToolSchema(normalizedRequest);
2942
- const requiresClientTools = requiresClientToolExecution(normalizedRequest);
2943
- const requiresImage = requiresImageInput(normalizedRequest);
2944
- const requiresFile = requiresFileUpload(normalizedRequest);
2945
- const requiresAnyCapability = requiresSchema || requiresClientTools || requiresImage || requiresFile;
2946
- const resolvedCandidates = router.resolveCandidates(
2947
- requiresAnyCapability ? {
2948
- ...normalizedRequest,
2949
- routeHints: {
2950
- ...normalizedRequest.routeHints ?? {},
2951
- requiredCapabilities: {
2952
- ...normalizedRequest.routeHints?.requiredCapabilities ?? {},
2953
- ...requiresSchema ? { supportsToolSchema: true } : {},
2954
- ...requiresClientTools ? { supportsClientToolExecution: true } : {},
2955
- ...requiresImage ? { supportsImageInput: true } : {},
2956
- ...requiresFile ? { supportsFileUpload: true } : {}
2957
- }
2958
- }
2959
- } : normalizedRequest
2960
- );
3388
+ const { requiresImage, requiresFile, request: capabilityScopedRequest } = resolveRequiredCapabilities(normalizedRequest);
3389
+ const resolvedCandidates = router.resolveCandidates(capabilityScopedRequest);
2961
3390
  const candidates = seed.candidates ?? resolvedCandidates.map(summarizeRoute);
2962
3391
  const requestSummary = summarizeRequest(normalizedRequest, candidates);
2963
3392
  if (resolvedCandidates.length === 0) {
@@ -3045,18 +3474,21 @@ function createClient(configOrInput, options = {}) {
3045
3474
  const attemptStartedAt = nowMs(runtime);
3046
3475
  let transportSummary2;
3047
3476
  try {
3048
- let output = await handler.generate({
3049
- route,
3050
- request: routeRequest,
3051
- runtime,
3052
- attempt: attempts.length + 1,
3053
- abort,
3054
- telemetry: {
3055
- captureTransport(summary) {
3056
- transportSummary2 = summary;
3477
+ let output = await runWithAbortRace(
3478
+ handler.generate({
3479
+ route,
3480
+ request: routeRequest,
3481
+ runtime,
3482
+ attempt: attempts.length + 1,
3483
+ abort,
3484
+ telemetry: {
3485
+ captureTransport(summary) {
3486
+ transportSummary2 = summary;
3487
+ }
3057
3488
  }
3058
- }
3059
- });
3489
+ }),
3490
+ abort
3491
+ );
3060
3492
  if (routeRequest.clientTools?.length && output.toolCalls?.length) {
3061
3493
  const loopResult = await executeClientToolLoop(
3062
3494
  route,
@@ -3071,6 +3503,9 @@ function createClient(configOrInput, options = {}) {
3071
3503
  transportSummary2 = loopResult.transportSummary;
3072
3504
  }
3073
3505
  }
3506
+ if (abort.signal.aborted) {
3507
+ throw mapAbortError(abort.reason);
3508
+ }
3074
3509
  wideAttempts.push({
3075
3510
  index: wideAttempts.length + 1,
3076
3511
  route: routeSummary,
@@ -3141,6 +3576,49 @@ function createClient(configOrInput, options = {}) {
3141
3576
  routeId: route.id
3142
3577
  });
3143
3578
  }
3579
+ if (error instanceof ClientToolExecutionError) {
3580
+ const toolError = error.aiError;
3581
+ attempts.push({
3582
+ routeId: route.id,
3583
+ handlerKey: route.handlerKey,
3584
+ errorCode: toolError.code,
3585
+ message: toolError.message
3586
+ });
3587
+ wideAttempts.push({
3588
+ index: wideAttempts.length + 1,
3589
+ route: routeSummary,
3590
+ durationMs: nowMs(runtime) - attemptStartedAt,
3591
+ outcome: "error",
3592
+ retryCount,
3593
+ errorCode: toolError.code,
3594
+ message: toolError.message,
3595
+ ...transportSummary2 ? { transport: transportSummary2 } : {}
3596
+ });
3597
+ await emitWideEvent({
3598
+ timestamp: isoTimestamp(startedAt),
3599
+ event: "ai_connect.operation",
3600
+ requestId,
3601
+ operationName,
3602
+ operation: normalizedRequest.operation,
3603
+ runtime: runtime.kind,
3604
+ outcome: "error",
3605
+ durationMs: nowMs(runtime) - startedAt,
3606
+ candidates,
3607
+ attempts: wideAttempts,
3608
+ request: requestSummary,
3609
+ error: {
3610
+ code: toolError.code,
3611
+ message: toolError.message,
3612
+ routeId: route.id
3613
+ },
3614
+ ...normalizedRequest.logContext ? { context: normalizedRequest.logContext } : {}
3615
+ });
3616
+ throw new AiConnectError(toolError.code, toolError.message, {
3617
+ ...toolError.details,
3618
+ attempts,
3619
+ routeId: route.id
3620
+ });
3621
+ }
3144
3622
  const normalizedError = toAiConnectError(error);
3145
3623
  const actions = config.routing.fallback.on[normalizedError.code] ?? [];
3146
3624
  attempts.push({
@@ -3385,30 +3863,16 @@ function createClient(configOrInput, options = {}) {
3385
3863
  return report;
3386
3864
  }
3387
3865
  async function runPingWithAbort(route, handler, abort) {
3388
- if (abort.signal.aborted) {
3389
- throw mapAbortError(abort.reason);
3390
- }
3391
- const handlerPromise = handler({
3392
- route,
3393
- request: buildPingRequest(),
3394
- runtime,
3395
- attempt: 1,
3866
+ return runWithAbortRace(
3867
+ handler({
3868
+ route,
3869
+ request: buildPingRequest(),
3870
+ runtime,
3871
+ attempt: 1,
3872
+ abort
3873
+ }),
3396
3874
  abort
3397
- });
3398
- let onAbort;
3399
- const abortPromise = new Promise((_, reject) => {
3400
- onAbort = () => reject(mapAbortError(abort.reason));
3401
- abort.signal.addEventListener("abort", onAbort, { once: true });
3402
- });
3403
- try {
3404
- return await Promise.race([handlerPromise, abortPromise]);
3405
- } finally {
3406
- if (onAbort) {
3407
- abort.signal.removeEventListener("abort", onAbort);
3408
- }
3409
- void Promise.resolve(handlerPromise).catch(() => {
3410
- });
3411
- }
3875
+ );
3412
3876
  }
3413
3877
  async function checkEndpointReachable(route, handler, abort) {
3414
3878
  if (abort.signal.aborted) {
@@ -3951,12 +4415,6 @@ function createClient(configOrInput, options = {}) {
3951
4415
  },
3952
4416
  async *stream(request, opts) {
3953
4417
  const preparedRequest = prepareRequest(request);
3954
- if (preparedRequest.clientTools?.length) {
3955
- throw new AiConnectError(
3956
- "not_supported",
3957
- "clientTools are currently supported only by generate(), not stream()."
3958
- );
3959
- }
3960
4418
  const { abort, dispose: disposeAbort } = deriveAbort(
3961
4419
  opts,
3962
4420
  resolveOpTimeout(opts, "generateMs", options.timeouts),
@@ -3976,27 +4434,26 @@ function createClient(configOrInput, options = {}) {
3976
4434
  release = await limiter.acquire(abort.signal);
3977
4435
  const selectedModel = await resolveSelectedModel(preparedRequest);
3978
4436
  const normalizedRequest = withSelectedModel(preparedRequest, selectedModel);
3979
- const requiresSchema = requiresToolSchema(normalizedRequest);
3980
- const candidates = router.resolveCandidates(
3981
- requiresSchema ? {
3982
- ...normalizedRequest,
3983
- routeHints: {
3984
- ...normalizedRequest.routeHints ?? {},
3985
- requiredCapabilities: {
3986
- ...normalizedRequest.routeHints?.requiredCapabilities ?? {},
3987
- supportsToolSchema: true
3988
- }
3989
- }
3990
- } : normalizedRequest
3991
- );
4437
+ const {
4438
+ requiresImage,
4439
+ requiresFile,
4440
+ requiresClientTools,
4441
+ request: capabilityScopedRequest
4442
+ } = resolveRequiredCapabilities(normalizedRequest);
4443
+ const candidates = router.resolveCandidates(capabilityScopedRequest);
3992
4444
  const startedAt = nowMs(runtime);
3993
4445
  const requestId = resolveEventRequestId(normalizedRequest.logContext);
3994
4446
  const candidateSummaries = candidates.map(summarizeRoute);
3995
4447
  const requestSummary = summarizeRequest(normalizedRequest, candidateSummaries);
3996
4448
  const attempts = [];
3997
4449
  const wideAttempts = [];
4450
+ let hasYieldedOutward = false;
4451
+ let sawStreamHandler = false;
3998
4452
  if (candidates.length === 0) {
3999
- const error = new AiConnectError("not_supported", `No eligible routes are available for operation "${normalizedRequest.operation}" in runtime "${runtime.kind}".`);
4453
+ const error = requiresImage || requiresFile || requiresClientTools ? new AiConnectError(
4454
+ "unsupported_capability",
4455
+ requiresImage || requiresFile ? `No eligible route supports the required ${requiresFile ? "document/file" : "image"} input for operation "${normalizedRequest.operation}" in runtime "${runtime.kind}".` : `No eligible route supports client-side tool execution for operation "${normalizedRequest.operation}" in runtime "${runtime.kind}".`
4456
+ ) : new AiConnectError("not_supported", `No eligible routes are available for operation "${normalizedRequest.operation}" in runtime "${runtime.kind}".`);
4000
4457
  await emitWideEvent({
4001
4458
  timestamp: isoTimestamp(startedAt),
4002
4459
  event: "ai_connect.operation",
@@ -4023,84 +4480,202 @@ function createClient(configOrInput, options = {}) {
4023
4480
  continue;
4024
4481
  }
4025
4482
  if (handler.stream) {
4483
+ sawStreamHandler = true;
4026
4484
  const routeSummary = summarizeRoute(route);
4027
4485
  const attemptStartedAt = nowMs(runtime);
4028
4486
  let lastResult;
4029
4487
  let accumulatedText = "";
4030
4488
  const routeRequest = applyRouteDefaults(route, normalizedRequest);
4031
- const iterable = handler.stream({
4032
- route,
4033
- request: routeRequest,
4034
- runtime,
4035
- attempt: 1,
4036
- abort
4037
- });
4038
- const iterator = iterable[Symbol.asyncIterator]();
4039
- let iteratorReturned = false;
4040
- const returnIterator = async () => {
4041
- if (iteratorReturned) {
4042
- return;
4043
- }
4044
- iteratorReturned = true;
4045
- try {
4046
- await iterator.return?.();
4047
- } catch {
4048
- }
4049
- };
4489
+ const roundTools = routeRequest.clientTools ?? [];
4490
+ const registry = new Map(
4491
+ roundTools.map((tool) => [tool.function.name, tool])
4492
+ );
4493
+ let loopMessages = routeRequest.messages;
4494
+ let aggregatedUsage;
4495
+ const aggregatedWarnings = [];
4496
+ let toolRoundsExecuted = 0;
4050
4497
  try {
4051
- while (true) {
4498
+ roundsLoop: for (let round = 0; ; round += 1) {
4499
+ if (round >= MAX_CLIENT_TOOL_LOOP_DEPTH) {
4500
+ throw new ClientToolExecutionError(
4501
+ new AiConnectError(
4502
+ "validation_error",
4503
+ `clientTools exceeded the maximum tool-call loop depth of ${MAX_CLIENT_TOOL_LOOP_DEPTH}.`
4504
+ )
4505
+ );
4506
+ }
4052
4507
  if (abort.signal.aborted) {
4053
- await returnIterator();
4054
4508
  throw mapAbortError(abort.reason);
4055
4509
  }
4056
- if (pauseSignal?.aborted) {
4510
+ const roundRequest = round === 0 ? routeRequest : { ...routeRequest, messages: loopMessages, attachments: [] };
4511
+ const iterable = handler.stream({
4512
+ route,
4513
+ request: roundRequest,
4514
+ runtime,
4515
+ attempt: 1,
4516
+ abort
4517
+ });
4518
+ const iterator = iterable[Symbol.asyncIterator]();
4519
+ let iteratorReturned = false;
4520
+ const returnIterator = async () => {
4521
+ if (iteratorReturned) {
4522
+ return;
4523
+ }
4524
+ iteratorReturned = true;
4525
+ try {
4526
+ await iterator.return?.();
4527
+ } catch {
4528
+ }
4529
+ };
4530
+ let roundResult;
4531
+ try {
4532
+ while (true) {
4533
+ if (abort.signal.aborted) {
4534
+ await returnIterator();
4535
+ throw mapAbortError(abort.reason);
4536
+ }
4537
+ if (pauseSignal?.aborted) {
4538
+ await returnIterator();
4539
+ const pausedResult = lastResult ?? {
4540
+ route,
4541
+ text: accumulatedText,
4542
+ attachments: [],
4543
+ warnings: [],
4544
+ attempts
4545
+ };
4546
+ wideAttempts.push({
4547
+ index: wideAttempts.length + 1,
4548
+ route: routeSummary,
4549
+ durationMs: nowMs(runtime) - attemptStartedAt,
4550
+ outcome: "success",
4551
+ retryCount: 0
4552
+ });
4553
+ await emitWideEvent({
4554
+ timestamp: isoTimestamp(startedAt),
4555
+ event: "ai_connect.operation",
4556
+ requestId,
4557
+ operationName: "stream",
4558
+ operation: normalizedRequest.operation,
4559
+ runtime: runtime.kind,
4560
+ outcome: "success",
4561
+ durationMs: nowMs(runtime) - startedAt,
4562
+ candidates: candidateSummaries,
4563
+ attempts: wideAttempts,
4564
+ selectedRoute: routeSummary,
4565
+ request: requestSummary,
4566
+ result: summarizeResult(pausedResult),
4567
+ ...normalizedRequest.logContext ? { context: normalizedRequest.logContext } : {}
4568
+ });
4569
+ yield { type: "paused", result: pausedResult };
4570
+ return;
4571
+ }
4572
+ const next = await iterator.next();
4573
+ if (next.done) {
4574
+ break;
4575
+ }
4576
+ const event = next.value;
4577
+ if (event.type === "delta") {
4578
+ accumulatedText += event.text;
4579
+ hasYieldedOutward = true;
4580
+ yield event;
4581
+ continue;
4582
+ }
4583
+ if (event.type === "paused") {
4584
+ lastResult = event.result;
4585
+ wideAttempts.push({
4586
+ index: wideAttempts.length + 1,
4587
+ route: routeSummary,
4588
+ durationMs: nowMs(runtime) - attemptStartedAt,
4589
+ outcome: "success",
4590
+ retryCount: 0
4591
+ });
4592
+ router.recordSuccess(route);
4593
+ await emitWideEvent({
4594
+ timestamp: isoTimestamp(startedAt),
4595
+ event: "ai_connect.operation",
4596
+ requestId,
4597
+ operationName: "stream",
4598
+ operation: normalizedRequest.operation,
4599
+ runtime: runtime.kind,
4600
+ outcome: "success",
4601
+ durationMs: nowMs(runtime) - startedAt,
4602
+ candidates: candidateSummaries,
4603
+ attempts: wideAttempts,
4604
+ selectedRoute: routeSummary,
4605
+ request: requestSummary,
4606
+ result: summarizeResult(event.result),
4607
+ ...normalizedRequest.logContext ? { context: normalizedRequest.logContext } : {}
4608
+ });
4609
+ yield event;
4610
+ return;
4611
+ }
4612
+ if (event.type === "result") {
4613
+ roundResult = event.result;
4614
+ break;
4615
+ }
4616
+ }
4617
+ } finally {
4057
4618
  await returnIterator();
4058
- const pausedResult = lastResult ?? {
4059
- route,
4060
- text: accumulatedText,
4061
- attachments: [],
4062
- warnings: [],
4063
- attempts
4064
- };
4065
- wideAttempts.push({
4066
- index: wideAttempts.length + 1,
4067
- route: routeSummary,
4068
- durationMs: nowMs(runtime) - attemptStartedAt,
4069
- outcome: "success",
4070
- retryCount: 0
4071
- });
4072
- await emitWideEvent({
4073
- timestamp: isoTimestamp(startedAt),
4074
- event: "ai_connect.operation",
4075
- requestId,
4076
- operationName: "stream",
4077
- operation: normalizedRequest.operation,
4078
- runtime: runtime.kind,
4079
- outcome: "success",
4080
- durationMs: nowMs(runtime) - startedAt,
4081
- candidates: candidateSummaries,
4082
- attempts: wideAttempts,
4083
- selectedRoute: routeSummary,
4084
- request: requestSummary,
4085
- result: summarizeResult(pausedResult),
4086
- ...normalizedRequest.logContext ? { context: normalizedRequest.logContext } : {}
4087
- });
4088
- yield { type: "paused", result: pausedResult };
4089
- return;
4090
4619
  }
4091
- const next = await iterator.next();
4092
- if (next.done) {
4093
- break;
4620
+ if (!roundResult) {
4621
+ break roundsLoop;
4622
+ }
4623
+ if (registry.size === 0 || !roundResult.toolCalls?.length) {
4624
+ if (toolRoundsExecuted === 0) {
4625
+ lastResult = roundResult;
4626
+ } else {
4627
+ const mergedUsage = mergeUsage(aggregatedUsage, roundResult.usage);
4628
+ lastResult = {
4629
+ ...roundResult,
4630
+ warnings: [
4631
+ ...aggregatedWarnings,
4632
+ ...roundResult.warnings ?? []
4633
+ ],
4634
+ ...mergedUsage ? { usage: mergedUsage } : {}
4635
+ };
4636
+ }
4637
+ yield { type: "result", result: lastResult };
4638
+ break roundsLoop;
4094
4639
  }
4095
- const event = next.value;
4096
- if (event.type === "delta") {
4097
- accumulatedText += event.text;
4098
- yield event;
4099
- continue;
4640
+ aggregatedUsage = mergeUsage(aggregatedUsage, roundResult.usage);
4641
+ aggregatedWarnings.push(...roundResult.warnings ?? []);
4642
+ const roundGen = executeToolCallsRound({
4643
+ toolCalls: roundResult.toolCalls,
4644
+ assistantText: roundResult.text ?? "",
4645
+ registry,
4646
+ route,
4647
+ request: routeRequest,
4648
+ runtime,
4649
+ messages: loopMessages,
4650
+ abort,
4651
+ // D3/UR-007: emit a `tool-call` BEFORE execute, `tool-result` AFTER.
4652
+ onToolStart: (toolCall) => ({
4653
+ type: "tool-call",
4654
+ toolCall: {
4655
+ id: toolCall.id,
4656
+ name: toolCall.name,
4657
+ arguments: toolCall.arguments
4658
+ }
4659
+ }),
4660
+ onToolFinish: (toolCall, execution) => ({
4661
+ type: "tool-result",
4662
+ toolCallId: toolCall.id,
4663
+ name: toolCall.name,
4664
+ ...execution.isError !== void 0 ? { isError: execution.isError } : {}
4665
+ })
4666
+ });
4667
+ let toolStep = await roundGen.next();
4668
+ while (!toolStep.done) {
4669
+ hasYieldedOutward = true;
4670
+ yield toolStep.value;
4671
+ toolStep = await roundGen.next();
4100
4672
  }
4101
- lastResult = event.result;
4102
- yield event;
4103
- break;
4673
+ loopMessages = [
4674
+ ...loopMessages,
4675
+ toolStep.value.assistantMessage,
4676
+ ...toolStep.value.toolMessages
4677
+ ];
4678
+ toolRoundsExecuted += 1;
4104
4679
  }
4105
4680
  wideAttempts.push({
4106
4681
  index: wideAttempts.length + 1,
@@ -4128,7 +4703,44 @@ function createClient(configOrInput, options = {}) {
4128
4703
  });
4129
4704
  return;
4130
4705
  } catch (error) {
4131
- await returnIterator();
4706
+ if (error instanceof ClientToolExecutionError && !abort.signal.aborted) {
4707
+ const toolError = error.aiError;
4708
+ attempts.push({
4709
+ routeId: route.id,
4710
+ handlerKey: route.handlerKey,
4711
+ errorCode: toolError.code,
4712
+ message: toolError.message
4713
+ });
4714
+ wideAttempts.push({
4715
+ index: wideAttempts.length + 1,
4716
+ route: routeSummary,
4717
+ durationMs: nowMs(runtime) - attemptStartedAt,
4718
+ outcome: "error",
4719
+ retryCount: 0,
4720
+ errorCode: toolError.code,
4721
+ message: toolError.message
4722
+ });
4723
+ await emitWideEvent({
4724
+ timestamp: isoTimestamp(startedAt),
4725
+ event: "ai_connect.operation",
4726
+ requestId,
4727
+ operationName: "stream",
4728
+ operation: normalizedRequest.operation,
4729
+ runtime: runtime.kind,
4730
+ outcome: "error",
4731
+ durationMs: nowMs(runtime) - startedAt,
4732
+ candidates: candidateSummaries,
4733
+ attempts: wideAttempts,
4734
+ request: requestSummary,
4735
+ error: {
4736
+ code: toolError.code,
4737
+ message: toolError.message,
4738
+ routeId: route.id
4739
+ },
4740
+ ...normalizedRequest.logContext ? { context: normalizedRequest.logContext } : {}
4741
+ });
4742
+ throw toolError;
4743
+ }
4132
4744
  const normalizedError = abort.signal.aborted ? mapAbortError(abort.reason) : toAiConnectError(error);
4133
4745
  attempts.push({
4134
4746
  routeId: route.id,
@@ -4167,6 +4779,28 @@ function createClient(configOrInput, options = {}) {
4167
4779
  });
4168
4780
  throw normalizedError;
4169
4781
  }
4782
+ if (hasYieldedOutward) {
4783
+ await emitWideEvent({
4784
+ timestamp: isoTimestamp(startedAt),
4785
+ event: "ai_connect.operation",
4786
+ requestId,
4787
+ operationName: "stream",
4788
+ operation: normalizedRequest.operation,
4789
+ runtime: runtime.kind,
4790
+ outcome: "error",
4791
+ durationMs: nowMs(runtime) - startedAt,
4792
+ candidates: candidateSummaries,
4793
+ attempts: wideAttempts,
4794
+ request: requestSummary,
4795
+ error: {
4796
+ code: normalizedError.code,
4797
+ message: normalizedError.message,
4798
+ routeId: route.id
4799
+ },
4800
+ ...normalizedRequest.logContext ? { context: normalizedRequest.logContext } : {}
4801
+ });
4802
+ throw normalizedError;
4803
+ }
4170
4804
  router.recordFailure(route, normalizedError.code);
4171
4805
  continue;
4172
4806
  }
@@ -4182,6 +4816,12 @@ function createClient(configOrInput, options = {}) {
4182
4816
  attempts,
4183
4817
  wideAttempts
4184
4818
  });
4819
+ if (requiresClientTools && !sawStreamHandler) {
4820
+ result.warnings = [
4821
+ ...result.warnings,
4822
+ "Streaming tool-loop is not supported by the available route(s); returned a non-streamed result via the generate tool-loop."
4823
+ ];
4824
+ }
4185
4825
  yield {
4186
4826
  type: "result",
4187
4827
  result
@@ -4472,12 +5112,32 @@ function trimTrailingSlash(value) {
4472
5112
  function appendPath(baseUrl, suffix) {
4473
5113
  return `${trimTrailingSlash(baseUrl)}/${suffix.replace(/^\/+/, "")}`;
4474
5114
  }
5115
+ function isAbortError(error) {
5116
+ if (error instanceof AiConnectError) {
5117
+ return error.code === "aborted";
5118
+ }
5119
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
5120
+ }
5121
+ function attachPartsToLastUserTurn(conversation, createEmptyUserTurn) {
5122
+ let targetIndex = -1;
5123
+ for (let index = conversation.length - 1; index >= 0; index -= 1) {
5124
+ if (conversation[index]?.role === "user") {
5125
+ targetIndex = index;
5126
+ break;
5127
+ }
5128
+ }
5129
+ if (targetIndex === -1) {
5130
+ conversation.push(createEmptyUserTurn());
5131
+ targetIndex = conversation.length - 1;
5132
+ }
5133
+ return conversation[targetIndex];
5134
+ }
4475
5135
  function resolveOpenAiEndpoint(route, operation) {
4476
5136
  const configured = route.transport.baseUrl?.trim();
4477
5137
  if (!configured) {
4478
5138
  return operation === "image" ? "https://api.openai.com/v1/images/generations" : "https://api.openai.com/v1/chat/completions";
4479
5139
  }
4480
- if (configured.endsWith("/chat/completions") || configured.endsWith("/images/generations")) {
5140
+ if (operation === "text" && configured.endsWith("/chat/completions") || operation === "image" && configured.endsWith("/images/generations")) {
4481
5141
  return configured;
4482
5142
  }
4483
5143
  return appendPath(
@@ -5308,7 +5968,7 @@ async function uploadAnthropicFile(payload, uploader) {
5308
5968
  }
5309
5969
  try {
5310
5970
  const base = uploader.context.route.transport.baseUrl?.trim();
5311
- const root = base ? base.replace(/\/(messages|models)$/, "") : "https://api.anthropic.com/v1";
5971
+ const root = base ? trimTrailingSlash(base).replace(/\/(messages|models)$/, "") : "https://api.anthropic.com/v1";
5312
5972
  const endpoint = appendPath(root, "/files");
5313
5973
  const form = new FormData();
5314
5974
  form.append(
@@ -5332,7 +5992,10 @@ async function uploadAnthropicFile(payload, uploader) {
5332
5992
  }
5333
5993
  const id = recordOf(data).id;
5334
5994
  return typeof id === "string" ? id : void 0;
5335
- } catch {
5995
+ } catch (error) {
5996
+ if (uploader.context.abort.signal.aborted || isAbortError(error)) {
5997
+ throw error;
5998
+ }
5336
5999
  return void 0;
5337
6000
  }
5338
6001
  }
@@ -5365,7 +6028,10 @@ async function uploadOpenAiFile(payload, uploader) {
5365
6028
  }
5366
6029
  const id = recordOf(data).id;
5367
6030
  return typeof id === "string" ? id : void 0;
5368
- } catch {
6031
+ } catch (error) {
6032
+ if (uploader.context.abort.signal.aborted || isAbortError(error)) {
6033
+ throw error;
6034
+ }
5369
6035
  return void 0;
5370
6036
  }
5371
6037
  }
@@ -5375,10 +6041,11 @@ async function uploadGeminiFile(payload, uploader) {
5375
6041
  }
5376
6042
  try {
5377
6043
  const base = uploader.context.route.transport.baseUrl?.trim();
5378
- const endpoint = base ? appendPath(
5379
- base.replace(/\/v1beta\/models.*$/, "/v1beta").replace(/\/+$/, ""),
6044
+ const root = base ? trimTrailingSlash(base.replace(/\/v1beta\/models.*$/, "/v1beta")) : void 0;
6045
+ const endpoint = root ? appendPath(
6046
+ root.endsWith("/v1beta") ? `${root.slice(0, -"/v1beta".length)}/upload/v1beta` : `${root}/upload`,
5380
6047
  "/files"
5381
- ).replace("/v1beta/files", "/upload/v1beta/files") : "https://generativelanguage.googleapis.com/upload/v1beta/files";
6048
+ ) : "https://generativelanguage.googleapis.com/upload/v1beta/files";
5382
6049
  const response = await uploader.fetchImpl(endpoint, {
5383
6050
  method: "POST",
5384
6051
  headers: {
@@ -5397,7 +6064,10 @@ async function uploadGeminiFile(payload, uploader) {
5397
6064
  const file = recordOf(recordOf(data).file);
5398
6065
  const uri = file.uri ?? recordOf(data).uri;
5399
6066
  return typeof uri === "string" ? uri : void 0;
5400
- } catch {
6067
+ } catch (error) {
6068
+ if (uploader.context.abort.signal.aborted || isAbortError(error)) {
6069
+ throw error;
6070
+ }
5401
6071
  return void 0;
5402
6072
  }
5403
6073
  }
@@ -5595,21 +6265,10 @@ function attachOpenAiParts(conversation, parts) {
5595
6265
  if (parts.length === 0) {
5596
6266
  return conversation;
5597
6267
  }
5598
- let targetIndex = -1;
5599
- for (let index = conversation.length - 1; index >= 0; index -= 1) {
5600
- if (conversation[index]?.role === "user") {
5601
- targetIndex = index;
5602
- break;
5603
- }
5604
- }
5605
- if (targetIndex === -1) {
5606
- conversation.push({
5607
- role: "user",
5608
- content: ""
5609
- });
5610
- targetIndex = conversation.length - 1;
5611
- }
5612
- const target = conversation[targetIndex];
6268
+ const target = attachPartsToLastUserTurn(conversation, () => ({
6269
+ role: "user",
6270
+ content: ""
6271
+ }));
5613
6272
  const baseParts = typeof target.content === "string" && target.content.trim().length > 0 ? [{ type: "text", text: target.content }] : Array.isArray(target.content) ? target.content : [];
5614
6273
  target.content = [...baseParts, ...parts];
5615
6274
  return conversation;
@@ -5695,21 +6354,10 @@ async function anthropicRequest(messages, attachments, uploader) {
5695
6354
  }
5696
6355
  const attachmentParts = await anthropicAttachmentParts(attachments, uploader);
5697
6356
  if (attachmentParts.length > 0) {
5698
- let targetIndex = -1;
5699
- for (let index = conversation.length - 1; index >= 0; index -= 1) {
5700
- if (conversation[index]?.role === "user") {
5701
- targetIndex = index;
5702
- break;
5703
- }
5704
- }
5705
- if (targetIndex === -1) {
5706
- conversation.push({
5707
- role: "user",
5708
- content: ""
5709
- });
5710
- targetIndex = conversation.length - 1;
5711
- }
5712
- const target = conversation[targetIndex];
6357
+ const target = attachPartsToLastUserTurn(conversation, () => ({
6358
+ role: "user",
6359
+ content: ""
6360
+ }));
5713
6361
  const baseParts = typeof target.content === "string" && target.content.trim().length > 0 ? [{ type: "text", text: target.content }] : Array.isArray(target.content) ? target.content : [];
5714
6362
  target.content = [...baseParts, ...attachmentParts];
5715
6363
  }
@@ -5720,12 +6368,27 @@ async function anthropicRequest(messages, attachments, uploader) {
5720
6368
  }
5721
6369
  async function geminiRequest(messages, attachments, uploader) {
5722
6370
  const system = messages.filter((message) => message.role === "system").map((message) => message.content).join("\n\n");
6371
+ const toolNameByCallId = /* @__PURE__ */ new Map();
6372
+ for (const message of messages) {
6373
+ if (hasAssistantToolCalls(message)) {
6374
+ for (const call of message.toolCalls) {
6375
+ toolNameByCallId.set(call.id, call.name);
6376
+ }
6377
+ }
6378
+ }
5723
6379
  const contents = messages.filter((message) => message.role !== "system").reduce(
5724
6380
  (conversation, message) => {
5725
6381
  if (isToolMessage(message)) {
6382
+ const resolvedName = message.toolName ?? toolNameByCallId.get(message.toolCallId);
6383
+ if (!resolvedName) {
6384
+ throw new AiConnectError(
6385
+ "validation_error",
6386
+ `Gemini functionResponse requires a tool name: the tool message for toolCallId "${message.toolCallId}" has no toolName and no matching assistant toolCall was found.`
6387
+ );
6388
+ }
5726
6389
  const response = {
5727
6390
  functionResponse: {
5728
- name: message.toolName ?? "tool",
6391
+ name: resolvedName,
5729
6392
  response: isRecord(message.data) ? message.data : {
5730
6393
  content: message.content,
5731
6394
  ...message.isError !== void 0 ? { isError: message.isError } : {}
@@ -5772,21 +6435,11 @@ async function geminiRequest(messages, attachments, uploader) {
5772
6435
  );
5773
6436
  const attachmentParts = await geminiAttachmentParts(attachments, uploader);
5774
6437
  if (attachmentParts.length > 0) {
5775
- let targetIndex = -1;
5776
- for (let index = contents.length - 1; index >= 0; index -= 1) {
5777
- if (contents[index]?.role === "user") {
5778
- targetIndex = index;
5779
- break;
5780
- }
5781
- }
5782
- if (targetIndex === -1) {
5783
- contents.push({
5784
- role: "user",
5785
- parts: []
5786
- });
5787
- targetIndex = contents.length - 1;
5788
- }
5789
- contents[targetIndex].parts.push(...attachmentParts);
6438
+ const target = attachPartsToLastUserTurn(contents, () => ({
6439
+ role: "user",
6440
+ parts: []
6441
+ }));
6442
+ target.parts.push(...attachmentParts);
5790
6443
  }
5791
6444
  return {
5792
6445
  ...system ? {
@@ -5836,6 +6489,22 @@ function extractThoughts(value) {
5836
6489
  const joined = thoughts.join("\n").trim();
5837
6490
  return joined.length > 0 ? joined : void 0;
5838
6491
  }
6492
+ function extractThinking(value) {
6493
+ if (!Array.isArray(value)) {
6494
+ return void 0;
6495
+ }
6496
+ const thinking = value.flatMap((part) => {
6497
+ if (part && typeof part === "object" && part.type === "thinking") {
6498
+ const candidate = part.thinking;
6499
+ if (typeof candidate === "string") {
6500
+ return [candidate];
6501
+ }
6502
+ }
6503
+ return [];
6504
+ });
6505
+ const joined = thinking.join("\n").trim();
6506
+ return joined.length > 0 ? joined : void 0;
6507
+ }
5839
6508
  function imageAttachmentsFromPayload(payload, ...sources) {
5840
6509
  return extractImagePayloads(payload, ...sources).map(
5841
6510
  (item) => preparePortableFile(item)
@@ -5923,6 +6592,10 @@ function buildOpenAiChatBody(context, messages, parameters, responseFormat, opts
5923
6592
  model: context.route.model,
5924
6593
  messages,
5925
6594
  stream: opts.stream,
6595
+ // Ask OpenAI-compatible streaming routes for the trailing usage chunk; without
6596
+ // `stream_options.include_usage` the SSE stream carries no `usage` block and
6597
+ // `runOpenAiStream` would always report `usage: undefined` (H5).
6598
+ ...opts.stream ? { stream_options: { include_usage: true } } : {},
5926
6599
  ...parameters?.maxTokens !== void 0 ? { max_tokens: parameters.maxTokens } : {},
5927
6600
  ...parameters?.temperature !== void 0 ? { temperature: parameters.temperature } : {},
5928
6601
  ...parameters?.topP !== void 0 ? { top_p: parameters.topP } : {},
@@ -6077,6 +6750,11 @@ async function* runOpenAiStream(fetchImpl, context) {
6077
6750
  }
6078
6751
  const response = outcome.response;
6079
6752
  const streamWarnings = [...uploaderWarnings];
6753
+ if (parameters?.candidateCount && parameters.candidateCount > 1) {
6754
+ streamWarnings.push(
6755
+ "candidateCount > 1 is forwarded to OpenAI-compatible text routes, but the normalized result returns only the first choice."
6756
+ );
6757
+ }
6080
6758
  if (droppedResponseFormat) {
6081
6759
  streamWarnings.push(
6082
6760
  "response_format was rejected by the OpenAI-compatible route (HTTP 400); the request was retried once without response_format and structured-output formatting was NOT enforced."
@@ -6222,8 +6900,7 @@ function buildOpenAiStreamResult(context, text, toolCalls, usage, warnings) {
6222
6900
  } : {}
6223
6901
  };
6224
6902
  }
6225
- async function runOpenAiImage(fetchImpl, context) {
6226
- const apiKey = resolveApiKey(context.route, context.runtime);
6903
+ async function runProviderImage(fetchImpl, context, opts) {
6227
6904
  const referenceImages = imageReferenceFiles(context);
6228
6905
  const bundle = buildImagePromptBundle({
6229
6906
  request: context.request,
@@ -6232,10 +6909,10 @@ async function runOpenAiImage(fetchImpl, context) {
6232
6909
  });
6233
6910
  const baseFields = buildImageRequestFields(context, bundle);
6234
6911
  const useEditEndpoint = shouldUseImageEditEndpoint(context);
6235
- const endpoint = useEditEndpoint ? resolveOpenAiImageEditEndpoint(context.route) : resolveOpenAiEndpoint(context.route, "image");
6912
+ const endpoint = useEditEndpoint ? opts.resolveEditEndpoint(context.route) : opts.resolveEndpoint(context.route);
6236
6913
  transportSummary(
6237
6914
  context,
6238
- useEditEndpoint ? "openai-image-edit" : "openai-image",
6915
+ useEditEndpoint ? `${opts.providerLabel}-image-edit` : `${opts.providerLabel}-image`,
6239
6916
  endpoint,
6240
6917
  "POST",
6241
6918
  false,
@@ -6248,23 +6925,21 @@ async function runOpenAiImage(fetchImpl, context) {
6248
6925
  );
6249
6926
  const response = await fetchImpl(endpoint, useEditEndpoint ? {
6250
6927
  method: "POST",
6251
- headers: {
6252
- authorization: `Bearer ${apiKey}`
6253
- },
6928
+ headers: { ...opts.authHeaders },
6254
6929
  body: await buildImageEditFormData(context, baseFields),
6255
6930
  signal: context.abort.signal
6256
6931
  } : {
6257
6932
  method: "POST",
6258
6933
  headers: {
6259
6934
  "content-type": "application/json",
6260
- authorization: `Bearer ${apiKey}`
6935
+ ...opts.authHeaders
6261
6936
  },
6262
6937
  body: JSON.stringify(baseFields),
6263
6938
  signal: context.abort.signal
6264
6939
  });
6265
6940
  const payload = await readPayload(response);
6266
6941
  if (!response.ok) {
6267
- throw classifyApiError("openai", response, payload);
6942
+ throw classifyApiError(opts.classifyProvider, response, payload);
6268
6943
  }
6269
6944
  return {
6270
6945
  data: {
@@ -6274,6 +6949,16 @@ async function runOpenAiImage(fetchImpl, context) {
6274
6949
  attachments: imageAttachmentsFromPayload(payload)
6275
6950
  };
6276
6951
  }
6952
+ async function runOpenAiImage(fetchImpl, context) {
6953
+ const apiKey = resolveApiKey(context.route, context.runtime);
6954
+ return runProviderImage(fetchImpl, context, {
6955
+ resolveEndpoint: (route) => resolveOpenAiEndpoint(route, "image"),
6956
+ resolveEditEndpoint: (route) => resolveOpenAiImageEditEndpoint(route),
6957
+ authHeaders: { authorization: `Bearer ${apiKey}` },
6958
+ providerLabel: "openai",
6959
+ classifyProvider: "openai"
6960
+ });
6961
+ }
6277
6962
  async function runAnthropic(fetchImpl, context) {
6278
6963
  const apiKey = resolveApiKey(context.route, context.runtime);
6279
6964
  const uploaderWarnings = [];
@@ -6285,6 +6970,8 @@ async function runAnthropic(fetchImpl, context) {
6285
6970
  const parameters = context.request.parameters;
6286
6971
  const requestBody = {
6287
6972
  model: context.route.model,
6973
+ // Anthropic REQUIRES `max_tokens` (unlike OpenAI/Gemini, where it is optional), so a
6974
+ // provider-specific default of 4096 is applied when the caller omits `maxTokens`.
6288
6975
  max_tokens: parameters?.maxTokens ?? 4096,
6289
6976
  ...parameters?.temperature !== void 0 ? { temperature: parameters.temperature } : {},
6290
6977
  ...parameters?.topP !== void 0 ? { top_p: parameters.topP } : {},
@@ -6314,8 +7001,12 @@ async function runAnthropic(fetchImpl, context) {
6314
7001
  throw classifyApiError("anthropic", response, payload);
6315
7002
  }
6316
7003
  const data = payload;
7004
+ const anthropicReasoning = extractThinking(data.content);
6317
7005
  return {
6318
7006
  text: extractText(data.content),
7007
+ // Anthropic extended-thinking blocks, separated from the answer (consumers route this
7008
+ // to a thinking UI), mirroring the Gemini `reasoning` path.
7009
+ ...anthropicReasoning ? { reasoning: anthropicReasoning } : {},
6319
7010
  data,
6320
7011
  ...anthropicToolCallsFromPayload(data).length > 0 ? { toolCalls: anthropicToolCallsFromPayload(data) } : {},
6321
7012
  ...uploaderWarnings.length > 0 ? { warnings: uploaderWarnings } : {},
@@ -6410,55 +7101,13 @@ async function runGemini(fetchImpl, context) {
6410
7101
  }
6411
7102
  async function runGeminiImage(fetchImpl, context) {
6412
7103
  const apiKey = resolveApiKey(context.route, context.runtime);
6413
- const referenceImages = imageReferenceFiles(context);
6414
- const bundle = buildImagePromptBundle({
6415
- request: context.request,
6416
- model: context.route.model,
6417
- referenceImages
7104
+ return runProviderImage(fetchImpl, context, {
7105
+ resolveEndpoint: (route) => resolveGeminiImageEndpoint(route),
7106
+ resolveEditEndpoint: (route) => resolveGeminiImageEditEndpoint(route),
7107
+ authHeaders: { authorization: `Bearer ${apiKey}` },
7108
+ providerLabel: "gemini",
7109
+ classifyProvider: context.route.provider
6418
7110
  });
6419
- const baseFields = buildImageRequestFields(context, bundle);
6420
- const useEditEndpoint = shouldUseImageEditEndpoint(context);
6421
- const endpoint = useEditEndpoint ? resolveGeminiImageEditEndpoint(context.route) : resolveGeminiImageEndpoint(context.route);
6422
- transportSummary(
6423
- context,
6424
- useEditEndpoint ? "gemini-image-edit" : "gemini-image",
6425
- endpoint,
6426
- "POST",
6427
- false,
6428
- {
6429
- ...baseFields,
6430
- source_image: Boolean(context.request.image?.sourceImage),
6431
- mask: Boolean(context.request.image?.mask),
6432
- reference_image_count: referenceImages.length
6433
- }
6434
- );
6435
- const response = await fetchImpl(endpoint, useEditEndpoint ? {
6436
- method: "POST",
6437
- headers: {
6438
- authorization: `Bearer ${apiKey}`
6439
- },
6440
- body: await buildImageEditFormData(context, baseFields),
6441
- signal: context.abort.signal
6442
- } : {
6443
- method: "POST",
6444
- headers: {
6445
- "content-type": "application/json",
6446
- authorization: `Bearer ${apiKey}`
6447
- },
6448
- body: JSON.stringify(baseFields),
6449
- signal: context.abort.signal
6450
- });
6451
- const payload = await readPayload(response);
6452
- if (!response.ok) {
6453
- throw classifyApiError(context.route.provider, response, payload);
6454
- }
6455
- return {
6456
- data: {
6457
- request: bundle,
6458
- response: payload
6459
- },
6460
- attachments: imageAttachmentsFromPayload(payload)
6461
- };
6462
7111
  }
6463
7112
  function verifyApiRoute(route, runtime) {
6464
7113
  resolveApiKey(route, runtime);
@@ -6537,9 +7186,12 @@ var AI_CONNECT_DEFAULT_SERVER_PRESETS = {
6537
7186
  transportId: "opencode-server"
6538
7187
  }
6539
7188
  };
6540
- var AI_CONNECT_DEFAULT_SERVER_COMMANDS = {
6541
- "opencode:opencode-server": AI_CONNECT_DEFAULT_SERVER_PRESETS.opencode.command
6542
- };
7189
+ var AI_CONNECT_DEFAULT_SERVER_COMMANDS = Object.fromEntries(
7190
+ Object.values(AI_CONNECT_DEFAULT_SERVER_PRESETS).map((preset) => [
7191
+ `${preset.id}:${preset.transportId}`,
7192
+ preset.command
7193
+ ])
7194
+ );
6543
7195
 
6544
7196
  // src/catalog.ts
6545
7197
  var TEXT_PROVIDER_CATALOG = [