@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
package/dist/bun/local.js CHANGED
@@ -16,7 +16,10 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
16
16
  // pi takes file/image attachments as separate `@<path>` argv tokens (REQ-024);
17
17
  // {files} expands one `@<staged-path>` token per attachment. pi stages all
18
18
  // categories (PDFs as-is, no extraction guarantee).
19
- argsTemplate: ["--print", "--model", "{model}", "{files}", "{prompt}"],
19
+ // H10 (flag injection): the `--` end-of-options token precedes {files}/{prompt}
20
+ // — both are BARE positionals here — so an `@<path>` reference or a prompt that
21
+ // starts with `-` is parsed by pi as a positional, never as a flag.
22
+ argsTemplate: ["--print", "--model", "{model}", "--", "{files}", "{prompt}"],
20
23
  discovery: {
21
24
  via: "none"
22
25
  },
@@ -36,13 +39,22 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
36
39
  transportId: "claude-cli",
37
40
  options: {
38
41
  preset: "claude",
39
- argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "{prompt}"],
42
+ // H10 (flag injection): `--` precedes the trailing {prompt} positional so a
43
+ // prompt starting with `-` (e.g. "--dangerously-skip-permissions") is parsed
44
+ // by the claude CLI as a positional, not a flag.
45
+ argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "--", "{prompt}"],
40
46
  discovery: {
41
47
  via: "acp",
42
48
  acp: {
43
49
  transportId: "claude-code-acp"
44
50
  }
45
51
  },
52
+ // SENTINEL (documentation-only): the `__preset__:*` textPath/errorPath values
53
+ // are NOT resolved as live JSON paths. This `parser` block records which
54
+ // dedicated parser a built-in preset maps to (parseClaudeCli); dispatch for a
55
+ // preset route goes through the `cliOptions.preset` switch in cli.ts
56
+ // (parseCliResult), which only reads a parser config when a ROUTE sets its own
57
+ // `cli.parser`. See the mirrored note in cli.ts::parseCliResult.
46
58
  parser: {
47
59
  kind: "json",
48
60
  textPath: "__preset__:claude.result",
@@ -58,7 +70,11 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
58
70
  transportId: "openclaude-cli",
59
71
  options: {
60
72
  preset: "openclaude",
61
- argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "{prompt}"],
73
+ // H10 (flag injection): `--` precedes the trailing {prompt} positional (see
74
+ // the claude preset above for rationale).
75
+ argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "--", "{prompt}"],
76
+ // SENTINEL (documentation-only): see the claude preset note — `__preset__:*`
77
+ // paths are markers, not live JSON paths; dispatch is via the preset switch.
62
78
  parser: {
63
79
  kind: "json",
64
80
  textPath: "__preset__:claude.result",
@@ -77,6 +93,11 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
77
93
  // codex exec takes image attachments via `-i/--image <FILE>` flags
78
94
  // (REQ-024); {files} expands a `--image <staged-path>` pair per image.
79
95
  // Images only — a non-image attachment cleanly rejects pre-spawn.
96
+ // H10 (flag injection): `--` sits AFTER {files} and before the trailing
97
+ // {prompt} positional. Unlike pi, codex files are passed via the explicit
98
+ // `--image <path>` flag (the path is that flag's VALUE, not a positional), so
99
+ // `--` must NOT precede {files} or it would neutralize `--image`. It guards
100
+ // only the {prompt} positional against being parsed as a flag.
80
101
  argsTemplate: [
81
102
  "exec",
82
103
  "--json",
@@ -85,6 +106,7 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
85
106
  "--model",
86
107
  "{model}",
87
108
  "{files}",
109
+ "--",
88
110
  "{prompt}"
89
111
  ],
90
112
  discovery: {
@@ -98,6 +120,11 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
98
120
  perFileArgs: ["--image", "{path}"],
99
121
  categories: ["image"]
100
122
  },
123
+ // SENTINEL (documentation-only): the `__preset__:*` path values below are
124
+ // markers naming the dedicated parser this preset maps to (parseCodexCli), NOT
125
+ // live JSONL selectors. Dispatch for a preset route runs through the
126
+ // `cliOptions.preset` switch in cli.ts::parseCliResult; this block is only ever
127
+ // read as a live parser when a ROUTE overrides `cli.parser`.
101
128
  parser: {
102
129
  kind: "jsonl",
103
130
  text: {
@@ -134,6 +161,19 @@ var AiConnectError = class extends Error {
134
161
  this.code = code;
135
162
  this.details = details;
136
163
  }
164
+ /**
165
+ * `Error.prototype.message` is non-enumerable, so a bare `JSON.stringify(err)` silently drops
166
+ * it (LOW, review-report-seed.md). Provide an explicit projection so serializing an
167
+ * `AiConnectError` (logs, wide events, error-response bodies) always carries the message.
168
+ */
169
+ toJSON() {
170
+ return {
171
+ name: this.name,
172
+ code: this.code,
173
+ message: this.message,
174
+ details: this.details
175
+ };
176
+ }
137
177
  };
138
178
  function mapAbortError(reason, message) {
139
179
  return reason === "timeout" ? new AiConnectError("timeout", message ?? "Operation timed out.") : new AiConnectError("aborted", message ?? "Operation aborted.");
@@ -161,6 +201,16 @@ function toAiConnectError(error, fallbackCode = "temporary_unavailable") {
161
201
  // src/config.ts
162
202
  var DEFAULT_OPERATIONS = ["text", "image", "file"];
163
203
  var DEFAULT_STRATEGY = "priority";
204
+ var CLI_TRANSPORT_ID_BY_PROVIDER = {
205
+ anthropic: "claude-cli",
206
+ openclaude: "openclaude-cli",
207
+ openai: "codex-cli",
208
+ pi: "pi-cli"
209
+ };
210
+ var ACP_TRANSPORT_ID_BY_PROVIDER = {
211
+ anthropic: "claude-code-acp",
212
+ openai: "codex-acp"
213
+ };
164
214
  var DEFAULT_FALLBACK_ACTIONS = {
165
215
  rate_limit: [
166
216
  "rotate-credential",
@@ -272,33 +322,64 @@ function assert(condition, message) {
272
322
  throw new AiConnectError("validation_error", message);
273
323
  }
274
324
  }
275
- function asPositiveInteger(value, fallback) {
325
+ function asPositiveInteger(value, fallback, fieldLabel) {
326
+ if (value === void 0) {
327
+ return fallback;
328
+ }
276
329
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
330
+ if (fieldLabel) {
331
+ console.warn(
332
+ `[ai-connect] ${fieldLabel} must be a positive finite number when provided; received ${JSON.stringify(value)}. Falling back to ${fallback}.`
333
+ );
334
+ }
277
335
  return fallback;
278
336
  }
279
337
  return Math.floor(value);
280
338
  }
281
- function asNonNegativeInteger(value, fallback) {
339
+ function asNonNegativeInteger(value, fallback, fieldLabel) {
340
+ if (value === void 0) {
341
+ return fallback;
342
+ }
282
343
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
344
+ if (fieldLabel) {
345
+ console.warn(
346
+ `[ai-connect] ${fieldLabel} must be a non-negative finite number when provided; received ${JSON.stringify(value)}. Falling back to ${fallback}.`
347
+ );
348
+ }
283
349
  return fallback;
284
350
  }
285
351
  return Math.floor(value);
286
352
  }
287
- function asOptionalContextWindow(value) {
353
+ function asOptionalContextWindow(value, fieldLabel) {
354
+ if (value === void 0) {
355
+ return void 0;
356
+ }
288
357
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
358
+ if (fieldLabel) {
359
+ console.warn(
360
+ `[ai-connect] ${fieldLabel} must be a positive finite number when provided; received ${JSON.stringify(value)}. Ignoring.`
361
+ );
362
+ }
289
363
  return void 0;
290
364
  }
291
365
  return Math.floor(value);
292
366
  }
293
- function normalizeModelInput(input, accountContextWindow) {
367
+ function normalizeModelInput(input, accountContextWindow, accountId, providerId) {
294
368
  if (typeof input === "string") {
295
369
  return {
296
370
  id: input.trim(),
297
371
  ...accountContextWindow !== void 0 ? { contextWindow: accountContextWindow } : {}
298
372
  };
299
373
  }
374
+ assert(
375
+ typeof input.id === "string" && input.id.trim().length > 0,
376
+ `Account "${accountId}" for provider "${providerId}" declared a model entry with a missing or empty "id".`
377
+ );
300
378
  const id = input.id.trim();
301
- const override = asOptionalContextWindow(input.contextWindow);
379
+ const override = asOptionalContextWindow(
380
+ input.contextWindow,
381
+ `Model "${id}" (account "${accountId}", provider "${providerId}") contextWindow`
382
+ );
302
383
  const contextWindow = override ?? accountContextWindow;
303
384
  return {
304
385
  id,
@@ -309,7 +390,8 @@ function asFanoutBound(value) {
309
390
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
310
391
  return Number.POSITIVE_INFINITY;
311
392
  }
312
- return Math.floor(value);
393
+ const floored = Math.floor(value);
394
+ return floored <= 0 ? Number.POSITIVE_INFINITY : floored;
313
395
  }
314
396
  function normalizeFanoutPolicy(policy) {
315
397
  return {
@@ -535,7 +617,7 @@ function normalizeTransport(providerId, input) {
535
617
  };
536
618
  }
537
619
  if (descriptor.kind === "cli") {
538
- const inferredId2 = providerId === "anthropic" ? "claude-cli" : providerId === "openclaude" ? "openclaude-cli" : providerId === "openai" ? "codex-cli" : providerId === "pi" ? "pi-cli" : "cli";
620
+ const inferredId2 = CLI_TRANSPORT_ID_BY_PROVIDER[providerId] ?? "cli";
539
621
  return {
540
622
  kind: "cli",
541
623
  id: descriptor.id?.trim() || inferredId2,
@@ -552,7 +634,7 @@ function normalizeTransport(providerId, input) {
552
634
  ...descriptor.baseUrl?.trim() ? { baseUrl: descriptor.baseUrl.trim() } : {}
553
635
  };
554
636
  }
555
- const inferredId = providerId === "anthropic" ? "claude-code-acp" : providerId === "openai" ? "codex-acp" : "acp";
637
+ const inferredId = ACP_TRANSPORT_ID_BY_PROVIDER[providerId] ?? "acp";
556
638
  const normalizedLaunch = descriptor.launch ? (() => {
557
639
  const contextMode = descriptor.launch?.contextMode ?? "workspace";
558
640
  const skillsMode = descriptor.launch?.skillsMode ?? "default";
@@ -649,20 +731,26 @@ function defaultCapabilities(transport, runtime) {
649
731
  }
650
732
  function normalizeCredential(providerId, accountId, transport, input, index) {
651
733
  const id = input.id?.trim() || `${accountId}-credential-${index + 1}`;
652
- const source = input.source?.trim() || (input.apiKey ? "literal" : input.apiKeyEnv ? "env" : transport.kind === "api" ? "env" : "session");
734
+ const trimmedApiKey = input.apiKey?.trim();
735
+ const trimmedApiKeyEnv = input.apiKeyEnv?.trim();
736
+ const source = input.source?.trim() || (trimmedApiKey ? "literal" : trimmedApiKeyEnv ? "env" : transport.kind === "api" ? "env" : "session");
653
737
  if (transport.kind === "api") {
654
738
  assert(
655
- Boolean(input.apiKey?.trim()) || Boolean(input.apiKeyEnv?.trim()),
739
+ Boolean(trimmedApiKey) || Boolean(trimmedApiKeyEnv),
656
740
  `API account "${accountId}" for provider "${providerId}" requires apiKey or apiKeyEnv credentials.`
657
741
  );
658
742
  }
659
743
  return {
660
744
  id,
661
745
  source,
662
- weight: asPositiveInteger(input.weight, 1),
746
+ weight: asPositiveInteger(
747
+ input.weight,
748
+ 1,
749
+ `Credential "${id}" (account "${accountId}", provider "${providerId}") weight`
750
+ ),
663
751
  metadata: input.metadata ?? {},
664
- ...input.apiKey?.trim() ? { apiKey: input.apiKey.trim() } : {},
665
- ...input.apiKeyEnv?.trim() ? { apiKeyEnv: input.apiKeyEnv.trim() } : {},
752
+ ...trimmedApiKey ? { apiKey: trimmedApiKey } : {},
753
+ ...trimmedApiKeyEnv ? { apiKeyEnv: trimmedApiKeyEnv } : {},
666
754
  ...input.apiKeyDelimiter ? { apiKeyDelimiter: input.apiKeyDelimiter } : {}
667
755
  };
668
756
  }
@@ -696,6 +784,31 @@ function buildRouteId(route) {
696
784
  slugify(route.model)
697
785
  ].join(":");
698
786
  }
787
+ function assertRouteIdUniqueness(routes) {
788
+ const byId = /* @__PURE__ */ new Map();
789
+ for (const route of routes) {
790
+ const group = byId.get(route.id);
791
+ if (group) {
792
+ group.push(route);
793
+ } else {
794
+ byId.set(route.id, [route]);
795
+ }
796
+ }
797
+ for (const [id, group] of byId) {
798
+ if (group.length <= 1) {
799
+ continue;
800
+ }
801
+ const distinctModels = [...new Set(group.map((route) => route.model))];
802
+ if (distinctModels.length <= 1) {
803
+ continue;
804
+ }
805
+ const first = group[0];
806
+ assert(
807
+ false,
808
+ `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.`
809
+ );
810
+ }
811
+ }
699
812
  function routeAliases(route) {
700
813
  return [
701
814
  route.provider,
@@ -820,7 +933,8 @@ function defineConfig(input) {
820
933
  const transport = normalizeTransport(providerId, accountInput.transport);
821
934
  const runtime = normalizeRuntime(transport, accountInput.runtime);
822
935
  const accountContextWindow = asOptionalContextWindow(
823
- accountInput.contextWindow
936
+ accountInput.contextWindow,
937
+ `Account "${accountId}" (provider "${providerId}") contextWindow`
824
938
  );
825
939
  const accountModelAllowlistMode = accountInput.modelAllowlistMode ?? "strict";
826
940
  const accountContextMode = accountInput.contextMode ?? "workspace";
@@ -830,7 +944,9 @@ function defineConfig(input) {
830
944
  Array.isArray(accountInput.models) && accountInput.models.length > 0,
831
945
  `Account "${accountId}" for provider "${providerId}" must declare at least one model.`
832
946
  );
833
- const models = accountInput.models.map((model) => normalizeModelInput(model, accountContextWindow)).filter((model) => model.id.length > 0);
947
+ const models = accountInput.models.map(
948
+ (model) => normalizeModelInput(model, accountContextWindow, accountId, providerId)
949
+ ).filter((model) => model.id.length > 0);
834
950
  assert(
835
951
  models.length > 0,
836
952
  `Account "${accountId}" for provider "${providerId}" must declare at least one model.`
@@ -860,8 +976,16 @@ function defineConfig(input) {
860
976
  credentials,
861
977
  capabilities,
862
978
  verify,
863
- weight: asPositiveInteger(accountInput.weight, 1),
864
- priority: asNonNegativeInteger(accountInput.priority, accountIndex),
979
+ weight: asPositiveInteger(
980
+ accountInput.weight,
981
+ 1,
982
+ `Account "${accountId}" (provider "${providerId}") weight`
983
+ ),
984
+ priority: asNonNegativeInteger(
985
+ accountInput.priority,
986
+ accountIndex,
987
+ `Account "${accountId}" (provider "${providerId}") priority`
988
+ ),
865
989
  enabled: accountInput.enabled ?? true
866
990
  };
867
991
  accounts.push(normalizedAccount);
@@ -908,6 +1032,7 @@ function defineConfig(input) {
908
1032
  };
909
1033
  }
910
1034
  assert(routes.length > 0, "At least one enabled route must be produced.");
1035
+ assertRouteIdUniqueness(routes);
911
1036
  finalizeRotationCapabilities(routes);
912
1037
  const strategy = input.routing?.strategy ?? DEFAULT_STRATEGY;
913
1038
  const resolutionInput = input.routing?.resolution;
@@ -981,6 +1106,10 @@ function resolveRouteSelectors(selectors, routes, policy) {
981
1106
  }
982
1107
 
983
1108
  // src/fanout.ts
1109
+ function abortedError() {
1110
+ return new AiConnectError("aborted", "Operation aborted.");
1111
+ }
1112
+ var MAX_DELAY_MS = 24 * 60 * 60 * 1e3;
984
1113
  function createFanoutLimiter(policy, runtime) {
985
1114
  const now = () => runtime.now?.() ?? Date.now();
986
1115
  const { maxConcurrency, requestsPerSecond, maxCalls } = policy;
@@ -1044,10 +1173,10 @@ function createFanoutLimiter(policy, runtime) {
1044
1173
  if (index >= 0) {
1045
1174
  concurrencyQueue.splice(index, 1);
1046
1175
  }
1047
- rejectWaiter(waiter, new AiConnectError("aborted", "Operation aborted."));
1176
+ rejectWaiter(waiter, abortedError());
1048
1177
  };
1049
1178
  if (signal.aborted) {
1050
- reject(new AiConnectError("aborted", "Operation aborted."));
1179
+ reject(abortedError());
1051
1180
  return;
1052
1181
  }
1053
1182
  waiter.signal = signal;
@@ -1063,7 +1192,7 @@ function createFanoutLimiter(policy, runtime) {
1063
1192
  }
1064
1193
  while (true) {
1065
1194
  if (signal?.aborted) {
1066
- throw new AiConnectError("aborted", "Operation aborted.");
1195
+ throw abortedError();
1067
1196
  }
1068
1197
  refill();
1069
1198
  if (tokens >= 1) {
@@ -1075,6 +1204,7 @@ function createFanoutLimiter(policy, runtime) {
1075
1204
  }
1076
1205
  }
1077
1206
  function delay(ms, signal) {
1207
+ const safeMs = Number.isFinite(ms) && ms >= 0 ? Math.min(ms, MAX_DELAY_MS) : MAX_DELAY_MS;
1078
1208
  return new Promise((resolve, reject) => {
1079
1209
  let cancelTimer;
1080
1210
  const onAbort = () => {
@@ -1082,11 +1212,11 @@ function createFanoutLimiter(policy, runtime) {
1082
1212
  if (signal) {
1083
1213
  signal.removeEventListener("abort", onAbort);
1084
1214
  }
1085
- reject(new AiConnectError("aborted", "Operation aborted."));
1215
+ reject(abortedError());
1086
1216
  };
1087
1217
  if (signal) {
1088
1218
  if (signal.aborted) {
1089
- reject(new AiConnectError("aborted", "Operation aborted."));
1219
+ reject(abortedError());
1090
1220
  return;
1091
1221
  }
1092
1222
  signal.addEventListener("abort", onAbort, { once: true });
@@ -1098,9 +1228,9 @@ function createFanoutLimiter(policy, runtime) {
1098
1228
  resolve();
1099
1229
  };
1100
1230
  if (runtime.setTimer) {
1101
- cancelTimer = runtime.setTimer(ms, done);
1231
+ cancelTimer = runtime.setTimer(safeMs, done);
1102
1232
  } else {
1103
- const handle = setTimeout(done, ms);
1233
+ const handle = setTimeout(done, safeMs);
1104
1234
  cancelTimer = () => clearTimeout(handle);
1105
1235
  }
1106
1236
  });
@@ -1114,8 +1244,13 @@ function createFanoutLimiter(policy, runtime) {
1114
1244
  );
1115
1245
  }
1116
1246
  callsStarted += 1;
1117
- await awaitRateToken(signal);
1118
- await awaitConcurrencySlot(signal);
1247
+ try {
1248
+ await awaitRateToken(signal);
1249
+ await awaitConcurrencySlot(signal);
1250
+ } catch (error) {
1251
+ callsStarted -= 1;
1252
+ throw error;
1253
+ }
1119
1254
  let released = false;
1120
1255
  return () => {
1121
1256
  if (released) {
@@ -1203,19 +1338,54 @@ function isRemoteUrl(value) {
1203
1338
  return false;
1204
1339
  }
1205
1340
  }
1341
+ function mimeTypeAndExtensionFromMediaType(mediaTypeAndParams, fallbackMimeType, fallbackExtension) {
1342
+ const mimeType = mediaTypeAndParams.split(";")[0]?.trim() || fallbackMimeType;
1343
+ const extension2 = mimeType.split("/")[1] ?? fallbackExtension;
1344
+ return { mimeType, extension: extension2 };
1345
+ }
1206
1346
  function parseDataUrl(value) {
1207
- const match = /^data:([^,]*?);base64,(.*)$/i.exec(value);
1208
- if (!match) {
1209
- return null;
1347
+ const base64Match = /^data:([^,]*?);base64,(.*)$/i.exec(value);
1348
+ if (base64Match) {
1349
+ const { mimeType, extension: extension2 } = mimeTypeAndExtensionFromMediaType(
1350
+ base64Match[1] ?? "",
1351
+ "application/octet-stream",
1352
+ "bin"
1353
+ );
1354
+ return {
1355
+ mimeType,
1356
+ extension: extension2,
1357
+ data: base64Match[2] ?? ""
1358
+ };
1210
1359
  }
1211
- const mediaTypeAndParams = match[1] ?? "";
1212
- const mimeType = mediaTypeAndParams.split(";")[0]?.trim() || "application/octet-stream";
1213
- const extension2 = mimeType.split("/")[1] ?? "bin";
1214
- return {
1215
- mimeType,
1216
- extension: extension2,
1217
- data: match[2] ?? ""
1218
- };
1360
+ const plainMatch = /^data:([^,]*?),(.*)$/i.exec(value);
1361
+ if (plainMatch) {
1362
+ const { mimeType, extension: extension2 } = mimeTypeAndExtensionFromMediaType(
1363
+ plainMatch[1] ?? "",
1364
+ "text/plain",
1365
+ "txt"
1366
+ );
1367
+ let decoded;
1368
+ try {
1369
+ decoded = decodeURIComponent(plainMatch[2] ?? "");
1370
+ } catch {
1371
+ throw new AiConnectError(
1372
+ "validation_error",
1373
+ "unsupported data URL encoding"
1374
+ );
1375
+ }
1376
+ return {
1377
+ mimeType,
1378
+ extension: extension2,
1379
+ data: encodeBase64(new TextEncoder().encode(decoded))
1380
+ };
1381
+ }
1382
+ if (/^data:/i.test(value)) {
1383
+ throw new AiConnectError(
1384
+ "validation_error",
1385
+ "unsupported data URL encoding"
1386
+ );
1387
+ }
1388
+ return null;
1219
1389
  }
1220
1390
  function mimeTypeFromName(name) {
1221
1391
  const ext = extension(name);
@@ -1429,9 +1599,61 @@ async function materializePortableFile(file) {
1429
1599
  payload.dataUrl = `data:${mimeType};base64,${base64}`;
1430
1600
  }
1431
1601
  }
1602
+ if (category === "other") {
1603
+ const base64 = await portableFileToBase64(file);
1604
+ if (base64 !== void 0) {
1605
+ payload.base64 = base64;
1606
+ payload.dataUrl = `data:${mimeType};base64,${base64}`;
1607
+ } else if (!uri && !providerFileId) {
1608
+ 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.`;
1609
+ payload.droppedReason = droppedReason;
1610
+ payload.warnings = [...payload.warnings ?? [], droppedReason];
1611
+ }
1612
+ }
1432
1613
  return payload;
1433
1614
  }
1434
- function preparePortableFile(input) {
1615
+ function collapsePathSegments(value) {
1616
+ const normalized = value.replaceAll("\\", "/");
1617
+ const isAbsolute = normalized.startsWith("/");
1618
+ const segments = normalized.split("/");
1619
+ const resolved = [];
1620
+ for (const segment of segments) {
1621
+ if (segment === "" || segment === ".") {
1622
+ continue;
1623
+ }
1624
+ if (segment === "..") {
1625
+ if (resolved.length > 0 && resolved[resolved.length - 1] !== "..") {
1626
+ resolved.pop();
1627
+ } else if (!isAbsolute) {
1628
+ resolved.push("..");
1629
+ }
1630
+ continue;
1631
+ }
1632
+ resolved.push(segment);
1633
+ }
1634
+ return (isAbsolute ? "/" : "") + resolved.join("/");
1635
+ }
1636
+ function applyPathGuard(rawPath, guard) {
1637
+ if (!guard) {
1638
+ return rawPath;
1639
+ }
1640
+ if (guard === "basename") {
1641
+ return basename(rawPath);
1642
+ }
1643
+ const resolved = collapsePathSegments(rawPath);
1644
+ const withinAllowedRoot = guard.allowedRoots.some((root) => {
1645
+ const resolvedRoot = collapsePathSegments(root);
1646
+ return resolved === resolvedRoot || resolved.startsWith(`${resolvedRoot}/`);
1647
+ });
1648
+ if (!withinAllowedRoot) {
1649
+ throw new AiConnectError(
1650
+ "validation_error",
1651
+ `Path "${rawPath}" is outside the configured allowedRoots for portable file attachments.`
1652
+ );
1653
+ }
1654
+ return rawPath;
1655
+ }
1656
+ function preparePortableFile(input, options) {
1435
1657
  if (isPortableFile(input)) {
1436
1658
  return input;
1437
1659
  }
@@ -1457,11 +1679,12 @@ function preparePortableFile(input) {
1457
1679
  }
1458
1680
  };
1459
1681
  }
1682
+ const guardedPath = applyPathGuard(input, options?.pathGuard);
1460
1683
  return {
1461
- id: `path:${input}`,
1684
+ id: `path:${guardedPath}`,
1462
1685
  kind: "path",
1463
- name: basename(input),
1464
- source: input
1686
+ name: basename(guardedPath),
1687
+ source: guardedPath
1465
1688
  };
1466
1689
  }
1467
1690
  if (isRemoteReference(input)) {
@@ -1502,7 +1725,20 @@ function preparePortableFile(input) {
1502
1725
  }
1503
1726
 
1504
1727
  // src/logging.ts
1505
- async function shouldEmitWideEvent(event, options = {}) {
1728
+ function redactBaseUrl(url) {
1729
+ try {
1730
+ const parsed = new URL(url);
1731
+ parsed.username = "";
1732
+ parsed.password = "";
1733
+ parsed.search = "";
1734
+ parsed.hash = "";
1735
+ return parsed.toString();
1736
+ } catch {
1737
+ const withoutHash = url.split("#")[0] ?? url;
1738
+ return withoutHash.split("?")[0] ?? withoutHash;
1739
+ }
1740
+ }
1741
+ async function shouldEmitWideEvent(event, options = {}, random = Math.random) {
1506
1742
  const sampleRate = options.sampleRate ?? 1;
1507
1743
  if ((options.keepErrors ?? true) && event.outcome === "error") {
1508
1744
  return true;
@@ -1536,7 +1772,7 @@ async function shouldEmitWideEvent(event, options = {}) {
1536
1772
  if (sampleRate >= 1) {
1537
1773
  return true;
1538
1774
  }
1539
- return Math.random() < sampleRate;
1775
+ return random() < sampleRate;
1540
1776
  }
1541
1777
 
1542
1778
  // src/router.ts
@@ -1601,6 +1837,19 @@ function buildWeightedOrder(routes) {
1601
1837
  }
1602
1838
  return expanded;
1603
1839
  }
1840
+ var ROUTING_STRATEGIES = {
1841
+ priority: (ordered) => ordered,
1842
+ failover: (ordered) => ordered,
1843
+ "round-robin": (ordered, cursor, cursorKey) => {
1844
+ const index = cursor(cursorKey, ordered.length);
1845
+ return rotate(ordered, index);
1846
+ },
1847
+ "weighted-round-robin": (ordered, cursor, cursorKey) => {
1848
+ const weighted = buildWeightedOrder(ordered);
1849
+ const index = cursor(cursorKey, weighted.length);
1850
+ return unique(rotate(weighted, index));
1851
+ }
1852
+ };
1604
1853
  var RouteRegistry = class {
1605
1854
  config;
1606
1855
  runtime;
@@ -1671,14 +1920,21 @@ var RouteRegistry = class {
1671
1920
  const routeIds = request.routeHints?.pool?.length ? resolveRouteSelectors(request.routeHints.pool, this.config.routes) : operationRouting.pool;
1672
1921
  const excludedIds = new Set(request.routeHints?.excludeRouteIds ?? []);
1673
1922
  const requestedModel = request.routeHints?.model;
1674
- 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) => {
1675
- const state = this.getHealth(route).state;
1676
- return state !== "cooling_down" && state !== "unavailable";
1677
- }).map((route) => applyRequestedModel(route, requestedModel)).filter((route) => Boolean(route));
1923
+ 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));
1924
+ const strategy = request.routeHints?.strategy ?? operationRouting.strategy;
1925
+ const cursorKey = `${operation}:${strategy}:${eligible.map((route) => route.id).join("|")}`;
1926
+ const healthyIds = new Set(
1927
+ eligible.filter((route) => {
1928
+ const state = this.getHealth(route).state;
1929
+ return state !== "cooling_down" && state !== "unavailable";
1930
+ }).map((route) => route.id)
1931
+ );
1678
1932
  return this.#order(
1679
- request.routeHints?.strategy ?? operationRouting.strategy,
1680
- available,
1681
- request.routeHints?.preferredProviders
1933
+ strategy,
1934
+ eligible,
1935
+ healthyIds,
1936
+ request.routeHints?.preferredProviders,
1937
+ cursorKey
1682
1938
  );
1683
1939
  }
1684
1940
  recordSuccess(route) {
@@ -1711,20 +1967,12 @@ var RouteRegistry = class {
1711
1967
  failureCount
1712
1968
  });
1713
1969
  }
1714
- #order(strategy, routes, preferredProviders) {
1715
- const sorted = baseSort(routes, preferredProviders);
1716
- const cursorKey = `${strategy}:${sorted.map((route) => route.id).join("|")}`;
1717
- const ordered = this.config.routing.shuffleOnInit ? this.#shuffle(cursorKey, sorted) : sorted;
1718
- if (strategy === "priority" || strategy === "failover") {
1719
- return ordered;
1720
- }
1721
- if (strategy === "round-robin") {
1722
- const index2 = this.#cursor(cursorKey, ordered.length);
1723
- return rotate(ordered, index2);
1724
- }
1725
- const weighted = buildWeightedOrder(ordered);
1726
- const index = this.#cursor(cursorKey, weighted.length);
1727
- return unique(rotate(weighted, index));
1970
+ #order(strategy, eligible, healthyIds, preferredProviders, cursorKey) {
1971
+ const sorted = baseSort(eligible, preferredProviders);
1972
+ const orderedFull = this.config.routing.shuffleOnInit ? this.#shuffle(cursorKey, sorted) : sorted;
1973
+ const ordered = orderedFull.filter((route) => healthyIds.has(route.id));
1974
+ const strategyFn = ROUTING_STRATEGIES[strategy];
1975
+ return strategyFn(ordered, (key, length) => this.#cursor(key, length), cursorKey);
1728
1976
  }
1729
1977
  #shuffle(key, routes) {
1730
1978
  const cached = this.#shuffles.get(key);
@@ -1828,8 +2076,22 @@ var MODEL_REFERENCE = {
1828
2076
  "gemini-1.5-flash": { key: "gemini-1.5-flash", contextLength: 1048576 },
1829
2077
  "gemini-1.5-pro": { key: "gemini-1.5-pro", contextLength: 2097152 }
1830
2078
  };
2079
+ var REASONING_EFFORT_SUFFIXES = /* @__PURE__ */ new Set([
2080
+ "minimal",
2081
+ "low",
2082
+ "medium",
2083
+ "high",
2084
+ "xhigh"
2085
+ ]);
1831
2086
  function normalizeModelKey(model) {
1832
2087
  let key = model.trim().toLowerCase();
2088
+ const lastSlashIndex = key.lastIndexOf("/");
2089
+ if (lastSlashIndex > 0 && lastSlashIndex < key.length - 1) {
2090
+ const tail = key.slice(lastSlashIndex + 1);
2091
+ if (REASONING_EFFORT_SUFFIXES.has(tail)) {
2092
+ key = key.slice(0, lastSlashIndex);
2093
+ }
2094
+ }
1833
2095
  const slashIndex = key.indexOf("/");
1834
2096
  if (slashIndex > 0) {
1835
2097
  key = key.slice(slashIndex + 1);
@@ -1837,6 +2099,7 @@ function normalizeModelKey(model) {
1837
2099
  key = key.replace(/:(?:free|beta)$/i, "");
1838
2100
  return key.trim();
1839
2101
  }
2102
+ var MIN_SUBSTRING_MATCH_KEY_LENGTH = 4;
1840
2103
  function lookupModelRef(model) {
1841
2104
  const normalized = normalizeModelKey(model);
1842
2105
  if (!normalized) {
@@ -1859,7 +2122,7 @@ function lookupModelRef(model) {
1859
2122
  }
1860
2123
  let substringWinner;
1861
2124
  for (const [key, entry] of Object.entries(MODEL_REFERENCE)) {
1862
- if (normalized.includes(key)) {
2125
+ if (key.length >= MIN_SUBSTRING_MATCH_KEY_LENGTH && normalized.includes(key)) {
1863
2126
  if (!substringWinner || key.length > substringWinner.key.length) {
1864
2127
  substringWinner = entry;
1865
2128
  }
@@ -2091,6 +2354,14 @@ var DEFAULT_LOGGING_SERVICE = "ai-connect";
2091
2354
  function splitCredentialValues(value, delimiter2) {
2092
2355
  return value.split(delimiter2).map((item) => item.trim()).filter(Boolean);
2093
2356
  }
2357
+ var ClientToolExecutionError = class extends Error {
2358
+ aiError;
2359
+ constructor(aiError) {
2360
+ super(aiError.message);
2361
+ this.name = "ClientToolExecutionError";
2362
+ this.aiError = aiError;
2363
+ }
2364
+ };
2094
2365
  function materializeRuntimeConfig(config, runtime) {
2095
2366
  const routeExpansion = /* @__PURE__ */ new Map();
2096
2367
  const routes = [];
@@ -2105,13 +2376,14 @@ function materializeRuntimeConfig(config, runtime) {
2105
2376
  continue;
2106
2377
  }
2107
2378
  const expandedIds = [];
2379
+ const expandedWeight = Math.max(1, Math.floor(route.weight / values.length));
2108
2380
  values.forEach((apiKey, index) => {
2109
2381
  const credentialId = `${route.credentialId}#${index + 1}`;
2110
2382
  const expandedRoute = {
2111
2383
  ...route,
2112
2384
  id: `${route.id}#${index + 1}`,
2113
2385
  credentialId,
2114
- weight: 1,
2386
+ weight: expandedWeight,
2115
2387
  credential: {
2116
2388
  ...route.credential,
2117
2389
  id: credentialId,
@@ -2152,6 +2424,27 @@ async function sleep(delayMs) {
2152
2424
  }
2153
2425
  await new Promise((resolve) => setTimeout(resolve, delayMs));
2154
2426
  }
2427
+ async function runWithAbortRace(handlerPromise, abort) {
2428
+ if (abort.signal.aborted) {
2429
+ void Promise.resolve(handlerPromise).catch(() => {
2430
+ });
2431
+ throw mapAbortError(abort.reason);
2432
+ }
2433
+ let onAbort;
2434
+ const abortPromise = new Promise((_, reject) => {
2435
+ onAbort = () => reject(mapAbortError(abort.reason));
2436
+ abort.signal.addEventListener("abort", onAbort, { once: true });
2437
+ });
2438
+ try {
2439
+ return await Promise.race([handlerPromise, abortPromise]);
2440
+ } finally {
2441
+ if (onAbort) {
2442
+ abort.signal.removeEventListener("abort", onAbort);
2443
+ }
2444
+ void Promise.resolve(handlerPromise).catch(() => {
2445
+ });
2446
+ }
2447
+ }
2155
2448
  function normalizeWorkingDirectory(value) {
2156
2449
  if (value === void 0) {
2157
2450
  return void 0;
@@ -2193,6 +2486,12 @@ function normalizeClientToolRegistry(tools) {
2193
2486
  const registry = /* @__PURE__ */ new Map();
2194
2487
  for (const tool of tools ?? []) {
2195
2488
  const normalized = normalizeClientToolDefinition(tool);
2489
+ if (registry.has(normalized.function.name)) {
2490
+ throw new AiConnectError(
2491
+ "validation_error",
2492
+ `Duplicate client tool name "${normalized.function.name}" registered on this client.`
2493
+ );
2494
+ }
2196
2495
  registry.set(normalized.function.name, normalized);
2197
2496
  }
2198
2497
  return registry;
@@ -2218,10 +2517,22 @@ function resolveClientToolSelections(selections, registry) {
2218
2517
  `No client tool named "${name}" is registered on this client.`
2219
2518
  );
2220
2519
  }
2520
+ if (resolved.has(name)) {
2521
+ throw new AiConnectError(
2522
+ "validation_error",
2523
+ `Duplicate client tool "${name}" in the clientTools selection.`
2524
+ );
2525
+ }
2221
2526
  resolved.set(name, registered);
2222
2527
  continue;
2223
2528
  }
2224
2529
  const normalized = normalizeClientToolDefinition(selection);
2530
+ if (resolved.has(normalized.function.name)) {
2531
+ throw new AiConnectError(
2532
+ "validation_error",
2533
+ `Duplicate client tool "${normalized.function.name}" in the clientTools selection.`
2534
+ );
2535
+ }
2225
2536
  resolved.set(normalized.function.name, normalized);
2226
2537
  }
2227
2538
  return Array.from(resolved.values());
@@ -2334,6 +2645,34 @@ function requiresImageInput(request) {
2334
2645
  function requiresFileUpload(request) {
2335
2646
  return request.attachments.some((file) => isPortableDocumentFile(file));
2336
2647
  }
2648
+ function resolveRequiredCapabilities(request) {
2649
+ const requiresSchema = requiresToolSchema(request);
2650
+ const requiresClientTools = requiresClientToolExecution(request);
2651
+ const requiresImage = requiresImageInput(request);
2652
+ const requiresFile = requiresFileUpload(request);
2653
+ const requiresAnyCapability = requiresSchema || requiresClientTools || requiresImage || requiresFile;
2654
+ const projected = requiresAnyCapability ? {
2655
+ ...request,
2656
+ routeHints: {
2657
+ ...request.routeHints ?? {},
2658
+ requiredCapabilities: {
2659
+ ...request.routeHints?.requiredCapabilities ?? {},
2660
+ ...requiresSchema ? { supportsToolSchema: true } : {},
2661
+ ...requiresClientTools ? { supportsClientToolExecution: true } : {},
2662
+ ...requiresImage ? { supportsImageInput: true } : {},
2663
+ ...requiresFile ? { supportsFileUpload: true } : {}
2664
+ }
2665
+ }
2666
+ } : request;
2667
+ return {
2668
+ requiresSchema,
2669
+ requiresClientTools,
2670
+ requiresImage,
2671
+ requiresFile,
2672
+ requiresAnyCapability,
2673
+ request: projected
2674
+ };
2675
+ }
2337
2676
  function normalizeRequestBase(request) {
2338
2677
  if (request.messages.length === 0) {
2339
2678
  throw new AiConnectError("validation_error", "Unified generate/stream requests must include at least one message.");
@@ -2542,7 +2881,10 @@ function summarizeRoute(route) {
2542
2881
  model: route.model,
2543
2882
  handlerKey: route.handlerKey,
2544
2883
  profileId: route.profileId,
2545
- ...route.transport.baseUrl ? { baseUrl: route.transport.baseUrl } : {}
2884
+ // SYS-3 / C1a: never emit a raw baseUrl into a wide event — a token embedded in
2885
+ // userinfo (`https://user:secret@host`) or the query string would leak verbatim,
2886
+ // unlike the secret-safe toPublicRoute projection. Strip credentials first.
2887
+ ...route.transport.baseUrl ? { baseUrl: redactBaseUrl(route.transport.baseUrl) } : {}
2546
2888
  };
2547
2889
  }
2548
2890
  function summarizeFiles(files) {
@@ -2853,21 +3195,40 @@ function createClient(configOrInput, options = {}) {
2853
3195
  for (const toolCall of toolCalls) {
2854
3196
  const tool = registry.get(toolCall.name);
2855
3197
  if (!tool) {
2856
- throw new AiConnectError(
2857
- "validation_error",
2858
- `No client tool handler is registered for "${toolCall.name}".`
3198
+ throw new ClientToolExecutionError(
3199
+ new AiConnectError(
3200
+ "validation_error",
3201
+ `No client tool handler is registered for "${toolCall.name}".`
3202
+ )
3203
+ );
3204
+ }
3205
+ if (abort.signal.aborted) {
3206
+ throw mapAbortError(abort.reason);
3207
+ }
3208
+ let execution;
3209
+ try {
3210
+ execution = normalizeToolResult(
3211
+ await tool.execute(toolCall.arguments, {
3212
+ route,
3213
+ request,
3214
+ runtime,
3215
+ toolCall,
3216
+ messages,
3217
+ ...request.workingDirectory ? { workingDirectory: request.workingDirectory } : {}
3218
+ })
3219
+ );
3220
+ } catch (error) {
3221
+ if (abort.signal.aborted) {
3222
+ throw mapAbortError(abort.reason);
3223
+ }
3224
+ throw new ClientToolExecutionError(
3225
+ new AiConnectError(
3226
+ "validation_error",
3227
+ `Client tool "${tool.function.name}" threw during execution: ${error instanceof Error ? error.message : String(error)}`,
3228
+ { cause: error }
3229
+ )
2859
3230
  );
2860
3231
  }
2861
- const execution = normalizeToolResult(
2862
- await tool.execute(toolCall.arguments, {
2863
- route,
2864
- request,
2865
- runtime,
2866
- toolCall,
2867
- messages,
2868
- ...request.workingDirectory ? { workingDirectory: request.workingDirectory } : {}
2869
- })
2870
- );
2871
3232
  toolMessages.push({
2872
3233
  role: "tool",
2873
3234
  content: execution.content,
@@ -2881,28 +3242,33 @@ function createClient(configOrInput, options = {}) {
2881
3242
  if (abort.signal.aborted) {
2882
3243
  throw mapAbortError(abort.reason);
2883
3244
  }
2884
- output = await handler({
2885
- route,
2886
- request: {
2887
- ...request,
2888
- messages,
2889
- attachments: []
2890
- },
2891
- runtime,
2892
- attempt: baseAttempt,
2893
- abort,
2894
- telemetry: {
2895
- captureTransport(summary) {
2896
- transportSummary2 = summary;
3245
+ output = await runWithAbortRace(
3246
+ handler({
3247
+ route,
3248
+ request: {
3249
+ ...request,
3250
+ messages,
3251
+ attachments: []
3252
+ },
3253
+ runtime,
3254
+ attempt: baseAttempt,
3255
+ abort,
3256
+ telemetry: {
3257
+ captureTransport(summary) {
3258
+ transportSummary2 = summary;
3259
+ }
2897
3260
  }
2898
- }
2899
- });
3261
+ }),
3262
+ abort
3263
+ );
2900
3264
  warnings.push(...output.warnings ?? []);
2901
3265
  usage = mergeUsage(usage, output.usage);
2902
3266
  }
2903
- throw new AiConnectError(
2904
- "validation_error",
2905
- "clientTools exceeded the maximum tool-call loop depth of 8."
3267
+ throw new ClientToolExecutionError(
3268
+ new AiConnectError(
3269
+ "validation_error",
3270
+ "clientTools exceeded the maximum tool-call loop depth of 8."
3271
+ )
2906
3272
  );
2907
3273
  }
2908
3274
  async function emitWideEvent(event) {
@@ -2933,26 +3299,8 @@ function createClient(configOrInput, options = {}) {
2933
3299
  async function executeGenerateOperation(normalizedRequest, operationName, abort, seed = {}) {
2934
3300
  const startedAt = seed.startMs ?? nowMs(runtime);
2935
3301
  const requestId = seed.requestId ?? resolveEventRequestId(normalizedRequest.logContext);
2936
- const requiresSchema = requiresToolSchema(normalizedRequest);
2937
- const requiresClientTools = requiresClientToolExecution(normalizedRequest);
2938
- const requiresImage = requiresImageInput(normalizedRequest);
2939
- const requiresFile = requiresFileUpload(normalizedRequest);
2940
- const requiresAnyCapability = requiresSchema || requiresClientTools || requiresImage || requiresFile;
2941
- const resolvedCandidates = router.resolveCandidates(
2942
- requiresAnyCapability ? {
2943
- ...normalizedRequest,
2944
- routeHints: {
2945
- ...normalizedRequest.routeHints ?? {},
2946
- requiredCapabilities: {
2947
- ...normalizedRequest.routeHints?.requiredCapabilities ?? {},
2948
- ...requiresSchema ? { supportsToolSchema: true } : {},
2949
- ...requiresClientTools ? { supportsClientToolExecution: true } : {},
2950
- ...requiresImage ? { supportsImageInput: true } : {},
2951
- ...requiresFile ? { supportsFileUpload: true } : {}
2952
- }
2953
- }
2954
- } : normalizedRequest
2955
- );
3302
+ const { requiresImage, requiresFile, request: capabilityScopedRequest } = resolveRequiredCapabilities(normalizedRequest);
3303
+ const resolvedCandidates = router.resolveCandidates(capabilityScopedRequest);
2956
3304
  const candidates = seed.candidates ?? resolvedCandidates.map(summarizeRoute);
2957
3305
  const requestSummary = summarizeRequest(normalizedRequest, candidates);
2958
3306
  if (resolvedCandidates.length === 0) {
@@ -3040,18 +3388,21 @@ function createClient(configOrInput, options = {}) {
3040
3388
  const attemptStartedAt = nowMs(runtime);
3041
3389
  let transportSummary2;
3042
3390
  try {
3043
- let output = await handler.generate({
3044
- route,
3045
- request: routeRequest,
3046
- runtime,
3047
- attempt: attempts.length + 1,
3048
- abort,
3049
- telemetry: {
3050
- captureTransport(summary) {
3051
- transportSummary2 = summary;
3391
+ let output = await runWithAbortRace(
3392
+ handler.generate({
3393
+ route,
3394
+ request: routeRequest,
3395
+ runtime,
3396
+ attempt: attempts.length + 1,
3397
+ abort,
3398
+ telemetry: {
3399
+ captureTransport(summary) {
3400
+ transportSummary2 = summary;
3401
+ }
3052
3402
  }
3053
- }
3054
- });
3403
+ }),
3404
+ abort
3405
+ );
3055
3406
  if (routeRequest.clientTools?.length && output.toolCalls?.length) {
3056
3407
  const loopResult = await executeClientToolLoop(
3057
3408
  route,
@@ -3066,6 +3417,9 @@ function createClient(configOrInput, options = {}) {
3066
3417
  transportSummary2 = loopResult.transportSummary;
3067
3418
  }
3068
3419
  }
3420
+ if (abort.signal.aborted) {
3421
+ throw mapAbortError(abort.reason);
3422
+ }
3069
3423
  wideAttempts.push({
3070
3424
  index: wideAttempts.length + 1,
3071
3425
  route: routeSummary,
@@ -3136,6 +3490,49 @@ function createClient(configOrInput, options = {}) {
3136
3490
  routeId: route.id
3137
3491
  });
3138
3492
  }
3493
+ if (error instanceof ClientToolExecutionError) {
3494
+ const toolError = error.aiError;
3495
+ attempts.push({
3496
+ routeId: route.id,
3497
+ handlerKey: route.handlerKey,
3498
+ errorCode: toolError.code,
3499
+ message: toolError.message
3500
+ });
3501
+ wideAttempts.push({
3502
+ index: wideAttempts.length + 1,
3503
+ route: routeSummary,
3504
+ durationMs: nowMs(runtime) - attemptStartedAt,
3505
+ outcome: "error",
3506
+ retryCount,
3507
+ errorCode: toolError.code,
3508
+ message: toolError.message,
3509
+ ...transportSummary2 ? { transport: transportSummary2 } : {}
3510
+ });
3511
+ await emitWideEvent({
3512
+ timestamp: isoTimestamp(startedAt),
3513
+ event: "ai_connect.operation",
3514
+ requestId,
3515
+ operationName,
3516
+ operation: normalizedRequest.operation,
3517
+ runtime: runtime.kind,
3518
+ outcome: "error",
3519
+ durationMs: nowMs(runtime) - startedAt,
3520
+ candidates,
3521
+ attempts: wideAttempts,
3522
+ request: requestSummary,
3523
+ error: {
3524
+ code: toolError.code,
3525
+ message: toolError.message,
3526
+ routeId: route.id
3527
+ },
3528
+ ...normalizedRequest.logContext ? { context: normalizedRequest.logContext } : {}
3529
+ });
3530
+ throw new AiConnectError(toolError.code, toolError.message, {
3531
+ ...toolError.details,
3532
+ attempts,
3533
+ routeId: route.id
3534
+ });
3535
+ }
3139
3536
  const normalizedError = toAiConnectError(error);
3140
3537
  const actions = config.routing.fallback.on[normalizedError.code] ?? [];
3141
3538
  attempts.push({
@@ -3380,30 +3777,16 @@ function createClient(configOrInput, options = {}) {
3380
3777
  return report;
3381
3778
  }
3382
3779
  async function runPingWithAbort(route, handler, abort) {
3383
- if (abort.signal.aborted) {
3384
- throw mapAbortError(abort.reason);
3385
- }
3386
- const handlerPromise = handler({
3387
- route,
3388
- request: buildPingRequest(),
3389
- runtime,
3390
- attempt: 1,
3780
+ return runWithAbortRace(
3781
+ handler({
3782
+ route,
3783
+ request: buildPingRequest(),
3784
+ runtime,
3785
+ attempt: 1,
3786
+ abort
3787
+ }),
3391
3788
  abort
3392
- });
3393
- let onAbort;
3394
- const abortPromise = new Promise((_, reject) => {
3395
- onAbort = () => reject(mapAbortError(abort.reason));
3396
- abort.signal.addEventListener("abort", onAbort, { once: true });
3397
- });
3398
- try {
3399
- return await Promise.race([handlerPromise, abortPromise]);
3400
- } finally {
3401
- if (onAbort) {
3402
- abort.signal.removeEventListener("abort", onAbort);
3403
- }
3404
- void Promise.resolve(handlerPromise).catch(() => {
3405
- });
3406
- }
3789
+ );
3407
3790
  }
3408
3791
  async function checkEndpointReachable(route, handler, abort) {
3409
3792
  if (abort.signal.aborted) {
@@ -3971,19 +4354,8 @@ function createClient(configOrInput, options = {}) {
3971
4354
  release = await limiter.acquire(abort.signal);
3972
4355
  const selectedModel = await resolveSelectedModel(preparedRequest);
3973
4356
  const normalizedRequest = withSelectedModel(preparedRequest, selectedModel);
3974
- const requiresSchema = requiresToolSchema(normalizedRequest);
3975
- const candidates = router.resolveCandidates(
3976
- requiresSchema ? {
3977
- ...normalizedRequest,
3978
- routeHints: {
3979
- ...normalizedRequest.routeHints ?? {},
3980
- requiredCapabilities: {
3981
- ...normalizedRequest.routeHints?.requiredCapabilities ?? {},
3982
- supportsToolSchema: true
3983
- }
3984
- }
3985
- } : normalizedRequest
3986
- );
4357
+ const { requiresImage, requiresFile, request: capabilityScopedRequest } = resolveRequiredCapabilities(normalizedRequest);
4358
+ const candidates = router.resolveCandidates(capabilityScopedRequest);
3987
4359
  const startedAt = nowMs(runtime);
3988
4360
  const requestId = resolveEventRequestId(normalizedRequest.logContext);
3989
4361
  const candidateSummaries = candidates.map(summarizeRoute);
@@ -3991,7 +4363,10 @@ function createClient(configOrInput, options = {}) {
3991
4363
  const attempts = [];
3992
4364
  const wideAttempts = [];
3993
4365
  if (candidates.length === 0) {
3994
- const error = new AiConnectError("not_supported", `No eligible routes are available for operation "${normalizedRequest.operation}" in runtime "${runtime.kind}".`);
4366
+ const error = requiresImage || requiresFile ? new AiConnectError(
4367
+ "unsupported_capability",
4368
+ `No eligible route supports the required ${requiresFile ? "document/file" : "image"} input for operation "${normalizedRequest.operation}" in runtime "${runtime.kind}".`
4369
+ ) : new AiConnectError("not_supported", `No eligible routes are available for operation "${normalizedRequest.operation}" in runtime "${runtime.kind}".`);
3995
4370
  await emitWideEvent({
3996
4371
  timestamp: isoTimestamp(startedAt),
3997
4372
  event: "ai_connect.operation",
@@ -4449,12 +4824,32 @@ function trimTrailingSlash(value) {
4449
4824
  function appendPath(baseUrl, suffix) {
4450
4825
  return `${trimTrailingSlash(baseUrl)}/${suffix.replace(/^\/+/, "")}`;
4451
4826
  }
4452
- function resolveOpenAiEndpoint(route, operation) {
4453
- const configured = route.transport.baseUrl?.trim();
4827
+ function isAbortError(error) {
4828
+ if (error instanceof AiConnectError) {
4829
+ return error.code === "aborted";
4830
+ }
4831
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
4832
+ }
4833
+ function attachPartsToLastUserTurn(conversation, createEmptyUserTurn) {
4834
+ let targetIndex = -1;
4835
+ for (let index = conversation.length - 1; index >= 0; index -= 1) {
4836
+ if (conversation[index]?.role === "user") {
4837
+ targetIndex = index;
4838
+ break;
4839
+ }
4840
+ }
4841
+ if (targetIndex === -1) {
4842
+ conversation.push(createEmptyUserTurn());
4843
+ targetIndex = conversation.length - 1;
4844
+ }
4845
+ return conversation[targetIndex];
4846
+ }
4847
+ function resolveOpenAiEndpoint(route, operation) {
4848
+ const configured = route.transport.baseUrl?.trim();
4454
4849
  if (!configured) {
4455
4850
  return operation === "image" ? "https://api.openai.com/v1/images/generations" : "https://api.openai.com/v1/chat/completions";
4456
4851
  }
4457
- if (configured.endsWith("/chat/completions") || configured.endsWith("/images/generations")) {
4852
+ if (operation === "text" && configured.endsWith("/chat/completions") || operation === "image" && configured.endsWith("/images/generations")) {
4458
4853
  return configured;
4459
4854
  }
4460
4855
  return appendPath(
@@ -5285,7 +5680,7 @@ async function uploadAnthropicFile(payload, uploader) {
5285
5680
  }
5286
5681
  try {
5287
5682
  const base = uploader.context.route.transport.baseUrl?.trim();
5288
- const root = base ? base.replace(/\/(messages|models)$/, "") : "https://api.anthropic.com/v1";
5683
+ const root = base ? trimTrailingSlash(base).replace(/\/(messages|models)$/, "") : "https://api.anthropic.com/v1";
5289
5684
  const endpoint = appendPath(root, "/files");
5290
5685
  const form = new FormData();
5291
5686
  form.append(
@@ -5309,7 +5704,10 @@ async function uploadAnthropicFile(payload, uploader) {
5309
5704
  }
5310
5705
  const id = recordOf(data).id;
5311
5706
  return typeof id === "string" ? id : void 0;
5312
- } catch {
5707
+ } catch (error) {
5708
+ if (uploader.context.abort.signal.aborted || isAbortError(error)) {
5709
+ throw error;
5710
+ }
5313
5711
  return void 0;
5314
5712
  }
5315
5713
  }
@@ -5342,7 +5740,10 @@ async function uploadOpenAiFile(payload, uploader) {
5342
5740
  }
5343
5741
  const id = recordOf(data).id;
5344
5742
  return typeof id === "string" ? id : void 0;
5345
- } catch {
5743
+ } catch (error) {
5744
+ if (uploader.context.abort.signal.aborted || isAbortError(error)) {
5745
+ throw error;
5746
+ }
5346
5747
  return void 0;
5347
5748
  }
5348
5749
  }
@@ -5352,10 +5753,11 @@ async function uploadGeminiFile(payload, uploader) {
5352
5753
  }
5353
5754
  try {
5354
5755
  const base = uploader.context.route.transport.baseUrl?.trim();
5355
- const endpoint = base ? appendPath(
5356
- base.replace(/\/v1beta\/models.*$/, "/v1beta").replace(/\/+$/, ""),
5756
+ const root = base ? trimTrailingSlash(base.replace(/\/v1beta\/models.*$/, "/v1beta")) : void 0;
5757
+ const endpoint = root ? appendPath(
5758
+ root.endsWith("/v1beta") ? `${root.slice(0, -"/v1beta".length)}/upload/v1beta` : `${root}/upload`,
5357
5759
  "/files"
5358
- ).replace("/v1beta/files", "/upload/v1beta/files") : "https://generativelanguage.googleapis.com/upload/v1beta/files";
5760
+ ) : "https://generativelanguage.googleapis.com/upload/v1beta/files";
5359
5761
  const response = await uploader.fetchImpl(endpoint, {
5360
5762
  method: "POST",
5361
5763
  headers: {
@@ -5374,7 +5776,10 @@ async function uploadGeminiFile(payload, uploader) {
5374
5776
  const file = recordOf(recordOf(data).file);
5375
5777
  const uri = file.uri ?? recordOf(data).uri;
5376
5778
  return typeof uri === "string" ? uri : void 0;
5377
- } catch {
5779
+ } catch (error) {
5780
+ if (uploader.context.abort.signal.aborted || isAbortError(error)) {
5781
+ throw error;
5782
+ }
5378
5783
  return void 0;
5379
5784
  }
5380
5785
  }
@@ -5572,21 +5977,10 @@ function attachOpenAiParts(conversation, parts) {
5572
5977
  if (parts.length === 0) {
5573
5978
  return conversation;
5574
5979
  }
5575
- let targetIndex = -1;
5576
- for (let index = conversation.length - 1; index >= 0; index -= 1) {
5577
- if (conversation[index]?.role === "user") {
5578
- targetIndex = index;
5579
- break;
5580
- }
5581
- }
5582
- if (targetIndex === -1) {
5583
- conversation.push({
5584
- role: "user",
5585
- content: ""
5586
- });
5587
- targetIndex = conversation.length - 1;
5588
- }
5589
- const target = conversation[targetIndex];
5980
+ const target = attachPartsToLastUserTurn(conversation, () => ({
5981
+ role: "user",
5982
+ content: ""
5983
+ }));
5590
5984
  const baseParts = typeof target.content === "string" && target.content.trim().length > 0 ? [{ type: "text", text: target.content }] : Array.isArray(target.content) ? target.content : [];
5591
5985
  target.content = [...baseParts, ...parts];
5592
5986
  return conversation;
@@ -5672,21 +6066,10 @@ async function anthropicRequest(messages, attachments, uploader) {
5672
6066
  }
5673
6067
  const attachmentParts = await anthropicAttachmentParts(attachments, uploader);
5674
6068
  if (attachmentParts.length > 0) {
5675
- let targetIndex = -1;
5676
- for (let index = conversation.length - 1; index >= 0; index -= 1) {
5677
- if (conversation[index]?.role === "user") {
5678
- targetIndex = index;
5679
- break;
5680
- }
5681
- }
5682
- if (targetIndex === -1) {
5683
- conversation.push({
5684
- role: "user",
5685
- content: ""
5686
- });
5687
- targetIndex = conversation.length - 1;
5688
- }
5689
- const target = conversation[targetIndex];
6069
+ const target = attachPartsToLastUserTurn(conversation, () => ({
6070
+ role: "user",
6071
+ content: ""
6072
+ }));
5690
6073
  const baseParts = typeof target.content === "string" && target.content.trim().length > 0 ? [{ type: "text", text: target.content }] : Array.isArray(target.content) ? target.content : [];
5691
6074
  target.content = [...baseParts, ...attachmentParts];
5692
6075
  }
@@ -5697,12 +6080,27 @@ async function anthropicRequest(messages, attachments, uploader) {
5697
6080
  }
5698
6081
  async function geminiRequest(messages, attachments, uploader) {
5699
6082
  const system = messages.filter((message) => message.role === "system").map((message) => message.content).join("\n\n");
6083
+ const toolNameByCallId = /* @__PURE__ */ new Map();
6084
+ for (const message of messages) {
6085
+ if (hasAssistantToolCalls(message)) {
6086
+ for (const call of message.toolCalls) {
6087
+ toolNameByCallId.set(call.id, call.name);
6088
+ }
6089
+ }
6090
+ }
5700
6091
  const contents = messages.filter((message) => message.role !== "system").reduce(
5701
6092
  (conversation, message) => {
5702
6093
  if (isToolMessage(message)) {
6094
+ const resolvedName = message.toolName ?? toolNameByCallId.get(message.toolCallId);
6095
+ if (!resolvedName) {
6096
+ throw new AiConnectError(
6097
+ "validation_error",
6098
+ `Gemini functionResponse requires a tool name: the tool message for toolCallId "${message.toolCallId}" has no toolName and no matching assistant toolCall was found.`
6099
+ );
6100
+ }
5703
6101
  const response = {
5704
6102
  functionResponse: {
5705
- name: message.toolName ?? "tool",
6103
+ name: resolvedName,
5706
6104
  response: isRecord(message.data) ? message.data : {
5707
6105
  content: message.content,
5708
6106
  ...message.isError !== void 0 ? { isError: message.isError } : {}
@@ -5749,21 +6147,11 @@ async function geminiRequest(messages, attachments, uploader) {
5749
6147
  );
5750
6148
  const attachmentParts = await geminiAttachmentParts(attachments, uploader);
5751
6149
  if (attachmentParts.length > 0) {
5752
- let targetIndex = -1;
5753
- for (let index = contents.length - 1; index >= 0; index -= 1) {
5754
- if (contents[index]?.role === "user") {
5755
- targetIndex = index;
5756
- break;
5757
- }
5758
- }
5759
- if (targetIndex === -1) {
5760
- contents.push({
5761
- role: "user",
5762
- parts: []
5763
- });
5764
- targetIndex = contents.length - 1;
5765
- }
5766
- contents[targetIndex].parts.push(...attachmentParts);
6150
+ const target = attachPartsToLastUserTurn(contents, () => ({
6151
+ role: "user",
6152
+ parts: []
6153
+ }));
6154
+ target.parts.push(...attachmentParts);
5767
6155
  }
5768
6156
  return {
5769
6157
  ...system ? {
@@ -5813,6 +6201,22 @@ function extractThoughts(value) {
5813
6201
  const joined = thoughts.join("\n").trim();
5814
6202
  return joined.length > 0 ? joined : void 0;
5815
6203
  }
6204
+ function extractThinking(value) {
6205
+ if (!Array.isArray(value)) {
6206
+ return void 0;
6207
+ }
6208
+ const thinking = value.flatMap((part) => {
6209
+ if (part && typeof part === "object" && part.type === "thinking") {
6210
+ const candidate = part.thinking;
6211
+ if (typeof candidate === "string") {
6212
+ return [candidate];
6213
+ }
6214
+ }
6215
+ return [];
6216
+ });
6217
+ const joined = thinking.join("\n").trim();
6218
+ return joined.length > 0 ? joined : void 0;
6219
+ }
5816
6220
  function imageAttachmentsFromPayload(payload, ...sources) {
5817
6221
  return extractImagePayloads(payload, ...sources).map(
5818
6222
  (item) => preparePortableFile(item)
@@ -5900,6 +6304,10 @@ function buildOpenAiChatBody(context, messages, parameters, responseFormat, opts
5900
6304
  model: context.route.model,
5901
6305
  messages,
5902
6306
  stream: opts.stream,
6307
+ // Ask OpenAI-compatible streaming routes for the trailing usage chunk; without
6308
+ // `stream_options.include_usage` the SSE stream carries no `usage` block and
6309
+ // `runOpenAiStream` would always report `usage: undefined` (H5).
6310
+ ...opts.stream ? { stream_options: { include_usage: true } } : {},
5903
6311
  ...parameters?.maxTokens !== void 0 ? { max_tokens: parameters.maxTokens } : {},
5904
6312
  ...parameters?.temperature !== void 0 ? { temperature: parameters.temperature } : {},
5905
6313
  ...parameters?.topP !== void 0 ? { top_p: parameters.topP } : {},
@@ -6054,6 +6462,11 @@ async function* runOpenAiStream(fetchImpl, context) {
6054
6462
  }
6055
6463
  const response = outcome.response;
6056
6464
  const streamWarnings = [...uploaderWarnings];
6465
+ if (parameters?.candidateCount && parameters.candidateCount > 1) {
6466
+ streamWarnings.push(
6467
+ "candidateCount > 1 is forwarded to OpenAI-compatible text routes, but the normalized result returns only the first choice."
6468
+ );
6469
+ }
6057
6470
  if (droppedResponseFormat) {
6058
6471
  streamWarnings.push(
6059
6472
  "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."
@@ -6199,8 +6612,7 @@ function buildOpenAiStreamResult(context, text, toolCalls, usage, warnings) {
6199
6612
  } : {}
6200
6613
  };
6201
6614
  }
6202
- async function runOpenAiImage(fetchImpl, context) {
6203
- const apiKey = resolveApiKey(context.route, context.runtime);
6615
+ async function runProviderImage(fetchImpl, context, opts) {
6204
6616
  const referenceImages = imageReferenceFiles(context);
6205
6617
  const bundle = buildImagePromptBundle({
6206
6618
  request: context.request,
@@ -6209,10 +6621,10 @@ async function runOpenAiImage(fetchImpl, context) {
6209
6621
  });
6210
6622
  const baseFields = buildImageRequestFields(context, bundle);
6211
6623
  const useEditEndpoint = shouldUseImageEditEndpoint(context);
6212
- const endpoint = useEditEndpoint ? resolveOpenAiImageEditEndpoint(context.route) : resolveOpenAiEndpoint(context.route, "image");
6624
+ const endpoint = useEditEndpoint ? opts.resolveEditEndpoint(context.route) : opts.resolveEndpoint(context.route);
6213
6625
  transportSummary(
6214
6626
  context,
6215
- useEditEndpoint ? "openai-image-edit" : "openai-image",
6627
+ useEditEndpoint ? `${opts.providerLabel}-image-edit` : `${opts.providerLabel}-image`,
6216
6628
  endpoint,
6217
6629
  "POST",
6218
6630
  false,
@@ -6225,23 +6637,21 @@ async function runOpenAiImage(fetchImpl, context) {
6225
6637
  );
6226
6638
  const response = await fetchImpl(endpoint, useEditEndpoint ? {
6227
6639
  method: "POST",
6228
- headers: {
6229
- authorization: `Bearer ${apiKey}`
6230
- },
6640
+ headers: { ...opts.authHeaders },
6231
6641
  body: await buildImageEditFormData(context, baseFields),
6232
6642
  signal: context.abort.signal
6233
6643
  } : {
6234
6644
  method: "POST",
6235
6645
  headers: {
6236
6646
  "content-type": "application/json",
6237
- authorization: `Bearer ${apiKey}`
6647
+ ...opts.authHeaders
6238
6648
  },
6239
6649
  body: JSON.stringify(baseFields),
6240
6650
  signal: context.abort.signal
6241
6651
  });
6242
6652
  const payload = await readPayload(response);
6243
6653
  if (!response.ok) {
6244
- throw classifyApiError("openai", response, payload);
6654
+ throw classifyApiError(opts.classifyProvider, response, payload);
6245
6655
  }
6246
6656
  return {
6247
6657
  data: {
@@ -6251,6 +6661,16 @@ async function runOpenAiImage(fetchImpl, context) {
6251
6661
  attachments: imageAttachmentsFromPayload(payload)
6252
6662
  };
6253
6663
  }
6664
+ async function runOpenAiImage(fetchImpl, context) {
6665
+ const apiKey = resolveApiKey(context.route, context.runtime);
6666
+ return runProviderImage(fetchImpl, context, {
6667
+ resolveEndpoint: (route) => resolveOpenAiEndpoint(route, "image"),
6668
+ resolveEditEndpoint: (route) => resolveOpenAiImageEditEndpoint(route),
6669
+ authHeaders: { authorization: `Bearer ${apiKey}` },
6670
+ providerLabel: "openai",
6671
+ classifyProvider: "openai"
6672
+ });
6673
+ }
6254
6674
  async function runAnthropic(fetchImpl, context) {
6255
6675
  const apiKey = resolveApiKey(context.route, context.runtime);
6256
6676
  const uploaderWarnings = [];
@@ -6262,6 +6682,8 @@ async function runAnthropic(fetchImpl, context) {
6262
6682
  const parameters = context.request.parameters;
6263
6683
  const requestBody = {
6264
6684
  model: context.route.model,
6685
+ // Anthropic REQUIRES `max_tokens` (unlike OpenAI/Gemini, where it is optional), so a
6686
+ // provider-specific default of 4096 is applied when the caller omits `maxTokens`.
6265
6687
  max_tokens: parameters?.maxTokens ?? 4096,
6266
6688
  ...parameters?.temperature !== void 0 ? { temperature: parameters.temperature } : {},
6267
6689
  ...parameters?.topP !== void 0 ? { top_p: parameters.topP } : {},
@@ -6291,8 +6713,12 @@ async function runAnthropic(fetchImpl, context) {
6291
6713
  throw classifyApiError("anthropic", response, payload);
6292
6714
  }
6293
6715
  const data = payload;
6716
+ const anthropicReasoning = extractThinking(data.content);
6294
6717
  return {
6295
6718
  text: extractText(data.content),
6719
+ // Anthropic extended-thinking blocks, separated from the answer (consumers route this
6720
+ // to a thinking UI), mirroring the Gemini `reasoning` path.
6721
+ ...anthropicReasoning ? { reasoning: anthropicReasoning } : {},
6296
6722
  data,
6297
6723
  ...anthropicToolCallsFromPayload(data).length > 0 ? { toolCalls: anthropicToolCallsFromPayload(data) } : {},
6298
6724
  ...uploaderWarnings.length > 0 ? { warnings: uploaderWarnings } : {},
@@ -6387,55 +6813,13 @@ async function runGemini(fetchImpl, context) {
6387
6813
  }
6388
6814
  async function runGeminiImage(fetchImpl, context) {
6389
6815
  const apiKey = resolveApiKey(context.route, context.runtime);
6390
- const referenceImages = imageReferenceFiles(context);
6391
- const bundle = buildImagePromptBundle({
6392
- request: context.request,
6393
- model: context.route.model,
6394
- referenceImages
6816
+ return runProviderImage(fetchImpl, context, {
6817
+ resolveEndpoint: (route) => resolveGeminiImageEndpoint(route),
6818
+ resolveEditEndpoint: (route) => resolveGeminiImageEditEndpoint(route),
6819
+ authHeaders: { authorization: `Bearer ${apiKey}` },
6820
+ providerLabel: "gemini",
6821
+ classifyProvider: context.route.provider
6395
6822
  });
6396
- const baseFields = buildImageRequestFields(context, bundle);
6397
- const useEditEndpoint = shouldUseImageEditEndpoint(context);
6398
- const endpoint = useEditEndpoint ? resolveGeminiImageEditEndpoint(context.route) : resolveGeminiImageEndpoint(context.route);
6399
- transportSummary(
6400
- context,
6401
- useEditEndpoint ? "gemini-image-edit" : "gemini-image",
6402
- endpoint,
6403
- "POST",
6404
- false,
6405
- {
6406
- ...baseFields,
6407
- source_image: Boolean(context.request.image?.sourceImage),
6408
- mask: Boolean(context.request.image?.mask),
6409
- reference_image_count: referenceImages.length
6410
- }
6411
- );
6412
- const response = await fetchImpl(endpoint, useEditEndpoint ? {
6413
- method: "POST",
6414
- headers: {
6415
- authorization: `Bearer ${apiKey}`
6416
- },
6417
- body: await buildImageEditFormData(context, baseFields),
6418
- signal: context.abort.signal
6419
- } : {
6420
- method: "POST",
6421
- headers: {
6422
- "content-type": "application/json",
6423
- authorization: `Bearer ${apiKey}`
6424
- },
6425
- body: JSON.stringify(baseFields),
6426
- signal: context.abort.signal
6427
- });
6428
- const payload = await readPayload(response);
6429
- if (!response.ok) {
6430
- throw classifyApiError(context.route.provider, response, payload);
6431
- }
6432
- return {
6433
- data: {
6434
- request: bundle,
6435
- response: payload
6436
- },
6437
- attachments: imageAttachmentsFromPayload(payload)
6438
- };
6439
6823
  }
6440
6824
  function verifyApiRoute(route, runtime) {
6441
6825
  resolveApiKey(route, runtime);
@@ -6512,9 +6896,118 @@ var AI_CONNECT_DEFAULT_ACP_COMMANDS = {
6512
6896
  "opencode:opencode-acp": "opencode acp"
6513
6897
  };
6514
6898
 
6899
+ // src/transport-runtime.ts
6900
+ var MINIMAL_ENV_KEYS = [
6901
+ "PATH",
6902
+ "HOME",
6903
+ "USERPROFILE",
6904
+ "HTTPS_PROXY",
6905
+ "HTTP_PROXY",
6906
+ "NO_PROXY",
6907
+ "https_proxy",
6908
+ "http_proxy",
6909
+ "no_proxy",
6910
+ "NODE_EXTRA_CA_CERTS",
6911
+ "SSL_CERT_FILE",
6912
+ "SSL_CERT_DIR",
6913
+ "SystemRoot",
6914
+ "windir",
6915
+ "APPDATA",
6916
+ "LOCALAPPDATA",
6917
+ "TEMP",
6918
+ "TMP",
6919
+ "PATHEXT",
6920
+ "ComSpec",
6921
+ "NUMBER_OF_PROCESSORS"
6922
+ ];
6923
+ function resolveChildEnv(opts = {}) {
6924
+ const extra = opts.extra ?? {};
6925
+ if (opts.envMode === "inherit") {
6926
+ const inherited = {};
6927
+ for (const [key, value] of Object.entries(process.env)) {
6928
+ if (typeof value === "string") {
6929
+ inherited[key] = value;
6930
+ }
6931
+ }
6932
+ return { ...inherited, ...extra };
6933
+ }
6934
+ const minimal = {};
6935
+ const keys = /* @__PURE__ */ new Set([...MINIMAL_ENV_KEYS, ...opts.allowlist ?? []]);
6936
+ for (const key of keys) {
6937
+ const value = process.env[key];
6938
+ if (typeof value === "string") {
6939
+ minimal[key] = value;
6940
+ }
6941
+ }
6942
+ return { ...minimal, ...extra };
6943
+ }
6944
+ function killProcessTree(child, signal, detached) {
6945
+ if (process.platform !== "win32" && detached && typeof child.pid === "number") {
6946
+ try {
6947
+ process.kill(-child.pid, signal);
6948
+ return;
6949
+ } catch {
6950
+ }
6951
+ }
6952
+ try {
6953
+ child.kill(signal);
6954
+ } catch {
6955
+ }
6956
+ }
6957
+ function resolveManagedCommand(key, optionsCommands, routeCommand, presetCommand) {
6958
+ const resolved = optionsCommands?.[key]?.trim() || routeCommand?.trim() || presetCommand?.trim();
6959
+ if (!resolved) {
6960
+ throw new AiConnectError(
6961
+ "validation_error",
6962
+ `No managed command is configured for "${key}".`
6963
+ );
6964
+ }
6965
+ return resolved;
6966
+ }
6967
+ var trackedChildren = /* @__PURE__ */ new Set();
6968
+ var exitHookRegistered = false;
6969
+ function killAllTrackedChildren() {
6970
+ for (const tracked of trackedChildren) {
6971
+ killProcessTree(tracked.child, "SIGKILL", tracked.detached);
6972
+ }
6973
+ trackedChildren.clear();
6974
+ }
6975
+ function ensureExitHook() {
6976
+ if (exitHookRegistered) {
6977
+ return;
6978
+ }
6979
+ exitHookRegistered = true;
6980
+ process.on("exit", () => {
6981
+ killAllTrackedChildren();
6982
+ });
6983
+ for (const signal of ["SIGINT", "SIGTERM"]) {
6984
+ const handler = () => {
6985
+ killAllTrackedChildren();
6986
+ process.removeListener(signal, handler);
6987
+ if (process.listenerCount(signal) === 0) {
6988
+ process.kill(process.pid, signal);
6989
+ }
6990
+ };
6991
+ process.on(signal, handler);
6992
+ }
6993
+ }
6994
+ function trackChild(child, detached) {
6995
+ ensureExitHook();
6996
+ const entry = { child, detached };
6997
+ trackedChildren.add(entry);
6998
+ const untrack = () => {
6999
+ trackedChildren.delete(entry);
7000
+ };
7001
+ child.once("close", untrack);
7002
+ child.once("exit", untrack);
7003
+ return untrack;
7004
+ }
7005
+
6515
7006
  // src/acp.ts
6516
7007
  var DEFAULT_TIMEOUT_MS = 6e4;
6517
7008
  var DEFAULT_SESSION_CACHE_IDLE_TTL_MS = 6e4;
7009
+ var MAX_ACP_STDERR_CHARS = 64 * 1024;
7010
+ var MAX_ACP_STREAM_QUEUE = 1024;
6518
7011
  var IMAGE_EXTENSIONS = /* @__PURE__ */ new Map([
6519
7012
  [".png", "image/png"],
6520
7013
  [".jpg", "image/jpeg"],
@@ -6803,24 +7296,29 @@ function prepareClaudeLaunchRuntime(base, launch) {
6803
7296
  launch
6804
7297
  };
6805
7298
  }
7299
+ var ACP_LAUNCH_PREPARERS = {
7300
+ "opencode-acp": prepareOpenCodeLaunchRuntime,
7301
+ "codex-acp": prepareCodexLaunchRuntime,
7302
+ "claude-code-acp": prepareClaudeLaunchRuntime
7303
+ };
6806
7304
  async function prepareAcpLaunchRuntime(route, options, commandLine, cwdOverride) {
6807
7305
  const launch = normalizeLaunchOptions(route, options);
6808
7306
  const base = {
6809
7307
  commandLine,
6810
7308
  cwd: path.resolve(cwdOverride ?? options?.cwd ?? process.cwd()),
6811
- env: {
6812
- ...process.env,
6813
- ...options?.env ?? {}
6814
- }
7309
+ // SYS-2: minimal env by default — do NOT hand the host's full process.env
7310
+ // (other-provider keys, CI tokens) to third-party ACP agents. Extra env is
7311
+ // layered via options.env.
7312
+ env: resolveChildEnv({ extra: options?.env ?? {} })
6815
7313
  };
6816
- if (route.transport.id === "opencode-acp") {
6817
- return prepareOpenCodeLaunchRuntime(base, launch);
6818
- }
6819
- if (route.transport.id === "codex-acp") {
6820
- return prepareCodexLaunchRuntime(base, launch);
7314
+ const preparer = ACP_LAUNCH_PREPARERS[route.transport.id];
7315
+ if (preparer) {
7316
+ return await preparer(base, launch);
6821
7317
  }
6822
- if (route.transport.id === "claude-code-acp") {
6823
- return prepareClaudeLaunchRuntime(base, launch);
7318
+ if (launch.contextMode !== "workspace" || launch.skillsMode !== "default") {
7319
+ console.warn(
7320
+ `[ai-connect] ACP transport "${route.transport.id}" has no launch-runtime preparer; requested contextMode/skillsMode isolation is not applied.`
7321
+ );
6824
7322
  }
6825
7323
  return {
6826
7324
  ...base,
@@ -6852,8 +7350,27 @@ function mimeTypeFromPath(filePath) {
6852
7350
  function isTextPath(filePath) {
6853
7351
  return TEXT_EXTENSIONS2.has(path.extname(filePath).toLowerCase());
6854
7352
  }
6855
- function isWithinRoot(rootDir, targetPath) {
6856
- const relative = path.relative(rootDir, targetPath);
7353
+ async function resolveRealPathTolerant(target) {
7354
+ try {
7355
+ return await fs.realpath(target);
7356
+ } catch {
7357
+ const parent = path.dirname(target);
7358
+ if (parent === target) {
7359
+ return target;
7360
+ }
7361
+ const realParent = await resolveRealPathTolerant(parent);
7362
+ return path.join(realParent, path.basename(target));
7363
+ }
7364
+ }
7365
+ async function isWithinRoot(rootDir, targetPath) {
7366
+ let realRoot;
7367
+ try {
7368
+ realRoot = await fs.realpath(rootDir);
7369
+ } catch {
7370
+ realRoot = path.resolve(rootDir);
7371
+ }
7372
+ const realTarget = await resolveRealPathTolerant(path.resolve(targetPath));
7373
+ const relative = path.relative(realRoot, realTarget);
6857
7374
  return relative.length === 0 || !relative.startsWith("..") && !path.isAbsolute(relative);
6858
7375
  }
6859
7376
  function buildMessageTranscript(context) {
@@ -7280,46 +7797,58 @@ function extractTextChunk(update) {
7280
7797
  }
7281
7798
  return typeof content.text === "string" && content.text.length > 0 ? content.text : void 0;
7282
7799
  }
7800
+ function isAuthErrorMessage(message) {
7801
+ return /unauthor|authentication|invalid[_\s-]?(api[_\s-]?key|credential|token)|forbidden|\b40[13]\b/i.test(
7802
+ message
7803
+ );
7804
+ }
7283
7805
  function normalizeAcpError(error) {
7284
7806
  if (error instanceof AiConnectError) {
7285
7807
  return error;
7286
7808
  }
7809
+ if (error instanceof Error) {
7810
+ const message = error.message || "ACP transport failed.";
7811
+ const errno = error.code;
7812
+ if (errno === "ENOENT" || errno === "EACCES" || errno === "ENOEXEC" || message.toLowerCase().includes("timed out")) {
7813
+ return new AiConnectError("local_harness_unavailable", message);
7814
+ }
7815
+ if (isAuthErrorMessage(message)) {
7816
+ return new AiConnectError("auth_error", message);
7817
+ }
7818
+ return new AiConnectError("temporary_unavailable", message);
7819
+ }
7287
7820
  if (isRecord2(error)) {
7288
7821
  const code = typeof error.code === "number" ? error.code : void 0;
7289
7822
  const message = typeof error.message === "string" ? error.message : "ACP transport failed.";
7290
7823
  if (code === -32601 || code === -32602) {
7291
7824
  return new AiConnectError("not_supported", message);
7292
7825
  }
7293
- if (message.toLowerCase().includes("auth")) {
7826
+ if (isAuthErrorMessage(message)) {
7294
7827
  return new AiConnectError("auth_error", message);
7295
7828
  }
7296
7829
  return new AiConnectError("temporary_unavailable", message);
7297
7830
  }
7298
- if (error instanceof Error) {
7299
- const message = error.message;
7300
- if ("code" in error && error.code === "ENOENT") {
7301
- return new AiConnectError("local_harness_unavailable", message);
7302
- }
7303
- if (message.toLowerCase().includes("timed out")) {
7304
- return new AiConnectError("local_harness_unavailable", message);
7305
- }
7306
- return new AiConnectError("temporary_unavailable", message);
7307
- }
7308
7831
  return new AiConnectError(
7309
7832
  "temporary_unavailable",
7310
7833
  "ACP transport failed."
7311
7834
  );
7312
7835
  }
7313
7836
  function resolveAcpCommand(route, options) {
7314
- const override = options?.commands?.[`${route.provider}:${route.transport.id}`] ?? options?.commands?.[route.transport.id] ?? options?.commands?.[route.provider];
7315
- const resolved = override ?? route.transport.command ?? AI_CONNECT_DEFAULT_ACP_COMMANDS[`${route.provider}:${route.transport.id}`];
7316
- if (!resolved) {
7837
+ const key = `${route.provider}:${route.transport.id}`;
7838
+ const optionsOverride = options?.commands?.[key] ?? options?.commands?.[route.transport.id] ?? options?.commands?.[route.provider];
7839
+ try {
7840
+ return resolveManagedCommand(
7841
+ key,
7842
+ optionsOverride ? { [key]: optionsOverride } : void 0,
7843
+ route.transport.command,
7844
+ AI_CONNECT_DEFAULT_ACP_COMMANDS[key]
7845
+ );
7846
+ } catch {
7317
7847
  throw new AiConnectError(
7318
7848
  "validation_error",
7319
7849
  `No ACP command is configured for route "${route.id}".`
7320
7850
  );
7321
7851
  }
7322
- return resolved;
7323
7852
  }
7324
7853
  function resolveAcpExecutable(route, options) {
7325
7854
  return splitCommandLine(resolveAcpCommand(route, options)).command;
@@ -7470,14 +7999,7 @@ async function settleClosePromise(closePromise, timeoutMs = 1500) {
7470
7999
  ]);
7471
8000
  }
7472
8001
  function killAcpProcess(child, signal) {
7473
- if (process.platform !== "win32" && typeof child.pid === "number") {
7474
- try {
7475
- process.kill(-child.pid, signal);
7476
- return;
7477
- } catch {
7478
- }
7479
- }
7480
- child.kill(signal);
8002
+ killProcessTree(child, signal, process.platform !== "win32");
7481
8003
  }
7482
8004
  var AcpConnection = class {
7483
8005
  commandLine;
@@ -7609,6 +8131,9 @@ var AcpConnection = class {
7609
8131
  if (stderrSlice.length > 0) {
7610
8132
  warnings.push(stderrSlice);
7611
8133
  }
8134
+ if (this.stderr.length > MAX_ACP_STDERR_CHARS) {
8135
+ this.stderr = this.stderr.slice(-MAX_ACP_STDERR_CHARS);
8136
+ }
7612
8137
  const text = this.activePrompt.text.trim();
7613
8138
  if (this.failOnHarnessOnlyTurn && this.activePrompt.harnessMarkers.length > 0 && text.length === 0) {
7614
8139
  this.activePrompt = void 0;
@@ -7778,6 +8303,12 @@ var AcpConnection = class {
7778
8303
  this.stderr = "";
7779
8304
  this.pending.clear();
7780
8305
  this.messageQueue = Promise.resolve();
8306
+ child.stdin?.on("error", (error) => {
8307
+ const message = error instanceof Error ? error.message : String(error);
8308
+ this.stderr += `[ai-connect] ACP stdin error: ${message}
8309
+ `;
8310
+ });
8311
+ trackChild(child, process.platform !== "win32");
7781
8312
  child.stdout.setEncoding("utf8");
7782
8313
  child.stdout.on("data", (chunk) => {
7783
8314
  this.buffer += chunk;
@@ -7841,8 +8372,14 @@ var AcpConnection = class {
7841
8372
  if (!stdin) {
7842
8373
  return;
7843
8374
  }
7844
- stdin.write(`${JSON.stringify(message)}
8375
+ try {
8376
+ stdin.write(`${JSON.stringify(message)}
7845
8377
  `);
8378
+ } catch (error) {
8379
+ const messageText = error instanceof Error ? error.message : String(error);
8380
+ this.stderr += `[ai-connect] ACP stdin write error (reply): ${messageText}
8381
+ `;
8382
+ }
7846
8383
  }
7847
8384
  failPending(error) {
7848
8385
  for (const entry of this.pending.values()) {
@@ -7863,7 +8400,7 @@ var AcpConnection = class {
7863
8400
  }
7864
8401
  if (message.method === "readTextFile") {
7865
8402
  const filePath = typeof params.path === "string" ? path.resolve(params.path) : void 0;
7866
- if (!filePath || !isWithinRoot(this.cwd, filePath)) {
8403
+ if (!filePath || !await isWithinRoot(this.cwd, filePath)) {
7867
8404
  this.reply({
7868
8405
  jsonrpc: "2.0",
7869
8406
  id: message.id,
@@ -7896,7 +8433,7 @@ var AcpConnection = class {
7896
8433
  }
7897
8434
  const filePath = typeof params.path === "string" ? path.resolve(params.path) : void 0;
7898
8435
  const content = typeof params.content === "string" ? params.content : void 0;
7899
- if (!filePath || content === void 0 || !isWithinRoot(this.cwd, filePath)) {
8436
+ if (!filePath || content === void 0 || !await isWithinRoot(this.cwd, filePath)) {
7900
8437
  this.reply({
7901
8438
  jsonrpc: "2.0",
7902
8439
  id: message.id,
@@ -8170,8 +8707,20 @@ var AcpConnection = class {
8170
8707
  );
8171
8708
  return;
8172
8709
  }
8173
- stdin.write(`${JSON.stringify(payload)}
8710
+ try {
8711
+ stdin.write(`${JSON.stringify(payload)}
8174
8712
  `);
8713
+ } catch (error) {
8714
+ clearTimeout(timer);
8715
+ this.pending.delete(id);
8716
+ const messageText = error instanceof Error ? error.message : String(error);
8717
+ reject(
8718
+ new AiConnectError(
8719
+ "local_harness_unavailable",
8720
+ `ACP command "${this.commandLine}" stdin write failed: ${messageText}`
8721
+ )
8722
+ );
8723
+ }
8175
8724
  });
8176
8725
  }
8177
8726
  };
@@ -8442,6 +8991,10 @@ function createAcpTransportManager(options) {
8442
8991
  let notify;
8443
8992
  const onDelta = (text) => {
8444
8993
  deltas.push(text);
8994
+ if (deltas.length > MAX_ACP_STREAM_QUEUE) {
8995
+ const overflow = deltas.splice(0, deltas.length - MAX_ACP_STREAM_QUEUE);
8996
+ deltas.unshift(overflow.join(""));
8997
+ }
8445
8998
  notify?.();
8446
8999
  };
8447
9000
  let settled = false;
@@ -8579,44 +9132,29 @@ ${message.content}`;
8579
9132
  }).join("\n\n");
8580
9133
  }
8581
9134
  function resolveCliCommand(route, options) {
8582
- if (route.transport.command?.trim()) {
8583
- return route.transport.command.trim();
8584
- }
8585
- const candidates = [
8586
- `${route.provider}:${route.transport.id}`,
8587
- route.transport.id,
8588
- route.provider
8589
- ];
8590
- for (const candidate of candidates) {
8591
- const override = options?.commands?.[candidate]?.trim();
8592
- if (override) {
8593
- return override;
8594
- }
8595
- }
9135
+ const key = `${route.provider}:${route.transport.id}`;
9136
+ const optionsOverride = options?.commands?.[key]?.trim() || options?.commands?.[route.transport.id]?.trim() || options?.commands?.[route.provider]?.trim() || void 0;
8596
9137
  const presetId = route.transport.cli?.preset ?? defaultCliPresetIdForRoute(route);
8597
- if (presetId) {
8598
- const presetCommand = getCliTransportPreset(presetId).command.trim();
8599
- if (presetCommand) {
8600
- return presetCommand;
8601
- }
8602
- }
8603
- const preset = AI_CONNECT_DEFAULT_CLI_COMMANDS[`${route.provider}:${route.transport.id}`];
8604
- if (preset) {
8605
- return preset;
9138
+ const presetCommand = (presetId ? getCliTransportPreset(presetId).command.trim() : "") || AI_CONNECT_DEFAULT_CLI_COMMANDS[key] || void 0;
9139
+ try {
9140
+ return resolveManagedCommand(
9141
+ key,
9142
+ optionsOverride ? { [key]: optionsOverride } : void 0,
9143
+ route.transport.command,
9144
+ presetCommand
9145
+ );
9146
+ } catch {
9147
+ throw new AiConnectError(
9148
+ "validation_error",
9149
+ `No CLI command preset is registered for route "${route.id}". Provide transport.command or cli.commands.`
9150
+ );
8606
9151
  }
8607
- throw new AiConnectError(
8608
- "validation_error",
8609
- `No CLI command preset is registered for route "${route.id}". Provide transport.command or cli.commands.`
8610
- );
8611
9152
  }
8612
9153
  function resolveCliExecutable(route, options) {
8613
9154
  return splitCommandLine2(resolveCliCommand(route, options)).command;
8614
9155
  }
8615
9156
  function buildCliEnvironment(options) {
8616
- return {
8617
- ...process.env,
8618
- ...options?.env ?? {}
8619
- };
9157
+ return resolveChildEnv({ extra: options?.env ?? {} });
8620
9158
  }
8621
9159
  function buildCliCwd(context, options) {
8622
9160
  return path2.resolve(
@@ -8717,9 +9255,13 @@ function resolveCliTransportOptions(route) {
8717
9255
  } : void 0;
8718
9256
  const merged = {
8719
9257
  ...preset?.options ?? {},
9258
+ // `...route.transport.cli` already spreads argsTemplate + parser (when set on
9259
+ // the route) over the preset defaults, so the previously-present conditional
9260
+ // re-spreads of those two fields were dead/redundant (they re-assigned the
9261
+ // identical value) and have been dropped. fileInput/preset below are NOT
9262
+ // redundant: fileInput is a per-field DEEP merge (mergedFileInput) and preset
9263
+ // is derived (route.transport.cli.preset ?? default), not a plain passthrough.
8720
9264
  ...route.transport.cli ?? {},
8721
- ...route.transport.cli?.argsTemplate ? { argsTemplate: route.transport.cli.argsTemplate } : {},
8722
- ...route.transport.cli?.parser ? { parser: route.transport.cli.parser } : {},
8723
9265
  ...mergedFileInput ? { fileInput: mergedFileInput } : {},
8724
9266
  ...presetId ? { preset: presetId } : {}
8725
9267
  };
@@ -8816,7 +9358,7 @@ function materializeTemplateArg(templateArg, values, parameterKeys) {
8816
9358
  }
8817
9359
  return values.outputFile;
8818
9360
  default:
8819
- if (templateArg.startsWith("--")) {
9361
+ if (templateArg.startsWith("--") && templateArg.length > 2) {
8820
9362
  parameterKeys.push(templateArg);
8821
9363
  }
8822
9364
  return templateArg;
@@ -8969,8 +9511,19 @@ function getValueByPath(value, dotPath) {
8969
9511
  }
8970
9512
  return current;
8971
9513
  }
8972
- function splitJsonlLines(stdout) {
8973
- return stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => JSON.parse(line));
9514
+ function parseCliJson(raw, context) {
9515
+ try {
9516
+ return JSON.parse(raw);
9517
+ } catch (error) {
9518
+ const detail = error instanceof Error ? error.message : String(error);
9519
+ throw new AiConnectError(
9520
+ "temporary_unavailable",
9521
+ `${context} produced output that is not valid JSON (${detail}).`
9522
+ );
9523
+ }
9524
+ }
9525
+ function splitJsonlLines(stdout, context = "CLI jsonl parser") {
9526
+ return stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => parseCliJson(line, context));
8974
9527
  }
8975
9528
  function normalizeErrorMessage(value) {
8976
9529
  if (typeof value === "string" && value.trim()) {
@@ -8996,8 +9549,8 @@ function findJsonlSelection(entries, selector) {
8996
9549
  }
8997
9550
  return void 0;
8998
9551
  }
8999
- function parseGenericJsonCli(stdout, parser) {
9000
- const payload = JSON.parse(stdout);
9552
+ function parseGenericJsonCli(stdout, parser, context) {
9553
+ const payload = parseCliJson(stdout, context);
9001
9554
  const errorValue = parser.errorPath ? getValueByPath(payload, parser.errorPath) : void 0;
9002
9555
  const errorMessage = normalizeErrorMessage(errorValue);
9003
9556
  if (errorMessage) {
@@ -9018,8 +9571,8 @@ function parseGenericJsonCli(stdout, parser) {
9018
9571
  data: payload
9019
9572
  };
9020
9573
  }
9021
- function parseGenericJsonlCli(stdout, parser) {
9022
- const entries = splitJsonlLines(stdout);
9574
+ function parseGenericJsonlCli(stdout, parser, context) {
9575
+ const entries = splitJsonlLines(stdout, context);
9023
9576
  const errorValue = parser.error ? findJsonlSelection(entries, parser.error) : void 0;
9024
9577
  const errorMessage = normalizeErrorMessage(errorValue);
9025
9578
  if (errorMessage) {
@@ -9093,7 +9646,7 @@ function parseCliModelList(stdout, parser, selector) {
9093
9646
  );
9094
9647
  }
9095
9648
  const records = parser.kind === "jsonl" ? splitJsonlLines(stdout) : (() => {
9096
- const payload = JSON.parse(stdout);
9649
+ const payload = parseCliJson(stdout, "CLI discovery json parser");
9097
9650
  const arr = selector.path ? getValueByPath(payload, selector.path) : payload;
9098
9651
  return Array.isArray(arr) ? arr : [];
9099
9652
  })();
@@ -9106,8 +9659,8 @@ function parseCliModelList(stdout, parser, selector) {
9106
9659
  }
9107
9660
  return models;
9108
9661
  }
9109
- function parseClaudeCli(stdout) {
9110
- const payload = JSON.parse(stdout);
9662
+ function parseClaudeCli(stdout, context) {
9663
+ const payload = parseCliJson(stdout, context);
9111
9664
  const text = typeof payload.result === "string" ? payload.result : typeof payload.response === "string" ? payload.response : typeof payload.text === "string" ? payload.text : void 0;
9112
9665
  if (!text) {
9113
9666
  throw new AiConnectError(
@@ -9122,8 +9675,8 @@ function parseClaudeCli(stdout) {
9122
9675
  data: payload
9123
9676
  };
9124
9677
  }
9125
- function parseCodexCli(stdout, outputFileContent) {
9126
- const events = splitJsonlLines(stdout);
9678
+ function parseCodexCli(stdout, outputFileContent, context) {
9679
+ const events = splitJsonlLines(stdout, context);
9127
9680
  let text = outputFileContent?.trim() || void 0;
9128
9681
  let usage;
9129
9682
  for (const event of events) {
@@ -9173,6 +9726,7 @@ function parseCliResult(route, result, outputFileContent) {
9173
9726
  );
9174
9727
  }
9175
9728
  const cliOptions = resolveCliTransportOptions(route);
9729
+ const parseContext = `CLI transport "${route.transport.id}"${cliOptions.preset ? ` (preset ${cliOptions.preset})` : ""}`;
9176
9730
  if (route.transport.cli?.parser) {
9177
9731
  const parser = cliOptions.parser;
9178
9732
  if (!parser) {
@@ -9185,9 +9739,9 @@ function parseCliResult(route, result, outputFileContent) {
9185
9739
  case "text":
9186
9740
  return parseTextCli(result.stdout, parser);
9187
9741
  case "json":
9188
- return parseGenericJsonCli(stdout, parser);
9742
+ return parseGenericJsonCli(stdout, parser, parseContext);
9189
9743
  default:
9190
- return parseGenericJsonlCli(stdout, parser);
9744
+ return parseGenericJsonlCli(stdout, parser, parseContext);
9191
9745
  }
9192
9746
  }
9193
9747
  switch (cliOptions.preset) {
@@ -9195,9 +9749,9 @@ function parseCliResult(route, result, outputFileContent) {
9195
9749
  return parseTextCli(stdout, { kind: "text" });
9196
9750
  case "claude":
9197
9751
  case "openclaude":
9198
- return parseClaudeCli(stdout);
9752
+ return parseClaudeCli(stdout, parseContext);
9199
9753
  case "codex":
9200
- return parseCodexCli(stdout, outputFileContent);
9754
+ return parseCodexCli(stdout, outputFileContent, parseContext);
9201
9755
  default:
9202
9756
  throw new AiConnectError(
9203
9757
  "not_supported",
@@ -9211,29 +9765,27 @@ function capturePhase(phases, name, startedAt) {
9211
9765
  durationMs: Date.now() - startedAt
9212
9766
  });
9213
9767
  }
9768
+ var MAX_CLI_OUTPUT_CHARS = 64 * 1024 * 1024;
9214
9769
  async function executeCliInvocation(invocation, timeoutMs, phases, signal) {
9215
9770
  const spawnStartedAt = Date.now();
9771
+ const detached = process.platform !== "win32";
9216
9772
  const child = spawn2(invocation.command, invocation.args, {
9217
9773
  cwd: invocation.cwd,
9218
9774
  env: invocation.env,
9219
- stdio: ["ignore", "pipe", "pipe"]
9775
+ stdio: ["ignore", "pipe", "pipe"],
9776
+ detached
9220
9777
  });
9221
9778
  capturePhase(phases, "spawn", spawnStartedAt);
9222
9779
  let stdout = "";
9223
9780
  let stderr = "";
9224
9781
  child.stdout.setEncoding("utf8");
9225
- child.stdout.on("data", (chunk) => {
9226
- stdout += chunk;
9227
- });
9228
9782
  child.stderr.setEncoding("utf8");
9229
- child.stderr.on("data", (chunk) => {
9230
- stderr += chunk;
9231
- });
9232
9783
  const executeStartedAt = Date.now();
9233
9784
  return await new Promise((resolve, reject) => {
9234
9785
  let abortListener;
9786
+ let overflowed = false;
9235
9787
  const timer = setTimeout(() => {
9236
- child.kill("SIGKILL");
9788
+ killProcessTree(child, "SIGKILL", detached);
9237
9789
  reject(
9238
9790
  new AiConnectError(
9239
9791
  "temporary_unavailable",
@@ -9248,15 +9800,43 @@ async function executeCliInvocation(invocation, timeoutMs, phases, signal) {
9248
9800
  abortListener = void 0;
9249
9801
  }
9250
9802
  };
9803
+ const enforceBufferCap = () => {
9804
+ if (overflowed || stdout.length + stderr.length <= MAX_CLI_OUTPUT_CHARS) {
9805
+ return;
9806
+ }
9807
+ overflowed = true;
9808
+ killProcessTree(child, "SIGKILL", detached);
9809
+ cleanup();
9810
+ reject(
9811
+ new AiConnectError(
9812
+ "temporary_unavailable",
9813
+ `CLI transport "${quoteArg(invocation.command)}" exceeded the ${MAX_CLI_OUTPUT_CHARS}-character output cap.`
9814
+ )
9815
+ );
9816
+ };
9817
+ child.stdout.on("data", (chunk) => {
9818
+ if (overflowed) {
9819
+ return;
9820
+ }
9821
+ stdout += chunk;
9822
+ enforceBufferCap();
9823
+ });
9824
+ child.stderr.on("data", (chunk) => {
9825
+ if (overflowed) {
9826
+ return;
9827
+ }
9828
+ stderr += chunk;
9829
+ enforceBufferCap();
9830
+ });
9251
9831
  if (signal) {
9252
9832
  if (signal.aborted) {
9253
- child.kill("SIGKILL");
9833
+ killProcessTree(child, "SIGKILL", detached);
9254
9834
  cleanup();
9255
9835
  reject(new AiConnectError("aborted", "CLI transport aborted before execution."));
9256
9836
  return;
9257
9837
  }
9258
9838
  abortListener = () => {
9259
- child.kill("SIGKILL");
9839
+ killProcessTree(child, "SIGKILL", detached);
9260
9840
  cleanup();
9261
9841
  reject(new AiConnectError("aborted", "CLI transport aborted."));
9262
9842
  };
@@ -9272,6 +9852,9 @@ async function executeCliInvocation(invocation, timeoutMs, phases, signal) {
9272
9852
  );
9273
9853
  });
9274
9854
  child.once("close", (exitCode) => {
9855
+ if (overflowed) {
9856
+ return;
9857
+ }
9275
9858
  cleanup();
9276
9859
  capturePhase(phases, "execute", executeStartedAt);
9277
9860
  resolve({
@@ -9375,7 +9958,10 @@ function createCliTransportManager(options) {
9375
9958
  )
9376
9959
  );
9377
9960
  } finally {
9378
- await cleanupCliInvocation(invocation);
9961
+ try {
9962
+ await cleanupCliInvocation(invocation);
9963
+ } catch {
9964
+ }
9379
9965
  }
9380
9966
  },
9381
9967
  async runPrompt(context) {
@@ -9415,7 +10001,10 @@ function createCliTransportManager(options) {
9415
10001
  }
9416
10002
  return result;
9417
10003
  } finally {
9418
- await cleanupCliInvocation(invocation);
10004
+ try {
10005
+ await cleanupCliInvocation(invocation);
10006
+ } catch {
10007
+ }
9419
10008
  }
9420
10009
  }
9421
10010
  };
@@ -9434,9 +10023,12 @@ var AI_CONNECT_DEFAULT_SERVER_PRESETS = {
9434
10023
  transportId: "opencode-server"
9435
10024
  }
9436
10025
  };
9437
- var AI_CONNECT_DEFAULT_SERVER_COMMANDS = {
9438
- "opencode:opencode-server": AI_CONNECT_DEFAULT_SERVER_PRESETS.opencode.command
9439
- };
10026
+ var AI_CONNECT_DEFAULT_SERVER_COMMANDS = Object.fromEntries(
10027
+ Object.values(AI_CONNECT_DEFAULT_SERVER_PRESETS).map((preset) => [
10028
+ `${preset.id}:${preset.transportId}`,
10029
+ preset.command
10030
+ ])
10031
+ );
9440
10032
 
9441
10033
  // src/server.ts
9442
10034
  function splitCommandLine3(commandLine) {
@@ -9457,28 +10049,21 @@ function splitCommandLine3(commandLine) {
9457
10049
  };
9458
10050
  }
9459
10051
  function resolveServerCommand(route, options) {
9460
- if (route.transport.command?.trim()) {
9461
- return route.transport.command.trim();
9462
- }
9463
- const candidates = [
9464
- `${route.provider}:${route.transport.id}`,
9465
- route.transport.id,
9466
- route.provider
9467
- ];
9468
- for (const candidate of candidates) {
9469
- const override = options?.commands?.[candidate]?.trim();
9470
- if (override) {
9471
- return override;
9472
- }
9473
- }
9474
- const preset = AI_CONNECT_DEFAULT_SERVER_COMMANDS[`${route.provider}:${route.transport.id}`];
9475
- if (preset) {
9476
- return preset;
10052
+ const key = `${route.provider}:${route.transport.id}`;
10053
+ const optionsOverride = options?.commands?.[key]?.trim() || options?.commands?.[route.transport.id]?.trim() || options?.commands?.[route.provider]?.trim() || void 0;
10054
+ try {
10055
+ return resolveManagedCommand(
10056
+ key,
10057
+ optionsOverride ? { [key]: optionsOverride } : void 0,
10058
+ route.transport.command,
10059
+ AI_CONNECT_DEFAULT_SERVER_COMMANDS[key]
10060
+ );
10061
+ } catch {
10062
+ throw new AiConnectError(
10063
+ "validation_error",
10064
+ `No server command preset is registered for route "${route.id}". Provide transport.command or server.commands.`
10065
+ );
9477
10066
  }
9478
- throw new AiConnectError(
9479
- "validation_error",
9480
- `No server command preset is registered for route "${route.id}". Provide transport.command or server.commands.`
9481
- );
9482
10067
  }
9483
10068
  function resolveServerExecutable(route, options) {
9484
10069
  return splitCommandLine3(resolveServerCommand(route, options)).command;
@@ -9550,9 +10135,12 @@ async function findAvailablePort(host = "127.0.0.1") {
9550
10135
  async function waitForServerHealth(fetchLike, origin, timeoutMs) {
9551
10136
  const deadline = Date.now() + timeoutMs;
9552
10137
  let lastError;
10138
+ const perAttemptTimeoutMs = Math.min(2e3, Math.max(250, timeoutMs));
9553
10139
  while (Date.now() < deadline) {
9554
10140
  try {
9555
- const response = await fetchLike(joinUrl(origin, "/global/health"));
10141
+ const response = await fetchLike(joinUrl(origin, "/global/health"), {
10142
+ signal: AbortSignal.timeout(perAttemptTimeoutMs)
10143
+ });
9556
10144
  if (response.ok) {
9557
10145
  return;
9558
10146
  }
@@ -9582,14 +10170,14 @@ async function spawnServerInstance(route, options, phases, cwdOverride) {
9582
10170
  }
9583
10171
  args.push("--hostname", host, "--port", String(port));
9584
10172
  const spawnStartedAt = Date.now();
10173
+ const detached = process.platform !== "win32";
9585
10174
  const child = spawn3(resolved.command, args, {
9586
10175
  cwd: cwdOverride ?? options?.cwd ?? process.cwd(),
9587
- env: {
9588
- ...process.env,
9589
- ...options?.env ?? {}
9590
- },
9591
- stdio: ["ignore", "pipe", "pipe"]
10176
+ env: resolveChildEnv({ extra: options?.env ?? {} }),
10177
+ stdio: ["ignore", "pipe", "pipe"],
10178
+ detached
9592
10179
  });
10180
+ const untrack = trackChild(child, detached);
9593
10181
  let stderr = "";
9594
10182
  child.stderr.setEncoding("utf8");
9595
10183
  child.stderr.on("data", (chunk) => {
@@ -9605,7 +10193,8 @@ async function spawnServerInstance(route, options, phases, cwdOverride) {
9605
10193
  );
9606
10194
  capturePhase2(phases, "health", healthStartedAt);
9607
10195
  } catch (error) {
9608
- child.kill("SIGKILL");
10196
+ untrack();
10197
+ killProcessTree(child, "SIGKILL", detached);
9609
10198
  throw new AiConnectError(
9610
10199
  "local_harness_unavailable",
9611
10200
  stderr.trim() || (error instanceof Error ? error.message : "Failed to start local server transport.")
@@ -9613,15 +10202,17 @@ async function spawnServerInstance(route, options, phases, cwdOverride) {
9613
10202
  }
9614
10203
  return {
9615
10204
  origin,
10205
+ child,
9616
10206
  dispose: async () => {
10207
+ untrack();
9617
10208
  if (child.killed) {
9618
10209
  return;
9619
10210
  }
9620
- child.kill("SIGTERM");
10211
+ killProcessTree(child, "SIGTERM", detached);
9621
10212
  await new Promise((resolve) => {
9622
10213
  child.once("close", () => resolve());
9623
10214
  setTimeout(() => {
9624
- child.kill("SIGKILL");
10215
+ killProcessTree(child, "SIGKILL", detached);
9625
10216
  resolve();
9626
10217
  }, 1e3).unref?.();
9627
10218
  });
@@ -9721,7 +10312,17 @@ function createServerTransportManager(options) {
9721
10312
  const pending = spawnServerInstance(route, options, phases, cwdOverride);
9722
10313
  instances.set(key, pending);
9723
10314
  try {
9724
- return await pending;
10315
+ const instance = await pending;
10316
+ if (instance.child) {
10317
+ const evict = () => {
10318
+ if (instances.get(key) === pending) {
10319
+ instances.delete(key);
10320
+ }
10321
+ };
10322
+ instance.child.once("exit", evict);
10323
+ instance.child.once("error", evict);
10324
+ }
10325
+ return instance;
9725
10326
  } catch (error) {
9726
10327
  instances.delete(key);
9727
10328
  throw error;
@@ -9751,7 +10352,10 @@ function createServerTransportManager(options) {
9751
10352
  headers: {
9752
10353
  "content-type": "application/json"
9753
10354
  },
9754
- body: JSON.stringify({})
10355
+ body: JSON.stringify({}),
10356
+ // H6: honor caller cancel / operation timeout on session creation too
10357
+ // (was only wired on the message fetch).
10358
+ signal: context.abort.signal
9755
10359
  });
9756
10360
  capturePhase2(phases, "session/create", sessionStartedAt);
9757
10361
  if (!sessionResponse.ok) {
@@ -9820,7 +10424,10 @@ function createServerTransportManager(options) {
9820
10424
  };
9821
10425
  context.telemetry?.captureTransport(transport);
9822
10426
  const startedAt = Date.now();
9823
- const response = await fetchLike(joinUrl(instance.origin, "/config/providers"));
10427
+ const response = await fetchLike(
10428
+ joinUrl(instance.origin, "/config/providers"),
10429
+ { signal: context.abort.signal }
10430
+ );
9824
10431
  capturePhase2(phases, "config/providers", startedAt);
9825
10432
  if (!response.ok) {
9826
10433
  throw new AiConnectError(
@@ -9864,6 +10471,15 @@ function createServerTransportManager(options) {
9864
10471
  }
9865
10472
 
9866
10473
  // src/local-handlers.ts
10474
+ function normalizeVerifyIssue(error, fallbackCode, fallbackMessage) {
10475
+ const normalized = error instanceof AiConnectError ? error : new AiConnectError(fallbackCode, fallbackMessage);
10476
+ return [
10477
+ {
10478
+ code: "handler_issue",
10479
+ message: normalized.message
10480
+ }
10481
+ ];
10482
+ }
9867
10483
  function createLocalRouteHandlers(options = {}) {
9868
10484
  const acpTransport = createAcpTransportManager(options.acp);
9869
10485
  const cliTransport = createCliTransportManager(options.cli);
@@ -9894,16 +10510,11 @@ function createLocalRouteHandlers(options = {}) {
9894
10510
  }
9895
10511
  ];
9896
10512
  } catch (error) {
9897
- const normalized = error instanceof AiConnectError ? error : new AiConnectError(
10513
+ return normalizeVerifyIssue(
10514
+ error,
9898
10515
  "validation_error",
9899
10516
  "ACP route verification failed."
9900
10517
  );
9901
- return [
9902
- {
9903
- code: "handler_issue",
9904
- message: normalized.message
9905
- }
9906
- ];
9907
10518
  }
9908
10519
  }
9909
10520
  },
@@ -9942,16 +10553,11 @@ function createLocalRouteHandlers(options = {}) {
9942
10553
  }
9943
10554
  ];
9944
10555
  } catch (error) {
9945
- const normalized = error instanceof AiConnectError ? error : new AiConnectError(
10556
+ return normalizeVerifyIssue(
10557
+ error,
9946
10558
  "validation_error",
9947
10559
  "CLI route verification failed."
9948
10560
  );
9949
- return [
9950
- {
9951
- code: "handler_issue",
9952
- message: normalized.message
9953
- }
9954
- ];
9955
10561
  }
9956
10562
  }
9957
10563
  },
@@ -9987,16 +10593,11 @@ function createLocalRouteHandlers(options = {}) {
9987
10593
  }
9988
10594
  ];
9989
10595
  } catch (error) {
9990
- const normalized = error instanceof AiConnectError ? error : new AiConnectError(
10596
+ return normalizeVerifyIssue(
10597
+ error,
9991
10598
  "validation_error",
9992
10599
  "Server route verification failed."
9993
10600
  );
9994
- return [
9995
- {
9996
- code: "handler_issue",
9997
- message: normalized.message
9998
- }
9999
- ];
10000
10601
  }
10001
10602
  }
10002
10603
  }