@vedmalex/ai-connect 0.8.0 → 0.9.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 +717 -284
  3. package/dist/browser/index.js.map +3 -3
  4. package/dist/bun/index.js +1089 -440
  5. package/dist/bun/index.js.map +4 -4
  6. package/dist/bun/local.js +1041 -440
  7. package/dist/bun/local.js.map +4 -4
  8. package/dist/node/index.js +1089 -440
  9. package/dist/node/index.js.map +4 -4
  10. package/dist/node/local.js +1041 -440
  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 +18 -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());
@@ -2339,6 +2696,34 @@ function requiresImageInput(request) {
2339
2696
  function requiresFileUpload(request) {
2340
2697
  return request.attachments.some((file) => isPortableDocumentFile(file));
2341
2698
  }
2699
+ function resolveRequiredCapabilities(request) {
2700
+ const requiresSchema = requiresToolSchema(request);
2701
+ const requiresClientTools = requiresClientToolExecution(request);
2702
+ const requiresImage = requiresImageInput(request);
2703
+ const requiresFile = requiresFileUpload(request);
2704
+ const requiresAnyCapability = requiresSchema || requiresClientTools || requiresImage || requiresFile;
2705
+ const projected = requiresAnyCapability ? {
2706
+ ...request,
2707
+ routeHints: {
2708
+ ...request.routeHints ?? {},
2709
+ requiredCapabilities: {
2710
+ ...request.routeHints?.requiredCapabilities ?? {},
2711
+ ...requiresSchema ? { supportsToolSchema: true } : {},
2712
+ ...requiresClientTools ? { supportsClientToolExecution: true } : {},
2713
+ ...requiresImage ? { supportsImageInput: true } : {},
2714
+ ...requiresFile ? { supportsFileUpload: true } : {}
2715
+ }
2716
+ }
2717
+ } : request;
2718
+ return {
2719
+ requiresSchema,
2720
+ requiresClientTools,
2721
+ requiresImage,
2722
+ requiresFile,
2723
+ requiresAnyCapability,
2724
+ request: projected
2725
+ };
2726
+ }
2342
2727
  function normalizeRequestBase(request) {
2343
2728
  if (request.messages.length === 0) {
2344
2729
  throw new AiConnectError("validation_error", "Unified generate/stream requests must include at least one message.");
@@ -2547,7 +2932,10 @@ function summarizeRoute(route) {
2547
2932
  model: route.model,
2548
2933
  handlerKey: route.handlerKey,
2549
2934
  profileId: route.profileId,
2550
- ...route.transport.baseUrl ? { baseUrl: route.transport.baseUrl } : {}
2935
+ // SYS-3 / C1a: never emit a raw baseUrl into a wide event — a token embedded in
2936
+ // userinfo (`https://user:secret@host`) or the query string would leak verbatim,
2937
+ // unlike the secret-safe toPublicRoute projection. Strip credentials first.
2938
+ ...route.transport.baseUrl ? { baseUrl: redactBaseUrl(route.transport.baseUrl) } : {}
2551
2939
  };
2552
2940
  }
2553
2941
  function summarizeFiles(files) {
@@ -2858,21 +3246,40 @@ function createClient(configOrInput, options = {}) {
2858
3246
  for (const toolCall of toolCalls) {
2859
3247
  const tool = registry.get(toolCall.name);
2860
3248
  if (!tool) {
2861
- throw new AiConnectError(
2862
- "validation_error",
2863
- `No client tool handler is registered for "${toolCall.name}".`
3249
+ throw new ClientToolExecutionError(
3250
+ new AiConnectError(
3251
+ "validation_error",
3252
+ `No client tool handler is registered for "${toolCall.name}".`
3253
+ )
3254
+ );
3255
+ }
3256
+ if (abort.signal.aborted) {
3257
+ throw mapAbortError(abort.reason);
3258
+ }
3259
+ let execution;
3260
+ try {
3261
+ execution = normalizeToolResult(
3262
+ await tool.execute(toolCall.arguments, {
3263
+ route,
3264
+ request,
3265
+ runtime,
3266
+ toolCall,
3267
+ messages,
3268
+ ...request.workingDirectory ? { workingDirectory: request.workingDirectory } : {}
3269
+ })
3270
+ );
3271
+ } catch (error) {
3272
+ if (abort.signal.aborted) {
3273
+ throw mapAbortError(abort.reason);
3274
+ }
3275
+ throw new ClientToolExecutionError(
3276
+ new AiConnectError(
3277
+ "validation_error",
3278
+ `Client tool "${tool.function.name}" threw during execution: ${error instanceof Error ? error.message : String(error)}`,
3279
+ { cause: error }
3280
+ )
2864
3281
  );
2865
3282
  }
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
3283
  toolMessages.push({
2877
3284
  role: "tool",
2878
3285
  content: execution.content,
@@ -2886,28 +3293,33 @@ function createClient(configOrInput, options = {}) {
2886
3293
  if (abort.signal.aborted) {
2887
3294
  throw mapAbortError(abort.reason);
2888
3295
  }
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;
3296
+ output = await runWithAbortRace(
3297
+ handler({
3298
+ route,
3299
+ request: {
3300
+ ...request,
3301
+ messages,
3302
+ attachments: []
3303
+ },
3304
+ runtime,
3305
+ attempt: baseAttempt,
3306
+ abort,
3307
+ telemetry: {
3308
+ captureTransport(summary) {
3309
+ transportSummary2 = summary;
3310
+ }
2902
3311
  }
2903
- }
2904
- });
3312
+ }),
3313
+ abort
3314
+ );
2905
3315
  warnings.push(...output.warnings ?? []);
2906
3316
  usage = mergeUsage(usage, output.usage);
2907
3317
  }
2908
- throw new AiConnectError(
2909
- "validation_error",
2910
- "clientTools exceeded the maximum tool-call loop depth of 8."
3318
+ throw new ClientToolExecutionError(
3319
+ new AiConnectError(
3320
+ "validation_error",
3321
+ "clientTools exceeded the maximum tool-call loop depth of 8."
3322
+ )
2911
3323
  );
2912
3324
  }
2913
3325
  async function emitWideEvent(event) {
@@ -2938,26 +3350,8 @@ function createClient(configOrInput, options = {}) {
2938
3350
  async function executeGenerateOperation(normalizedRequest, operationName, abort, seed = {}) {
2939
3351
  const startedAt = seed.startMs ?? nowMs(runtime);
2940
3352
  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
- );
3353
+ const { requiresImage, requiresFile, request: capabilityScopedRequest } = resolveRequiredCapabilities(normalizedRequest);
3354
+ const resolvedCandidates = router.resolveCandidates(capabilityScopedRequest);
2961
3355
  const candidates = seed.candidates ?? resolvedCandidates.map(summarizeRoute);
2962
3356
  const requestSummary = summarizeRequest(normalizedRequest, candidates);
2963
3357
  if (resolvedCandidates.length === 0) {
@@ -3045,18 +3439,21 @@ function createClient(configOrInput, options = {}) {
3045
3439
  const attemptStartedAt = nowMs(runtime);
3046
3440
  let transportSummary2;
3047
3441
  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;
3442
+ let output = await runWithAbortRace(
3443
+ handler.generate({
3444
+ route,
3445
+ request: routeRequest,
3446
+ runtime,
3447
+ attempt: attempts.length + 1,
3448
+ abort,
3449
+ telemetry: {
3450
+ captureTransport(summary) {
3451
+ transportSummary2 = summary;
3452
+ }
3057
3453
  }
3058
- }
3059
- });
3454
+ }),
3455
+ abort
3456
+ );
3060
3457
  if (routeRequest.clientTools?.length && output.toolCalls?.length) {
3061
3458
  const loopResult = await executeClientToolLoop(
3062
3459
  route,
@@ -3071,6 +3468,9 @@ function createClient(configOrInput, options = {}) {
3071
3468
  transportSummary2 = loopResult.transportSummary;
3072
3469
  }
3073
3470
  }
3471
+ if (abort.signal.aborted) {
3472
+ throw mapAbortError(abort.reason);
3473
+ }
3074
3474
  wideAttempts.push({
3075
3475
  index: wideAttempts.length + 1,
3076
3476
  route: routeSummary,
@@ -3141,6 +3541,49 @@ function createClient(configOrInput, options = {}) {
3141
3541
  routeId: route.id
3142
3542
  });
3143
3543
  }
3544
+ if (error instanceof ClientToolExecutionError) {
3545
+ const toolError = error.aiError;
3546
+ attempts.push({
3547
+ routeId: route.id,
3548
+ handlerKey: route.handlerKey,
3549
+ errorCode: toolError.code,
3550
+ message: toolError.message
3551
+ });
3552
+ wideAttempts.push({
3553
+ index: wideAttempts.length + 1,
3554
+ route: routeSummary,
3555
+ durationMs: nowMs(runtime) - attemptStartedAt,
3556
+ outcome: "error",
3557
+ retryCount,
3558
+ errorCode: toolError.code,
3559
+ message: toolError.message,
3560
+ ...transportSummary2 ? { transport: transportSummary2 } : {}
3561
+ });
3562
+ await emitWideEvent({
3563
+ timestamp: isoTimestamp(startedAt),
3564
+ event: "ai_connect.operation",
3565
+ requestId,
3566
+ operationName,
3567
+ operation: normalizedRequest.operation,
3568
+ runtime: runtime.kind,
3569
+ outcome: "error",
3570
+ durationMs: nowMs(runtime) - startedAt,
3571
+ candidates,
3572
+ attempts: wideAttempts,
3573
+ request: requestSummary,
3574
+ error: {
3575
+ code: toolError.code,
3576
+ message: toolError.message,
3577
+ routeId: route.id
3578
+ },
3579
+ ...normalizedRequest.logContext ? { context: normalizedRequest.logContext } : {}
3580
+ });
3581
+ throw new AiConnectError(toolError.code, toolError.message, {
3582
+ ...toolError.details,
3583
+ attempts,
3584
+ routeId: route.id
3585
+ });
3586
+ }
3144
3587
  const normalizedError = toAiConnectError(error);
3145
3588
  const actions = config.routing.fallback.on[normalizedError.code] ?? [];
3146
3589
  attempts.push({
@@ -3385,30 +3828,16 @@ function createClient(configOrInput, options = {}) {
3385
3828
  return report;
3386
3829
  }
3387
3830
  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,
3831
+ return runWithAbortRace(
3832
+ handler({
3833
+ route,
3834
+ request: buildPingRequest(),
3835
+ runtime,
3836
+ attempt: 1,
3837
+ abort
3838
+ }),
3396
3839
  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
- }
3840
+ );
3412
3841
  }
3413
3842
  async function checkEndpointReachable(route, handler, abort) {
3414
3843
  if (abort.signal.aborted) {
@@ -3976,19 +4405,8 @@ function createClient(configOrInput, options = {}) {
3976
4405
  release = await limiter.acquire(abort.signal);
3977
4406
  const selectedModel = await resolveSelectedModel(preparedRequest);
3978
4407
  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
- );
4408
+ const { requiresImage, requiresFile, request: capabilityScopedRequest } = resolveRequiredCapabilities(normalizedRequest);
4409
+ const candidates = router.resolveCandidates(capabilityScopedRequest);
3992
4410
  const startedAt = nowMs(runtime);
3993
4411
  const requestId = resolveEventRequestId(normalizedRequest.logContext);
3994
4412
  const candidateSummaries = candidates.map(summarizeRoute);
@@ -3996,7 +4414,10 @@ function createClient(configOrInput, options = {}) {
3996
4414
  const attempts = [];
3997
4415
  const wideAttempts = [];
3998
4416
  if (candidates.length === 0) {
3999
- const error = new AiConnectError("not_supported", `No eligible routes are available for operation "${normalizedRequest.operation}" in runtime "${runtime.kind}".`);
4417
+ const error = requiresImage || requiresFile ? new AiConnectError(
4418
+ "unsupported_capability",
4419
+ `No eligible route supports the required ${requiresFile ? "document/file" : "image"} input for operation "${normalizedRequest.operation}" in runtime "${runtime.kind}".`
4420
+ ) : new AiConnectError("not_supported", `No eligible routes are available for operation "${normalizedRequest.operation}" in runtime "${runtime.kind}".`);
4000
4421
  await emitWideEvent({
4001
4422
  timestamp: isoTimestamp(startedAt),
4002
4423
  event: "ai_connect.operation",
@@ -4472,12 +4893,32 @@ function trimTrailingSlash(value) {
4472
4893
  function appendPath(baseUrl, suffix) {
4473
4894
  return `${trimTrailingSlash(baseUrl)}/${suffix.replace(/^\/+/, "")}`;
4474
4895
  }
4896
+ function isAbortError(error) {
4897
+ if (error instanceof AiConnectError) {
4898
+ return error.code === "aborted";
4899
+ }
4900
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
4901
+ }
4902
+ function attachPartsToLastUserTurn(conversation, createEmptyUserTurn) {
4903
+ let targetIndex = -1;
4904
+ for (let index = conversation.length - 1; index >= 0; index -= 1) {
4905
+ if (conversation[index]?.role === "user") {
4906
+ targetIndex = index;
4907
+ break;
4908
+ }
4909
+ }
4910
+ if (targetIndex === -1) {
4911
+ conversation.push(createEmptyUserTurn());
4912
+ targetIndex = conversation.length - 1;
4913
+ }
4914
+ return conversation[targetIndex];
4915
+ }
4475
4916
  function resolveOpenAiEndpoint(route, operation) {
4476
4917
  const configured = route.transport.baseUrl?.trim();
4477
4918
  if (!configured) {
4478
4919
  return operation === "image" ? "https://api.openai.com/v1/images/generations" : "https://api.openai.com/v1/chat/completions";
4479
4920
  }
4480
- if (configured.endsWith("/chat/completions") || configured.endsWith("/images/generations")) {
4921
+ if (operation === "text" && configured.endsWith("/chat/completions") || operation === "image" && configured.endsWith("/images/generations")) {
4481
4922
  return configured;
4482
4923
  }
4483
4924
  return appendPath(
@@ -5308,7 +5749,7 @@ async function uploadAnthropicFile(payload, uploader) {
5308
5749
  }
5309
5750
  try {
5310
5751
  const base = uploader.context.route.transport.baseUrl?.trim();
5311
- const root = base ? base.replace(/\/(messages|models)$/, "") : "https://api.anthropic.com/v1";
5752
+ const root = base ? trimTrailingSlash(base).replace(/\/(messages|models)$/, "") : "https://api.anthropic.com/v1";
5312
5753
  const endpoint = appendPath(root, "/files");
5313
5754
  const form = new FormData();
5314
5755
  form.append(
@@ -5332,7 +5773,10 @@ async function uploadAnthropicFile(payload, uploader) {
5332
5773
  }
5333
5774
  const id = recordOf(data).id;
5334
5775
  return typeof id === "string" ? id : void 0;
5335
- } catch {
5776
+ } catch (error) {
5777
+ if (uploader.context.abort.signal.aborted || isAbortError(error)) {
5778
+ throw error;
5779
+ }
5336
5780
  return void 0;
5337
5781
  }
5338
5782
  }
@@ -5365,7 +5809,10 @@ async function uploadOpenAiFile(payload, uploader) {
5365
5809
  }
5366
5810
  const id = recordOf(data).id;
5367
5811
  return typeof id === "string" ? id : void 0;
5368
- } catch {
5812
+ } catch (error) {
5813
+ if (uploader.context.abort.signal.aborted || isAbortError(error)) {
5814
+ throw error;
5815
+ }
5369
5816
  return void 0;
5370
5817
  }
5371
5818
  }
@@ -5375,10 +5822,11 @@ async function uploadGeminiFile(payload, uploader) {
5375
5822
  }
5376
5823
  try {
5377
5824
  const base = uploader.context.route.transport.baseUrl?.trim();
5378
- const endpoint = base ? appendPath(
5379
- base.replace(/\/v1beta\/models.*$/, "/v1beta").replace(/\/+$/, ""),
5825
+ const root = base ? trimTrailingSlash(base.replace(/\/v1beta\/models.*$/, "/v1beta")) : void 0;
5826
+ const endpoint = root ? appendPath(
5827
+ root.endsWith("/v1beta") ? `${root.slice(0, -"/v1beta".length)}/upload/v1beta` : `${root}/upload`,
5380
5828
  "/files"
5381
- ).replace("/v1beta/files", "/upload/v1beta/files") : "https://generativelanguage.googleapis.com/upload/v1beta/files";
5829
+ ) : "https://generativelanguage.googleapis.com/upload/v1beta/files";
5382
5830
  const response = await uploader.fetchImpl(endpoint, {
5383
5831
  method: "POST",
5384
5832
  headers: {
@@ -5397,7 +5845,10 @@ async function uploadGeminiFile(payload, uploader) {
5397
5845
  const file = recordOf(recordOf(data).file);
5398
5846
  const uri = file.uri ?? recordOf(data).uri;
5399
5847
  return typeof uri === "string" ? uri : void 0;
5400
- } catch {
5848
+ } catch (error) {
5849
+ if (uploader.context.abort.signal.aborted || isAbortError(error)) {
5850
+ throw error;
5851
+ }
5401
5852
  return void 0;
5402
5853
  }
5403
5854
  }
@@ -5595,21 +6046,10 @@ function attachOpenAiParts(conversation, parts) {
5595
6046
  if (parts.length === 0) {
5596
6047
  return conversation;
5597
6048
  }
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];
6049
+ const target = attachPartsToLastUserTurn(conversation, () => ({
6050
+ role: "user",
6051
+ content: ""
6052
+ }));
5613
6053
  const baseParts = typeof target.content === "string" && target.content.trim().length > 0 ? [{ type: "text", text: target.content }] : Array.isArray(target.content) ? target.content : [];
5614
6054
  target.content = [...baseParts, ...parts];
5615
6055
  return conversation;
@@ -5695,21 +6135,10 @@ async function anthropicRequest(messages, attachments, uploader) {
5695
6135
  }
5696
6136
  const attachmentParts = await anthropicAttachmentParts(attachments, uploader);
5697
6137
  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];
6138
+ const target = attachPartsToLastUserTurn(conversation, () => ({
6139
+ role: "user",
6140
+ content: ""
6141
+ }));
5713
6142
  const baseParts = typeof target.content === "string" && target.content.trim().length > 0 ? [{ type: "text", text: target.content }] : Array.isArray(target.content) ? target.content : [];
5714
6143
  target.content = [...baseParts, ...attachmentParts];
5715
6144
  }
@@ -5720,12 +6149,27 @@ async function anthropicRequest(messages, attachments, uploader) {
5720
6149
  }
5721
6150
  async function geminiRequest(messages, attachments, uploader) {
5722
6151
  const system = messages.filter((message) => message.role === "system").map((message) => message.content).join("\n\n");
6152
+ const toolNameByCallId = /* @__PURE__ */ new Map();
6153
+ for (const message of messages) {
6154
+ if (hasAssistantToolCalls(message)) {
6155
+ for (const call of message.toolCalls) {
6156
+ toolNameByCallId.set(call.id, call.name);
6157
+ }
6158
+ }
6159
+ }
5723
6160
  const contents = messages.filter((message) => message.role !== "system").reduce(
5724
6161
  (conversation, message) => {
5725
6162
  if (isToolMessage(message)) {
6163
+ const resolvedName = message.toolName ?? toolNameByCallId.get(message.toolCallId);
6164
+ if (!resolvedName) {
6165
+ throw new AiConnectError(
6166
+ "validation_error",
6167
+ `Gemini functionResponse requires a tool name: the tool message for toolCallId "${message.toolCallId}" has no toolName and no matching assistant toolCall was found.`
6168
+ );
6169
+ }
5726
6170
  const response = {
5727
6171
  functionResponse: {
5728
- name: message.toolName ?? "tool",
6172
+ name: resolvedName,
5729
6173
  response: isRecord(message.data) ? message.data : {
5730
6174
  content: message.content,
5731
6175
  ...message.isError !== void 0 ? { isError: message.isError } : {}
@@ -5772,21 +6216,11 @@ async function geminiRequest(messages, attachments, uploader) {
5772
6216
  );
5773
6217
  const attachmentParts = await geminiAttachmentParts(attachments, uploader);
5774
6218
  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);
6219
+ const target = attachPartsToLastUserTurn(contents, () => ({
6220
+ role: "user",
6221
+ parts: []
6222
+ }));
6223
+ target.parts.push(...attachmentParts);
5790
6224
  }
5791
6225
  return {
5792
6226
  ...system ? {
@@ -5836,6 +6270,22 @@ function extractThoughts(value) {
5836
6270
  const joined = thoughts.join("\n").trim();
5837
6271
  return joined.length > 0 ? joined : void 0;
5838
6272
  }
6273
+ function extractThinking(value) {
6274
+ if (!Array.isArray(value)) {
6275
+ return void 0;
6276
+ }
6277
+ const thinking = value.flatMap((part) => {
6278
+ if (part && typeof part === "object" && part.type === "thinking") {
6279
+ const candidate = part.thinking;
6280
+ if (typeof candidate === "string") {
6281
+ return [candidate];
6282
+ }
6283
+ }
6284
+ return [];
6285
+ });
6286
+ const joined = thinking.join("\n").trim();
6287
+ return joined.length > 0 ? joined : void 0;
6288
+ }
5839
6289
  function imageAttachmentsFromPayload(payload, ...sources) {
5840
6290
  return extractImagePayloads(payload, ...sources).map(
5841
6291
  (item) => preparePortableFile(item)
@@ -5923,6 +6373,10 @@ function buildOpenAiChatBody(context, messages, parameters, responseFormat, opts
5923
6373
  model: context.route.model,
5924
6374
  messages,
5925
6375
  stream: opts.stream,
6376
+ // Ask OpenAI-compatible streaming routes for the trailing usage chunk; without
6377
+ // `stream_options.include_usage` the SSE stream carries no `usage` block and
6378
+ // `runOpenAiStream` would always report `usage: undefined` (H5).
6379
+ ...opts.stream ? { stream_options: { include_usage: true } } : {},
5926
6380
  ...parameters?.maxTokens !== void 0 ? { max_tokens: parameters.maxTokens } : {},
5927
6381
  ...parameters?.temperature !== void 0 ? { temperature: parameters.temperature } : {},
5928
6382
  ...parameters?.topP !== void 0 ? { top_p: parameters.topP } : {},
@@ -6077,6 +6531,11 @@ async function* runOpenAiStream(fetchImpl, context) {
6077
6531
  }
6078
6532
  const response = outcome.response;
6079
6533
  const streamWarnings = [...uploaderWarnings];
6534
+ if (parameters?.candidateCount && parameters.candidateCount > 1) {
6535
+ streamWarnings.push(
6536
+ "candidateCount > 1 is forwarded to OpenAI-compatible text routes, but the normalized result returns only the first choice."
6537
+ );
6538
+ }
6080
6539
  if (droppedResponseFormat) {
6081
6540
  streamWarnings.push(
6082
6541
  "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 +6681,7 @@ function buildOpenAiStreamResult(context, text, toolCalls, usage, warnings) {
6222
6681
  } : {}
6223
6682
  };
6224
6683
  }
6225
- async function runOpenAiImage(fetchImpl, context) {
6226
- const apiKey = resolveApiKey(context.route, context.runtime);
6684
+ async function runProviderImage(fetchImpl, context, opts) {
6227
6685
  const referenceImages = imageReferenceFiles(context);
6228
6686
  const bundle = buildImagePromptBundle({
6229
6687
  request: context.request,
@@ -6232,10 +6690,10 @@ async function runOpenAiImage(fetchImpl, context) {
6232
6690
  });
6233
6691
  const baseFields = buildImageRequestFields(context, bundle);
6234
6692
  const useEditEndpoint = shouldUseImageEditEndpoint(context);
6235
- const endpoint = useEditEndpoint ? resolveOpenAiImageEditEndpoint(context.route) : resolveOpenAiEndpoint(context.route, "image");
6693
+ const endpoint = useEditEndpoint ? opts.resolveEditEndpoint(context.route) : opts.resolveEndpoint(context.route);
6236
6694
  transportSummary(
6237
6695
  context,
6238
- useEditEndpoint ? "openai-image-edit" : "openai-image",
6696
+ useEditEndpoint ? `${opts.providerLabel}-image-edit` : `${opts.providerLabel}-image`,
6239
6697
  endpoint,
6240
6698
  "POST",
6241
6699
  false,
@@ -6248,23 +6706,21 @@ async function runOpenAiImage(fetchImpl, context) {
6248
6706
  );
6249
6707
  const response = await fetchImpl(endpoint, useEditEndpoint ? {
6250
6708
  method: "POST",
6251
- headers: {
6252
- authorization: `Bearer ${apiKey}`
6253
- },
6709
+ headers: { ...opts.authHeaders },
6254
6710
  body: await buildImageEditFormData(context, baseFields),
6255
6711
  signal: context.abort.signal
6256
6712
  } : {
6257
6713
  method: "POST",
6258
6714
  headers: {
6259
6715
  "content-type": "application/json",
6260
- authorization: `Bearer ${apiKey}`
6716
+ ...opts.authHeaders
6261
6717
  },
6262
6718
  body: JSON.stringify(baseFields),
6263
6719
  signal: context.abort.signal
6264
6720
  });
6265
6721
  const payload = await readPayload(response);
6266
6722
  if (!response.ok) {
6267
- throw classifyApiError("openai", response, payload);
6723
+ throw classifyApiError(opts.classifyProvider, response, payload);
6268
6724
  }
6269
6725
  return {
6270
6726
  data: {
@@ -6274,6 +6730,16 @@ async function runOpenAiImage(fetchImpl, context) {
6274
6730
  attachments: imageAttachmentsFromPayload(payload)
6275
6731
  };
6276
6732
  }
6733
+ async function runOpenAiImage(fetchImpl, context) {
6734
+ const apiKey = resolveApiKey(context.route, context.runtime);
6735
+ return runProviderImage(fetchImpl, context, {
6736
+ resolveEndpoint: (route) => resolveOpenAiEndpoint(route, "image"),
6737
+ resolveEditEndpoint: (route) => resolveOpenAiImageEditEndpoint(route),
6738
+ authHeaders: { authorization: `Bearer ${apiKey}` },
6739
+ providerLabel: "openai",
6740
+ classifyProvider: "openai"
6741
+ });
6742
+ }
6277
6743
  async function runAnthropic(fetchImpl, context) {
6278
6744
  const apiKey = resolveApiKey(context.route, context.runtime);
6279
6745
  const uploaderWarnings = [];
@@ -6285,6 +6751,8 @@ async function runAnthropic(fetchImpl, context) {
6285
6751
  const parameters = context.request.parameters;
6286
6752
  const requestBody = {
6287
6753
  model: context.route.model,
6754
+ // Anthropic REQUIRES `max_tokens` (unlike OpenAI/Gemini, where it is optional), so a
6755
+ // provider-specific default of 4096 is applied when the caller omits `maxTokens`.
6288
6756
  max_tokens: parameters?.maxTokens ?? 4096,
6289
6757
  ...parameters?.temperature !== void 0 ? { temperature: parameters.temperature } : {},
6290
6758
  ...parameters?.topP !== void 0 ? { top_p: parameters.topP } : {},
@@ -6314,8 +6782,12 @@ async function runAnthropic(fetchImpl, context) {
6314
6782
  throw classifyApiError("anthropic", response, payload);
6315
6783
  }
6316
6784
  const data = payload;
6785
+ const anthropicReasoning = extractThinking(data.content);
6317
6786
  return {
6318
6787
  text: extractText(data.content),
6788
+ // Anthropic extended-thinking blocks, separated from the answer (consumers route this
6789
+ // to a thinking UI), mirroring the Gemini `reasoning` path.
6790
+ ...anthropicReasoning ? { reasoning: anthropicReasoning } : {},
6319
6791
  data,
6320
6792
  ...anthropicToolCallsFromPayload(data).length > 0 ? { toolCalls: anthropicToolCallsFromPayload(data) } : {},
6321
6793
  ...uploaderWarnings.length > 0 ? { warnings: uploaderWarnings } : {},
@@ -6410,55 +6882,13 @@ async function runGemini(fetchImpl, context) {
6410
6882
  }
6411
6883
  async function runGeminiImage(fetchImpl, context) {
6412
6884
  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
6885
+ return runProviderImage(fetchImpl, context, {
6886
+ resolveEndpoint: (route) => resolveGeminiImageEndpoint(route),
6887
+ resolveEditEndpoint: (route) => resolveGeminiImageEditEndpoint(route),
6888
+ authHeaders: { authorization: `Bearer ${apiKey}` },
6889
+ providerLabel: "gemini",
6890
+ classifyProvider: context.route.provider
6418
6891
  });
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
6892
  }
6463
6893
  function verifyApiRoute(route, runtime) {
6464
6894
  resolveApiKey(route, runtime);
@@ -6537,9 +6967,12 @@ var AI_CONNECT_DEFAULT_SERVER_PRESETS = {
6537
6967
  transportId: "opencode-server"
6538
6968
  }
6539
6969
  };
6540
- var AI_CONNECT_DEFAULT_SERVER_COMMANDS = {
6541
- "opencode:opencode-server": AI_CONNECT_DEFAULT_SERVER_PRESETS.opencode.command
6542
- };
6970
+ var AI_CONNECT_DEFAULT_SERVER_COMMANDS = Object.fromEntries(
6971
+ Object.values(AI_CONNECT_DEFAULT_SERVER_PRESETS).map((preset) => [
6972
+ `${preset.id}:${preset.transportId}`,
6973
+ preset.command
6974
+ ])
6975
+ );
6543
6976
 
6544
6977
  // src/catalog.ts
6545
6978
  var TEXT_PROVIDER_CATALOG = [