@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/index.js CHANGED
@@ -12,7 +12,10 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
12
12
  // pi takes file/image attachments as separate `@<path>` argv tokens (REQ-024);
13
13
  // {files} expands one `@<staged-path>` token per attachment. pi stages all
14
14
  // categories (PDFs as-is, no extraction guarantee).
15
- argsTemplate: ["--print", "--model", "{model}", "{files}", "{prompt}"],
15
+ // H10 (flag injection): the `--` end-of-options token precedes {files}/{prompt}
16
+ // — both are BARE positionals here — so an `@<path>` reference or a prompt that
17
+ // starts with `-` is parsed by pi as a positional, never as a flag.
18
+ argsTemplate: ["--print", "--model", "{model}", "--", "{files}", "{prompt}"],
16
19
  discovery: {
17
20
  via: "none"
18
21
  },
@@ -32,13 +35,22 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
32
35
  transportId: "claude-cli",
33
36
  options: {
34
37
  preset: "claude",
35
- argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "{prompt}"],
38
+ // H10 (flag injection): `--` precedes the trailing {prompt} positional so a
39
+ // prompt starting with `-` (e.g. "--dangerously-skip-permissions") is parsed
40
+ // by the claude CLI as a positional, not a flag.
41
+ argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "--", "{prompt}"],
36
42
  discovery: {
37
43
  via: "acp",
38
44
  acp: {
39
45
  transportId: "claude-code-acp"
40
46
  }
41
47
  },
48
+ // SENTINEL (documentation-only): the `__preset__:*` textPath/errorPath values
49
+ // are NOT resolved as live JSON paths. This `parser` block records which
50
+ // dedicated parser a built-in preset maps to (parseClaudeCli); dispatch for a
51
+ // preset route goes through the `cliOptions.preset` switch in cli.ts
52
+ // (parseCliResult), which only reads a parser config when a ROUTE sets its own
53
+ // `cli.parser`. See the mirrored note in cli.ts::parseCliResult.
42
54
  parser: {
43
55
  kind: "json",
44
56
  textPath: "__preset__:claude.result",
@@ -54,7 +66,11 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
54
66
  transportId: "openclaude-cli",
55
67
  options: {
56
68
  preset: "openclaude",
57
- argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "{prompt}"],
69
+ // H10 (flag injection): `--` precedes the trailing {prompt} positional (see
70
+ // the claude preset above for rationale).
71
+ argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "--", "{prompt}"],
72
+ // SENTINEL (documentation-only): see the claude preset note — `__preset__:*`
73
+ // paths are markers, not live JSON paths; dispatch is via the preset switch.
58
74
  parser: {
59
75
  kind: "json",
60
76
  textPath: "__preset__:claude.result",
@@ -73,6 +89,11 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
73
89
  // codex exec takes image attachments via `-i/--image <FILE>` flags
74
90
  // (REQ-024); {files} expands a `--image <staged-path>` pair per image.
75
91
  // Images only — a non-image attachment cleanly rejects pre-spawn.
92
+ // H10 (flag injection): `--` sits AFTER {files} and before the trailing
93
+ // {prompt} positional. Unlike pi, codex files are passed via the explicit
94
+ // `--image <path>` flag (the path is that flag's VALUE, not a positional), so
95
+ // `--` must NOT precede {files} or it would neutralize `--image`. It guards
96
+ // only the {prompt} positional against being parsed as a flag.
76
97
  argsTemplate: [
77
98
  "exec",
78
99
  "--json",
@@ -81,6 +102,7 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
81
102
  "--model",
82
103
  "{model}",
83
104
  "{files}",
105
+ "--",
84
106
  "{prompt}"
85
107
  ],
86
108
  discovery: {
@@ -94,6 +116,11 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
94
116
  perFileArgs: ["--image", "{path}"],
95
117
  categories: ["image"]
96
118
  },
119
+ // SENTINEL (documentation-only): the `__preset__:*` path values below are
120
+ // markers naming the dedicated parser this preset maps to (parseCodexCli), NOT
121
+ // live JSONL selectors. Dispatch for a preset route runs through the
122
+ // `cliOptions.preset` switch in cli.ts::parseCliResult; this block is only ever
123
+ // read as a live parser when a ROUTE overrides `cli.parser`.
97
124
  parser: {
98
125
  kind: "jsonl",
99
126
  text: {
@@ -130,6 +157,19 @@ var AiConnectError = class extends Error {
130
157
  this.code = code;
131
158
  this.details = details;
132
159
  }
160
+ /**
161
+ * `Error.prototype.message` is non-enumerable, so a bare `JSON.stringify(err)` silently drops
162
+ * it (LOW, review-report-seed.md). Provide an explicit projection so serializing an
163
+ * `AiConnectError` (logs, wide events, error-response bodies) always carries the message.
164
+ */
165
+ toJSON() {
166
+ return {
167
+ name: this.name,
168
+ code: this.code,
169
+ message: this.message,
170
+ details: this.details
171
+ };
172
+ }
133
173
  };
134
174
  function isAiConnectError(error) {
135
175
  return error instanceof AiConnectError;
@@ -160,6 +200,16 @@ function toAiConnectError(error, fallbackCode = "temporary_unavailable") {
160
200
  // src/config.ts
161
201
  var DEFAULT_OPERATIONS = ["text", "image", "file"];
162
202
  var DEFAULT_STRATEGY = "priority";
203
+ var CLI_TRANSPORT_ID_BY_PROVIDER = {
204
+ anthropic: "claude-cli",
205
+ openclaude: "openclaude-cli",
206
+ openai: "codex-cli",
207
+ pi: "pi-cli"
208
+ };
209
+ var ACP_TRANSPORT_ID_BY_PROVIDER = {
210
+ anthropic: "claude-code-acp",
211
+ openai: "codex-acp"
212
+ };
163
213
  var DEFAULT_FALLBACK_ACTIONS = {
164
214
  rate_limit: [
165
215
  "rotate-credential",
@@ -271,33 +321,64 @@ function assert(condition, message) {
271
321
  throw new AiConnectError("validation_error", message);
272
322
  }
273
323
  }
274
- function asPositiveInteger(value, fallback) {
324
+ function asPositiveInteger(value, fallback, fieldLabel) {
325
+ if (value === void 0) {
326
+ return fallback;
327
+ }
275
328
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
329
+ if (fieldLabel) {
330
+ console.warn(
331
+ `[ai-connect] ${fieldLabel} must be a positive finite number when provided; received ${JSON.stringify(value)}. Falling back to ${fallback}.`
332
+ );
333
+ }
276
334
  return fallback;
277
335
  }
278
336
  return Math.floor(value);
279
337
  }
280
- function asNonNegativeInteger(value, fallback) {
338
+ function asNonNegativeInteger(value, fallback, fieldLabel) {
339
+ if (value === void 0) {
340
+ return fallback;
341
+ }
281
342
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
343
+ if (fieldLabel) {
344
+ console.warn(
345
+ `[ai-connect] ${fieldLabel} must be a non-negative finite number when provided; received ${JSON.stringify(value)}. Falling back to ${fallback}.`
346
+ );
347
+ }
282
348
  return fallback;
283
349
  }
284
350
  return Math.floor(value);
285
351
  }
286
- function asOptionalContextWindow(value) {
352
+ function asOptionalContextWindow(value, fieldLabel) {
353
+ if (value === void 0) {
354
+ return void 0;
355
+ }
287
356
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
357
+ if (fieldLabel) {
358
+ console.warn(
359
+ `[ai-connect] ${fieldLabel} must be a positive finite number when provided; received ${JSON.stringify(value)}. Ignoring.`
360
+ );
361
+ }
288
362
  return void 0;
289
363
  }
290
364
  return Math.floor(value);
291
365
  }
292
- function normalizeModelInput(input, accountContextWindow) {
366
+ function normalizeModelInput(input, accountContextWindow, accountId, providerId) {
293
367
  if (typeof input === "string") {
294
368
  return {
295
369
  id: input.trim(),
296
370
  ...accountContextWindow !== void 0 ? { contextWindow: accountContextWindow } : {}
297
371
  };
298
372
  }
373
+ assert(
374
+ typeof input.id === "string" && input.id.trim().length > 0,
375
+ `Account "${accountId}" for provider "${providerId}" declared a model entry with a missing or empty "id".`
376
+ );
299
377
  const id = input.id.trim();
300
- const override = asOptionalContextWindow(input.contextWindow);
378
+ const override = asOptionalContextWindow(
379
+ input.contextWindow,
380
+ `Model "${id}" (account "${accountId}", provider "${providerId}") contextWindow`
381
+ );
301
382
  const contextWindow = override ?? accountContextWindow;
302
383
  return {
303
384
  id,
@@ -308,7 +389,8 @@ function asFanoutBound(value) {
308
389
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
309
390
  return Number.POSITIVE_INFINITY;
310
391
  }
311
- return Math.floor(value);
392
+ const floored = Math.floor(value);
393
+ return floored <= 0 ? Number.POSITIVE_INFINITY : floored;
312
394
  }
313
395
  function normalizeFanoutPolicy(policy) {
314
396
  return {
@@ -534,7 +616,7 @@ function normalizeTransport(providerId, input) {
534
616
  };
535
617
  }
536
618
  if (descriptor.kind === "cli") {
537
- const inferredId2 = providerId === "anthropic" ? "claude-cli" : providerId === "openclaude" ? "openclaude-cli" : providerId === "openai" ? "codex-cli" : providerId === "pi" ? "pi-cli" : "cli";
619
+ const inferredId2 = CLI_TRANSPORT_ID_BY_PROVIDER[providerId] ?? "cli";
538
620
  return {
539
621
  kind: "cli",
540
622
  id: descriptor.id?.trim() || inferredId2,
@@ -551,7 +633,7 @@ function normalizeTransport(providerId, input) {
551
633
  ...descriptor.baseUrl?.trim() ? { baseUrl: descriptor.baseUrl.trim() } : {}
552
634
  };
553
635
  }
554
- const inferredId = providerId === "anthropic" ? "claude-code-acp" : providerId === "openai" ? "codex-acp" : "acp";
636
+ const inferredId = ACP_TRANSPORT_ID_BY_PROVIDER[providerId] ?? "acp";
555
637
  const normalizedLaunch = descriptor.launch ? (() => {
556
638
  const contextMode = descriptor.launch?.contextMode ?? "workspace";
557
639
  const skillsMode = descriptor.launch?.skillsMode ?? "default";
@@ -648,20 +730,26 @@ function defaultCapabilities(transport, runtime) {
648
730
  }
649
731
  function normalizeCredential(providerId, accountId, transport, input, index) {
650
732
  const id = input.id?.trim() || `${accountId}-credential-${index + 1}`;
651
- const source = input.source?.trim() || (input.apiKey ? "literal" : input.apiKeyEnv ? "env" : transport.kind === "api" ? "env" : "session");
733
+ const trimmedApiKey = input.apiKey?.trim();
734
+ const trimmedApiKeyEnv = input.apiKeyEnv?.trim();
735
+ const source = input.source?.trim() || (trimmedApiKey ? "literal" : trimmedApiKeyEnv ? "env" : transport.kind === "api" ? "env" : "session");
652
736
  if (transport.kind === "api") {
653
737
  assert(
654
- Boolean(input.apiKey?.trim()) || Boolean(input.apiKeyEnv?.trim()),
738
+ Boolean(trimmedApiKey) || Boolean(trimmedApiKeyEnv),
655
739
  `API account "${accountId}" for provider "${providerId}" requires apiKey or apiKeyEnv credentials.`
656
740
  );
657
741
  }
658
742
  return {
659
743
  id,
660
744
  source,
661
- weight: asPositiveInteger(input.weight, 1),
745
+ weight: asPositiveInteger(
746
+ input.weight,
747
+ 1,
748
+ `Credential "${id}" (account "${accountId}", provider "${providerId}") weight`
749
+ ),
662
750
  metadata: input.metadata ?? {},
663
- ...input.apiKey?.trim() ? { apiKey: input.apiKey.trim() } : {},
664
- ...input.apiKeyEnv?.trim() ? { apiKeyEnv: input.apiKeyEnv.trim() } : {},
751
+ ...trimmedApiKey ? { apiKey: trimmedApiKey } : {},
752
+ ...trimmedApiKeyEnv ? { apiKeyEnv: trimmedApiKeyEnv } : {},
665
753
  ...input.apiKeyDelimiter ? { apiKeyDelimiter: input.apiKeyDelimiter } : {}
666
754
  };
667
755
  }
@@ -695,6 +783,31 @@ function buildRouteId(route) {
695
783
  slugify(route.model)
696
784
  ].join(":");
697
785
  }
786
+ function assertRouteIdUniqueness(routes) {
787
+ const byId = /* @__PURE__ */ new Map();
788
+ for (const route of routes) {
789
+ const group = byId.get(route.id);
790
+ if (group) {
791
+ group.push(route);
792
+ } else {
793
+ byId.set(route.id, [route]);
794
+ }
795
+ }
796
+ for (const [id, group] of byId) {
797
+ if (group.length <= 1) {
798
+ continue;
799
+ }
800
+ const distinctModels = [...new Set(group.map((route) => route.model))];
801
+ if (distinctModels.length <= 1) {
802
+ continue;
803
+ }
804
+ const first = group[0];
805
+ assert(
806
+ false,
807
+ `Route id "${id}" is ambiguous: model(s) ${distinctModels.map((model) => `"${model}"`).join(", ")} on account "${first.accountId}" (provider "${first.provider}") slugify to the same route id. Rename one of the models so they no longer collide after slugification.`
808
+ );
809
+ }
810
+ }
698
811
  function routeAliases(route) {
699
812
  return [
700
813
  route.provider,
@@ -819,7 +932,8 @@ function defineConfig(input) {
819
932
  const transport = normalizeTransport(providerId, accountInput.transport);
820
933
  const runtime = normalizeRuntime(transport, accountInput.runtime);
821
934
  const accountContextWindow = asOptionalContextWindow(
822
- accountInput.contextWindow
935
+ accountInput.contextWindow,
936
+ `Account "${accountId}" (provider "${providerId}") contextWindow`
823
937
  );
824
938
  const accountModelAllowlistMode = accountInput.modelAllowlistMode ?? "strict";
825
939
  const accountContextMode = accountInput.contextMode ?? "workspace";
@@ -829,7 +943,9 @@ function defineConfig(input) {
829
943
  Array.isArray(accountInput.models) && accountInput.models.length > 0,
830
944
  `Account "${accountId}" for provider "${providerId}" must declare at least one model.`
831
945
  );
832
- const models = accountInput.models.map((model) => normalizeModelInput(model, accountContextWindow)).filter((model) => model.id.length > 0);
946
+ const models = accountInput.models.map(
947
+ (model) => normalizeModelInput(model, accountContextWindow, accountId, providerId)
948
+ ).filter((model) => model.id.length > 0);
833
949
  assert(
834
950
  models.length > 0,
835
951
  `Account "${accountId}" for provider "${providerId}" must declare at least one model.`
@@ -859,8 +975,16 @@ function defineConfig(input) {
859
975
  credentials,
860
976
  capabilities,
861
977
  verify,
862
- weight: asPositiveInteger(accountInput.weight, 1),
863
- priority: asNonNegativeInteger(accountInput.priority, accountIndex),
978
+ weight: asPositiveInteger(
979
+ accountInput.weight,
980
+ 1,
981
+ `Account "${accountId}" (provider "${providerId}") weight`
982
+ ),
983
+ priority: asNonNegativeInteger(
984
+ accountInput.priority,
985
+ accountIndex,
986
+ `Account "${accountId}" (provider "${providerId}") priority`
987
+ ),
864
988
  enabled: accountInput.enabled ?? true
865
989
  };
866
990
  accounts.push(normalizedAccount);
@@ -907,6 +1031,7 @@ function defineConfig(input) {
907
1031
  };
908
1032
  }
909
1033
  assert(routes.length > 0, "At least one enabled route must be produced.");
1034
+ assertRouteIdUniqueness(routes);
910
1035
  finalizeRotationCapabilities(routes);
911
1036
  const strategy = input.routing?.strategy ?? DEFAULT_STRATEGY;
912
1037
  const resolutionInput = input.routing?.resolution;
@@ -980,6 +1105,10 @@ function resolveRouteSelectors(selectors, routes, policy) {
980
1105
  }
981
1106
 
982
1107
  // src/fanout.ts
1108
+ function abortedError() {
1109
+ return new AiConnectError("aborted", "Operation aborted.");
1110
+ }
1111
+ var MAX_DELAY_MS = 24 * 60 * 60 * 1e3;
983
1112
  function createFanoutLimiter(policy, runtime) {
984
1113
  const now = () => runtime.now?.() ?? Date.now();
985
1114
  const { maxConcurrency, requestsPerSecond, maxCalls } = policy;
@@ -1043,10 +1172,10 @@ function createFanoutLimiter(policy, runtime) {
1043
1172
  if (index >= 0) {
1044
1173
  concurrencyQueue.splice(index, 1);
1045
1174
  }
1046
- rejectWaiter(waiter, new AiConnectError("aborted", "Operation aborted."));
1175
+ rejectWaiter(waiter, abortedError());
1047
1176
  };
1048
1177
  if (signal.aborted) {
1049
- reject(new AiConnectError("aborted", "Operation aborted."));
1178
+ reject(abortedError());
1050
1179
  return;
1051
1180
  }
1052
1181
  waiter.signal = signal;
@@ -1062,7 +1191,7 @@ function createFanoutLimiter(policy, runtime) {
1062
1191
  }
1063
1192
  while (true) {
1064
1193
  if (signal?.aborted) {
1065
- throw new AiConnectError("aborted", "Operation aborted.");
1194
+ throw abortedError();
1066
1195
  }
1067
1196
  refill();
1068
1197
  if (tokens >= 1) {
@@ -1074,6 +1203,7 @@ function createFanoutLimiter(policy, runtime) {
1074
1203
  }
1075
1204
  }
1076
1205
  function delay(ms, signal) {
1206
+ const safeMs = Number.isFinite(ms) && ms >= 0 ? Math.min(ms, MAX_DELAY_MS) : MAX_DELAY_MS;
1077
1207
  return new Promise((resolve, reject) => {
1078
1208
  let cancelTimer;
1079
1209
  const onAbort = () => {
@@ -1081,11 +1211,11 @@ function createFanoutLimiter(policy, runtime) {
1081
1211
  if (signal) {
1082
1212
  signal.removeEventListener("abort", onAbort);
1083
1213
  }
1084
- reject(new AiConnectError("aborted", "Operation aborted."));
1214
+ reject(abortedError());
1085
1215
  };
1086
1216
  if (signal) {
1087
1217
  if (signal.aborted) {
1088
- reject(new AiConnectError("aborted", "Operation aborted."));
1218
+ reject(abortedError());
1089
1219
  return;
1090
1220
  }
1091
1221
  signal.addEventListener("abort", onAbort, { once: true });
@@ -1097,9 +1227,9 @@ function createFanoutLimiter(policy, runtime) {
1097
1227
  resolve();
1098
1228
  };
1099
1229
  if (runtime.setTimer) {
1100
- cancelTimer = runtime.setTimer(ms, done);
1230
+ cancelTimer = runtime.setTimer(safeMs, done);
1101
1231
  } else {
1102
- const handle = setTimeout(done, ms);
1232
+ const handle = setTimeout(done, safeMs);
1103
1233
  cancelTimer = () => clearTimeout(handle);
1104
1234
  }
1105
1235
  });
@@ -1113,8 +1243,13 @@ function createFanoutLimiter(policy, runtime) {
1113
1243
  );
1114
1244
  }
1115
1245
  callsStarted += 1;
1116
- await awaitRateToken(signal);
1117
- await awaitConcurrencySlot(signal);
1246
+ try {
1247
+ await awaitRateToken(signal);
1248
+ await awaitConcurrencySlot(signal);
1249
+ } catch (error) {
1250
+ callsStarted -= 1;
1251
+ throw error;
1252
+ }
1118
1253
  let released = false;
1119
1254
  return () => {
1120
1255
  if (released) {
@@ -1202,19 +1337,54 @@ function isRemoteUrl(value) {
1202
1337
  return false;
1203
1338
  }
1204
1339
  }
1340
+ function mimeTypeAndExtensionFromMediaType(mediaTypeAndParams, fallbackMimeType, fallbackExtension) {
1341
+ const mimeType = mediaTypeAndParams.split(";")[0]?.trim() || fallbackMimeType;
1342
+ const extension2 = mimeType.split("/")[1] ?? fallbackExtension;
1343
+ return { mimeType, extension: extension2 };
1344
+ }
1205
1345
  function parseDataUrl(value) {
1206
- const match = /^data:([^,]*?);base64,(.*)$/i.exec(value);
1207
- if (!match) {
1208
- return null;
1346
+ const base64Match = /^data:([^,]*?);base64,(.*)$/i.exec(value);
1347
+ if (base64Match) {
1348
+ const { mimeType, extension: extension2 } = mimeTypeAndExtensionFromMediaType(
1349
+ base64Match[1] ?? "",
1350
+ "application/octet-stream",
1351
+ "bin"
1352
+ );
1353
+ return {
1354
+ mimeType,
1355
+ extension: extension2,
1356
+ data: base64Match[2] ?? ""
1357
+ };
1209
1358
  }
1210
- const mediaTypeAndParams = match[1] ?? "";
1211
- const mimeType = mediaTypeAndParams.split(";")[0]?.trim() || "application/octet-stream";
1212
- const extension2 = mimeType.split("/")[1] ?? "bin";
1213
- return {
1214
- mimeType,
1215
- extension: extension2,
1216
- data: match[2] ?? ""
1217
- };
1359
+ const plainMatch = /^data:([^,]*?),(.*)$/i.exec(value);
1360
+ if (plainMatch) {
1361
+ const { mimeType, extension: extension2 } = mimeTypeAndExtensionFromMediaType(
1362
+ plainMatch[1] ?? "",
1363
+ "text/plain",
1364
+ "txt"
1365
+ );
1366
+ let decoded;
1367
+ try {
1368
+ decoded = decodeURIComponent(plainMatch[2] ?? "");
1369
+ } catch {
1370
+ throw new AiConnectError(
1371
+ "validation_error",
1372
+ "unsupported data URL encoding"
1373
+ );
1374
+ }
1375
+ return {
1376
+ mimeType,
1377
+ extension: extension2,
1378
+ data: encodeBase64(new TextEncoder().encode(decoded))
1379
+ };
1380
+ }
1381
+ if (/^data:/i.test(value)) {
1382
+ throw new AiConnectError(
1383
+ "validation_error",
1384
+ "unsupported data URL encoding"
1385
+ );
1386
+ }
1387
+ return null;
1218
1388
  }
1219
1389
  function mimeTypeFromName(name) {
1220
1390
  const ext = extension(name);
@@ -1428,9 +1598,61 @@ async function materializePortableFile(file) {
1428
1598
  payload.dataUrl = `data:${mimeType};base64,${base64}`;
1429
1599
  }
1430
1600
  }
1601
+ if (category === "other") {
1602
+ const base64 = await portableFileToBase64(file);
1603
+ if (base64 !== void 0) {
1604
+ payload.base64 = base64;
1605
+ payload.dataUrl = `data:${mimeType};base64,${base64}`;
1606
+ } else if (!uri && !providerFileId) {
1607
+ 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.`;
1608
+ payload.droppedReason = droppedReason;
1609
+ payload.warnings = [...payload.warnings ?? [], droppedReason];
1610
+ }
1611
+ }
1431
1612
  return payload;
1432
1613
  }
1433
- function preparePortableFile(input) {
1614
+ function collapsePathSegments(value) {
1615
+ const normalized = value.replaceAll("\\", "/");
1616
+ const isAbsolute = normalized.startsWith("/");
1617
+ const segments = normalized.split("/");
1618
+ const resolved = [];
1619
+ for (const segment of segments) {
1620
+ if (segment === "" || segment === ".") {
1621
+ continue;
1622
+ }
1623
+ if (segment === "..") {
1624
+ if (resolved.length > 0 && resolved[resolved.length - 1] !== "..") {
1625
+ resolved.pop();
1626
+ } else if (!isAbsolute) {
1627
+ resolved.push("..");
1628
+ }
1629
+ continue;
1630
+ }
1631
+ resolved.push(segment);
1632
+ }
1633
+ return (isAbsolute ? "/" : "") + resolved.join("/");
1634
+ }
1635
+ function applyPathGuard(rawPath, guard) {
1636
+ if (!guard) {
1637
+ return rawPath;
1638
+ }
1639
+ if (guard === "basename") {
1640
+ return basename(rawPath);
1641
+ }
1642
+ const resolved = collapsePathSegments(rawPath);
1643
+ const withinAllowedRoot = guard.allowedRoots.some((root) => {
1644
+ const resolvedRoot = collapsePathSegments(root);
1645
+ return resolved === resolvedRoot || resolved.startsWith(`${resolvedRoot}/`);
1646
+ });
1647
+ if (!withinAllowedRoot) {
1648
+ throw new AiConnectError(
1649
+ "validation_error",
1650
+ `Path "${rawPath}" is outside the configured allowedRoots for portable file attachments.`
1651
+ );
1652
+ }
1653
+ return rawPath;
1654
+ }
1655
+ function preparePortableFile(input, options) {
1434
1656
  if (isPortableFile(input)) {
1435
1657
  return input;
1436
1658
  }
@@ -1456,11 +1678,12 @@ function preparePortableFile(input) {
1456
1678
  }
1457
1679
  };
1458
1680
  }
1681
+ const guardedPath = applyPathGuard(input, options?.pathGuard);
1459
1682
  return {
1460
- id: `path:${input}`,
1683
+ id: `path:${guardedPath}`,
1461
1684
  kind: "path",
1462
- name: basename(input),
1463
- source: input
1685
+ name: basename(guardedPath),
1686
+ source: guardedPath
1464
1687
  };
1465
1688
  }
1466
1689
  if (isRemoteReference(input)) {
@@ -1501,17 +1724,76 @@ function preparePortableFile(input) {
1501
1724
  }
1502
1725
 
1503
1726
  // src/logging.ts
1727
+ var REDACT_KEYS = /* @__PURE__ */ new Set([
1728
+ "apikey",
1729
+ "authorization",
1730
+ "password",
1731
+ "token",
1732
+ "x-api-key",
1733
+ "x-goog-api-key"
1734
+ ]);
1735
+ var REDACTED = "[REDACTED]";
1736
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1737
+ var SECRET_PREFIX_RE = /^(sk-|sk-ant-|xox[baprs]-|ghp_|gho_|AIza)/;
1738
+ function looksLikeSecretValue(value) {
1739
+ if (UUID_RE.test(value)) {
1740
+ return false;
1741
+ }
1742
+ if (/^Bearer\s+/i.test(value)) {
1743
+ return true;
1744
+ }
1745
+ if (SECRET_PREFIX_RE.test(value)) {
1746
+ return true;
1747
+ }
1748
+ return /^[A-Za-z0-9+/=_]{40,}$/.test(value);
1749
+ }
1750
+ function redactNode(node) {
1751
+ if (node === null || node === void 0) {
1752
+ return node;
1753
+ }
1754
+ if (typeof node === "string") {
1755
+ return looksLikeSecretValue(node) ? REDACTED : node;
1756
+ }
1757
+ if (typeof node !== "object") {
1758
+ return node;
1759
+ }
1760
+ if (Array.isArray(node)) {
1761
+ return node.map((item) => redactNode(item));
1762
+ }
1763
+ const result = {};
1764
+ for (const [key, value] of Object.entries(node)) {
1765
+ result[key] = REDACT_KEYS.has(key.toLowerCase()) ? REDACTED : redactNode(value);
1766
+ }
1767
+ return result;
1768
+ }
1769
+ function redactEvent(event) {
1770
+ return redactNode(event);
1771
+ }
1772
+ function redactBaseUrl(url) {
1773
+ try {
1774
+ const parsed = new URL(url);
1775
+ parsed.username = "";
1776
+ parsed.password = "";
1777
+ parsed.search = "";
1778
+ parsed.hash = "";
1779
+ return parsed.toString();
1780
+ } catch {
1781
+ const withoutHash = url.split("#")[0] ?? url;
1782
+ return withoutHash.split("?")[0] ?? withoutHash;
1783
+ }
1784
+ }
1504
1785
  function createConsoleWideEventLogger(options = {}) {
1505
1786
  const write = options.write ?? ((line) => {
1506
1787
  console.info(line);
1507
1788
  });
1508
1789
  return (event) => {
1790
+ const redacted = redactEvent(event);
1509
1791
  write(
1510
- options.pretty ? JSON.stringify(event, null, 2) : JSON.stringify(event)
1792
+ options.pretty ? JSON.stringify(redacted, null, 2) : JSON.stringify(redacted)
1511
1793
  );
1512
1794
  };
1513
1795
  }
1514
- async function shouldEmitWideEvent(event, options = {}) {
1796
+ async function shouldEmitWideEvent(event, options = {}, random = Math.random) {
1515
1797
  const sampleRate = options.sampleRate ?? 1;
1516
1798
  if ((options.keepErrors ?? true) && event.outcome === "error") {
1517
1799
  return true;
@@ -1545,7 +1827,7 @@ async function shouldEmitWideEvent(event, options = {}) {
1545
1827
  if (sampleRate >= 1) {
1546
1828
  return true;
1547
1829
  }
1548
- return Math.random() < sampleRate;
1830
+ return random() < sampleRate;
1549
1831
  }
1550
1832
 
1551
1833
  // src/router.ts
@@ -1610,6 +1892,19 @@ function buildWeightedOrder(routes) {
1610
1892
  }
1611
1893
  return expanded;
1612
1894
  }
1895
+ var ROUTING_STRATEGIES = {
1896
+ priority: (ordered) => ordered,
1897
+ failover: (ordered) => ordered,
1898
+ "round-robin": (ordered, cursor, cursorKey) => {
1899
+ const index = cursor(cursorKey, ordered.length);
1900
+ return rotate(ordered, index);
1901
+ },
1902
+ "weighted-round-robin": (ordered, cursor, cursorKey) => {
1903
+ const weighted = buildWeightedOrder(ordered);
1904
+ const index = cursor(cursorKey, weighted.length);
1905
+ return unique(rotate(weighted, index));
1906
+ }
1907
+ };
1613
1908
  var RouteRegistry = class {
1614
1909
  config;
1615
1910
  runtime;
@@ -1680,14 +1975,21 @@ var RouteRegistry = class {
1680
1975
  const routeIds = request.routeHints?.pool?.length ? resolveRouteSelectors(request.routeHints.pool, this.config.routes) : operationRouting.pool;
1681
1976
  const excludedIds = new Set(request.routeHints?.excludeRouteIds ?? []);
1682
1977
  const requestedModel = request.routeHints?.model;
1683
- 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) => {
1684
- const state = this.getHealth(route).state;
1685
- return state !== "cooling_down" && state !== "unavailable";
1686
- }).map((route) => applyRequestedModel(route, requestedModel)).filter((route) => Boolean(route));
1978
+ 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));
1979
+ const strategy = request.routeHints?.strategy ?? operationRouting.strategy;
1980
+ const cursorKey = `${operation}:${strategy}:${eligible.map((route) => route.id).join("|")}`;
1981
+ const healthyIds = new Set(
1982
+ eligible.filter((route) => {
1983
+ const state = this.getHealth(route).state;
1984
+ return state !== "cooling_down" && state !== "unavailable";
1985
+ }).map((route) => route.id)
1986
+ );
1687
1987
  return this.#order(
1688
- request.routeHints?.strategy ?? operationRouting.strategy,
1689
- available,
1690
- request.routeHints?.preferredProviders
1988
+ strategy,
1989
+ eligible,
1990
+ healthyIds,
1991
+ request.routeHints?.preferredProviders,
1992
+ cursorKey
1691
1993
  );
1692
1994
  }
1693
1995
  recordSuccess(route) {
@@ -1720,20 +2022,12 @@ var RouteRegistry = class {
1720
2022
  failureCount
1721
2023
  });
1722
2024
  }
1723
- #order(strategy, routes, preferredProviders) {
1724
- const sorted = baseSort(routes, preferredProviders);
1725
- const cursorKey = `${strategy}:${sorted.map((route) => route.id).join("|")}`;
1726
- const ordered = this.config.routing.shuffleOnInit ? this.#shuffle(cursorKey, sorted) : sorted;
1727
- if (strategy === "priority" || strategy === "failover") {
1728
- return ordered;
1729
- }
1730
- if (strategy === "round-robin") {
1731
- const index2 = this.#cursor(cursorKey, ordered.length);
1732
- return rotate(ordered, index2);
1733
- }
1734
- const weighted = buildWeightedOrder(ordered);
1735
- const index = this.#cursor(cursorKey, weighted.length);
1736
- return unique(rotate(weighted, index));
2025
+ #order(strategy, eligible, healthyIds, preferredProviders, cursorKey) {
2026
+ const sorted = baseSort(eligible, preferredProviders);
2027
+ const orderedFull = this.config.routing.shuffleOnInit ? this.#shuffle(cursorKey, sorted) : sorted;
2028
+ const ordered = orderedFull.filter((route) => healthyIds.has(route.id));
2029
+ const strategyFn = ROUTING_STRATEGIES[strategy];
2030
+ return strategyFn(ordered, (key, length) => this.#cursor(key, length), cursorKey);
1737
2031
  }
1738
2032
  #shuffle(key, routes) {
1739
2033
  const cached = this.#shuffles.get(key);
@@ -1837,8 +2131,22 @@ var MODEL_REFERENCE = {
1837
2131
  "gemini-1.5-flash": { key: "gemini-1.5-flash", contextLength: 1048576 },
1838
2132
  "gemini-1.5-pro": { key: "gemini-1.5-pro", contextLength: 2097152 }
1839
2133
  };
2134
+ var REASONING_EFFORT_SUFFIXES = /* @__PURE__ */ new Set([
2135
+ "minimal",
2136
+ "low",
2137
+ "medium",
2138
+ "high",
2139
+ "xhigh"
2140
+ ]);
1840
2141
  function normalizeModelKey(model) {
1841
2142
  let key = model.trim().toLowerCase();
2143
+ const lastSlashIndex = key.lastIndexOf("/");
2144
+ if (lastSlashIndex > 0 && lastSlashIndex < key.length - 1) {
2145
+ const tail = key.slice(lastSlashIndex + 1);
2146
+ if (REASONING_EFFORT_SUFFIXES.has(tail)) {
2147
+ key = key.slice(0, lastSlashIndex);
2148
+ }
2149
+ }
1842
2150
  const slashIndex = key.indexOf("/");
1843
2151
  if (slashIndex > 0) {
1844
2152
  key = key.slice(slashIndex + 1);
@@ -1846,6 +2154,7 @@ function normalizeModelKey(model) {
1846
2154
  key = key.replace(/:(?:free|beta)$/i, "");
1847
2155
  return key.trim();
1848
2156
  }
2157
+ var MIN_SUBSTRING_MATCH_KEY_LENGTH = 4;
1849
2158
  function lookupModelRef(model) {
1850
2159
  const normalized = normalizeModelKey(model);
1851
2160
  if (!normalized) {
@@ -1868,7 +2177,7 @@ function lookupModelRef(model) {
1868
2177
  }
1869
2178
  let substringWinner;
1870
2179
  for (const [key, entry] of Object.entries(MODEL_REFERENCE)) {
1871
- if (normalized.includes(key)) {
2180
+ if (key.length >= MIN_SUBSTRING_MATCH_KEY_LENGTH && normalized.includes(key)) {
1872
2181
  if (!substringWinner || key.length > substringWinner.key.length) {
1873
2182
  substringWinner = entry;
1874
2183
  }
@@ -2103,6 +2412,14 @@ var DEFAULT_LOGGING_SERVICE = "ai-connect";
2103
2412
  function splitCredentialValues(value, delimiter2) {
2104
2413
  return value.split(delimiter2).map((item) => item.trim()).filter(Boolean);
2105
2414
  }
2415
+ var ClientToolExecutionError = class extends Error {
2416
+ aiError;
2417
+ constructor(aiError) {
2418
+ super(aiError.message);
2419
+ this.name = "ClientToolExecutionError";
2420
+ this.aiError = aiError;
2421
+ }
2422
+ };
2106
2423
  function materializeRuntimeConfig(config, runtime) {
2107
2424
  const routeExpansion = /* @__PURE__ */ new Map();
2108
2425
  const routes = [];
@@ -2117,13 +2434,14 @@ function materializeRuntimeConfig(config, runtime) {
2117
2434
  continue;
2118
2435
  }
2119
2436
  const expandedIds = [];
2437
+ const expandedWeight = Math.max(1, Math.floor(route.weight / values.length));
2120
2438
  values.forEach((apiKey, index) => {
2121
2439
  const credentialId = `${route.credentialId}#${index + 1}`;
2122
2440
  const expandedRoute = {
2123
2441
  ...route,
2124
2442
  id: `${route.id}#${index + 1}`,
2125
2443
  credentialId,
2126
- weight: 1,
2444
+ weight: expandedWeight,
2127
2445
  credential: {
2128
2446
  ...route.credential,
2129
2447
  id: credentialId,
@@ -2164,6 +2482,27 @@ async function sleep(delayMs) {
2164
2482
  }
2165
2483
  await new Promise((resolve) => setTimeout(resolve, delayMs));
2166
2484
  }
2485
+ async function runWithAbortRace(handlerPromise, abort) {
2486
+ if (abort.signal.aborted) {
2487
+ void Promise.resolve(handlerPromise).catch(() => {
2488
+ });
2489
+ throw mapAbortError(abort.reason);
2490
+ }
2491
+ let onAbort;
2492
+ const abortPromise = new Promise((_, reject) => {
2493
+ onAbort = () => reject(mapAbortError(abort.reason));
2494
+ abort.signal.addEventListener("abort", onAbort, { once: true });
2495
+ });
2496
+ try {
2497
+ return await Promise.race([handlerPromise, abortPromise]);
2498
+ } finally {
2499
+ if (onAbort) {
2500
+ abort.signal.removeEventListener("abort", onAbort);
2501
+ }
2502
+ void Promise.resolve(handlerPromise).catch(() => {
2503
+ });
2504
+ }
2505
+ }
2167
2506
  function normalizeWorkingDirectory(value) {
2168
2507
  if (value === void 0) {
2169
2508
  return void 0;
@@ -2205,6 +2544,12 @@ function normalizeClientToolRegistry(tools) {
2205
2544
  const registry = /* @__PURE__ */ new Map();
2206
2545
  for (const tool of tools ?? []) {
2207
2546
  const normalized = normalizeClientToolDefinition(tool);
2547
+ if (registry.has(normalized.function.name)) {
2548
+ throw new AiConnectError(
2549
+ "validation_error",
2550
+ `Duplicate client tool name "${normalized.function.name}" registered on this client.`
2551
+ );
2552
+ }
2208
2553
  registry.set(normalized.function.name, normalized);
2209
2554
  }
2210
2555
  return registry;
@@ -2230,10 +2575,22 @@ function resolveClientToolSelections(selections, registry) {
2230
2575
  `No client tool named "${name}" is registered on this client.`
2231
2576
  );
2232
2577
  }
2578
+ if (resolved.has(name)) {
2579
+ throw new AiConnectError(
2580
+ "validation_error",
2581
+ `Duplicate client tool "${name}" in the clientTools selection.`
2582
+ );
2583
+ }
2233
2584
  resolved.set(name, registered);
2234
2585
  continue;
2235
2586
  }
2236
2587
  const normalized = normalizeClientToolDefinition(selection);
2588
+ if (resolved.has(normalized.function.name)) {
2589
+ throw new AiConnectError(
2590
+ "validation_error",
2591
+ `Duplicate client tool "${normalized.function.name}" in the clientTools selection.`
2592
+ );
2593
+ }
2237
2594
  resolved.set(normalized.function.name, normalized);
2238
2595
  }
2239
2596
  return Array.from(resolved.values());
@@ -2346,6 +2703,34 @@ function requiresImageInput(request) {
2346
2703
  function requiresFileUpload(request) {
2347
2704
  return request.attachments.some((file) => isPortableDocumentFile(file));
2348
2705
  }
2706
+ function resolveRequiredCapabilities(request) {
2707
+ const requiresSchema = requiresToolSchema(request);
2708
+ const requiresClientTools = requiresClientToolExecution(request);
2709
+ const requiresImage = requiresImageInput(request);
2710
+ const requiresFile = requiresFileUpload(request);
2711
+ const requiresAnyCapability = requiresSchema || requiresClientTools || requiresImage || requiresFile;
2712
+ const projected = requiresAnyCapability ? {
2713
+ ...request,
2714
+ routeHints: {
2715
+ ...request.routeHints ?? {},
2716
+ requiredCapabilities: {
2717
+ ...request.routeHints?.requiredCapabilities ?? {},
2718
+ ...requiresSchema ? { supportsToolSchema: true } : {},
2719
+ ...requiresClientTools ? { supportsClientToolExecution: true } : {},
2720
+ ...requiresImage ? { supportsImageInput: true } : {},
2721
+ ...requiresFile ? { supportsFileUpload: true } : {}
2722
+ }
2723
+ }
2724
+ } : request;
2725
+ return {
2726
+ requiresSchema,
2727
+ requiresClientTools,
2728
+ requiresImage,
2729
+ requiresFile,
2730
+ requiresAnyCapability,
2731
+ request: projected
2732
+ };
2733
+ }
2349
2734
  function normalizeRequestBase(request) {
2350
2735
  if (request.messages.length === 0) {
2351
2736
  throw new AiConnectError("validation_error", "Unified generate/stream requests must include at least one message.");
@@ -2554,7 +2939,10 @@ function summarizeRoute(route) {
2554
2939
  model: route.model,
2555
2940
  handlerKey: route.handlerKey,
2556
2941
  profileId: route.profileId,
2557
- ...route.transport.baseUrl ? { baseUrl: route.transport.baseUrl } : {}
2942
+ // SYS-3 / C1a: never emit a raw baseUrl into a wide event — a token embedded in
2943
+ // userinfo (`https://user:secret@host`) or the query string would leak verbatim,
2944
+ // unlike the secret-safe toPublicRoute projection. Strip credentials first.
2945
+ ...route.transport.baseUrl ? { baseUrl: redactBaseUrl(route.transport.baseUrl) } : {}
2558
2946
  };
2559
2947
  }
2560
2948
  function summarizeFiles(files) {
@@ -2865,21 +3253,40 @@ function createClient(configOrInput, options = {}) {
2865
3253
  for (const toolCall of toolCalls) {
2866
3254
  const tool = registry.get(toolCall.name);
2867
3255
  if (!tool) {
2868
- throw new AiConnectError(
2869
- "validation_error",
2870
- `No client tool handler is registered for "${toolCall.name}".`
3256
+ throw new ClientToolExecutionError(
3257
+ new AiConnectError(
3258
+ "validation_error",
3259
+ `No client tool handler is registered for "${toolCall.name}".`
3260
+ )
3261
+ );
3262
+ }
3263
+ if (abort.signal.aborted) {
3264
+ throw mapAbortError(abort.reason);
3265
+ }
3266
+ let execution;
3267
+ try {
3268
+ execution = normalizeToolResult(
3269
+ await tool.execute(toolCall.arguments, {
3270
+ route,
3271
+ request,
3272
+ runtime,
3273
+ toolCall,
3274
+ messages,
3275
+ ...request.workingDirectory ? { workingDirectory: request.workingDirectory } : {}
3276
+ })
3277
+ );
3278
+ } catch (error) {
3279
+ if (abort.signal.aborted) {
3280
+ throw mapAbortError(abort.reason);
3281
+ }
3282
+ throw new ClientToolExecutionError(
3283
+ new AiConnectError(
3284
+ "validation_error",
3285
+ `Client tool "${tool.function.name}" threw during execution: ${error instanceof Error ? error.message : String(error)}`,
3286
+ { cause: error }
3287
+ )
2871
3288
  );
2872
3289
  }
2873
- const execution = normalizeToolResult(
2874
- await tool.execute(toolCall.arguments, {
2875
- route,
2876
- request,
2877
- runtime,
2878
- toolCall,
2879
- messages,
2880
- ...request.workingDirectory ? { workingDirectory: request.workingDirectory } : {}
2881
- })
2882
- );
2883
3290
  toolMessages.push({
2884
3291
  role: "tool",
2885
3292
  content: execution.content,
@@ -2893,28 +3300,33 @@ function createClient(configOrInput, options = {}) {
2893
3300
  if (abort.signal.aborted) {
2894
3301
  throw mapAbortError(abort.reason);
2895
3302
  }
2896
- output = await handler({
2897
- route,
2898
- request: {
2899
- ...request,
2900
- messages,
2901
- attachments: []
2902
- },
2903
- runtime,
2904
- attempt: baseAttempt,
2905
- abort,
2906
- telemetry: {
2907
- captureTransport(summary) {
2908
- transportSummary2 = summary;
3303
+ output = await runWithAbortRace(
3304
+ handler({
3305
+ route,
3306
+ request: {
3307
+ ...request,
3308
+ messages,
3309
+ attachments: []
3310
+ },
3311
+ runtime,
3312
+ attempt: baseAttempt,
3313
+ abort,
3314
+ telemetry: {
3315
+ captureTransport(summary) {
3316
+ transportSummary2 = summary;
3317
+ }
2909
3318
  }
2910
- }
2911
- });
3319
+ }),
3320
+ abort
3321
+ );
2912
3322
  warnings.push(...output.warnings ?? []);
2913
3323
  usage = mergeUsage(usage, output.usage);
2914
3324
  }
2915
- throw new AiConnectError(
2916
- "validation_error",
2917
- "clientTools exceeded the maximum tool-call loop depth of 8."
3325
+ throw new ClientToolExecutionError(
3326
+ new AiConnectError(
3327
+ "validation_error",
3328
+ "clientTools exceeded the maximum tool-call loop depth of 8."
3329
+ )
2918
3330
  );
2919
3331
  }
2920
3332
  async function emitWideEvent(event) {
@@ -2945,26 +3357,8 @@ function createClient(configOrInput, options = {}) {
2945
3357
  async function executeGenerateOperation(normalizedRequest, operationName, abort, seed = {}) {
2946
3358
  const startedAt = seed.startMs ?? nowMs(runtime);
2947
3359
  const requestId = seed.requestId ?? resolveEventRequestId(normalizedRequest.logContext);
2948
- const requiresSchema = requiresToolSchema(normalizedRequest);
2949
- const requiresClientTools = requiresClientToolExecution(normalizedRequest);
2950
- const requiresImage = requiresImageInput(normalizedRequest);
2951
- const requiresFile = requiresFileUpload(normalizedRequest);
2952
- const requiresAnyCapability = requiresSchema || requiresClientTools || requiresImage || requiresFile;
2953
- const resolvedCandidates = router.resolveCandidates(
2954
- requiresAnyCapability ? {
2955
- ...normalizedRequest,
2956
- routeHints: {
2957
- ...normalizedRequest.routeHints ?? {},
2958
- requiredCapabilities: {
2959
- ...normalizedRequest.routeHints?.requiredCapabilities ?? {},
2960
- ...requiresSchema ? { supportsToolSchema: true } : {},
2961
- ...requiresClientTools ? { supportsClientToolExecution: true } : {},
2962
- ...requiresImage ? { supportsImageInput: true } : {},
2963
- ...requiresFile ? { supportsFileUpload: true } : {}
2964
- }
2965
- }
2966
- } : normalizedRequest
2967
- );
3360
+ const { requiresImage, requiresFile, request: capabilityScopedRequest } = resolveRequiredCapabilities(normalizedRequest);
3361
+ const resolvedCandidates = router.resolveCandidates(capabilityScopedRequest);
2968
3362
  const candidates = seed.candidates ?? resolvedCandidates.map(summarizeRoute);
2969
3363
  const requestSummary = summarizeRequest(normalizedRequest, candidates);
2970
3364
  if (resolvedCandidates.length === 0) {
@@ -3052,18 +3446,21 @@ function createClient(configOrInput, options = {}) {
3052
3446
  const attemptStartedAt = nowMs(runtime);
3053
3447
  let transportSummary2;
3054
3448
  try {
3055
- let output = await handler.generate({
3056
- route,
3057
- request: routeRequest,
3058
- runtime,
3059
- attempt: attempts.length + 1,
3060
- abort,
3061
- telemetry: {
3062
- captureTransport(summary) {
3063
- transportSummary2 = summary;
3449
+ let output = await runWithAbortRace(
3450
+ handler.generate({
3451
+ route,
3452
+ request: routeRequest,
3453
+ runtime,
3454
+ attempt: attempts.length + 1,
3455
+ abort,
3456
+ telemetry: {
3457
+ captureTransport(summary) {
3458
+ transportSummary2 = summary;
3459
+ }
3064
3460
  }
3065
- }
3066
- });
3461
+ }),
3462
+ abort
3463
+ );
3067
3464
  if (routeRequest.clientTools?.length && output.toolCalls?.length) {
3068
3465
  const loopResult = await executeClientToolLoop(
3069
3466
  route,
@@ -3078,6 +3475,9 @@ function createClient(configOrInput, options = {}) {
3078
3475
  transportSummary2 = loopResult.transportSummary;
3079
3476
  }
3080
3477
  }
3478
+ if (abort.signal.aborted) {
3479
+ throw mapAbortError(abort.reason);
3480
+ }
3081
3481
  wideAttempts.push({
3082
3482
  index: wideAttempts.length + 1,
3083
3483
  route: routeSummary,
@@ -3148,6 +3548,49 @@ function createClient(configOrInput, options = {}) {
3148
3548
  routeId: route.id
3149
3549
  });
3150
3550
  }
3551
+ if (error instanceof ClientToolExecutionError) {
3552
+ const toolError = error.aiError;
3553
+ attempts.push({
3554
+ routeId: route.id,
3555
+ handlerKey: route.handlerKey,
3556
+ errorCode: toolError.code,
3557
+ message: toolError.message
3558
+ });
3559
+ wideAttempts.push({
3560
+ index: wideAttempts.length + 1,
3561
+ route: routeSummary,
3562
+ durationMs: nowMs(runtime) - attemptStartedAt,
3563
+ outcome: "error",
3564
+ retryCount,
3565
+ errorCode: toolError.code,
3566
+ message: toolError.message,
3567
+ ...transportSummary2 ? { transport: transportSummary2 } : {}
3568
+ });
3569
+ await emitWideEvent({
3570
+ timestamp: isoTimestamp(startedAt),
3571
+ event: "ai_connect.operation",
3572
+ requestId,
3573
+ operationName,
3574
+ operation: normalizedRequest.operation,
3575
+ runtime: runtime.kind,
3576
+ outcome: "error",
3577
+ durationMs: nowMs(runtime) - startedAt,
3578
+ candidates,
3579
+ attempts: wideAttempts,
3580
+ request: requestSummary,
3581
+ error: {
3582
+ code: toolError.code,
3583
+ message: toolError.message,
3584
+ routeId: route.id
3585
+ },
3586
+ ...normalizedRequest.logContext ? { context: normalizedRequest.logContext } : {}
3587
+ });
3588
+ throw new AiConnectError(toolError.code, toolError.message, {
3589
+ ...toolError.details,
3590
+ attempts,
3591
+ routeId: route.id
3592
+ });
3593
+ }
3151
3594
  const normalizedError = toAiConnectError(error);
3152
3595
  const actions = config.routing.fallback.on[normalizedError.code] ?? [];
3153
3596
  attempts.push({
@@ -3392,30 +3835,16 @@ function createClient(configOrInput, options = {}) {
3392
3835
  return report;
3393
3836
  }
3394
3837
  async function runPingWithAbort(route, handler, abort) {
3395
- if (abort.signal.aborted) {
3396
- throw mapAbortError(abort.reason);
3397
- }
3398
- const handlerPromise = handler({
3399
- route,
3400
- request: buildPingRequest(),
3401
- runtime,
3402
- attempt: 1,
3838
+ return runWithAbortRace(
3839
+ handler({
3840
+ route,
3841
+ request: buildPingRequest(),
3842
+ runtime,
3843
+ attempt: 1,
3844
+ abort
3845
+ }),
3403
3846
  abort
3404
- });
3405
- let onAbort;
3406
- const abortPromise = new Promise((_, reject) => {
3407
- onAbort = () => reject(mapAbortError(abort.reason));
3408
- abort.signal.addEventListener("abort", onAbort, { once: true });
3409
- });
3410
- try {
3411
- return await Promise.race([handlerPromise, abortPromise]);
3412
- } finally {
3413
- if (onAbort) {
3414
- abort.signal.removeEventListener("abort", onAbort);
3415
- }
3416
- void Promise.resolve(handlerPromise).catch(() => {
3417
- });
3418
- }
3847
+ );
3419
3848
  }
3420
3849
  async function checkEndpointReachable(route, handler, abort) {
3421
3850
  if (abort.signal.aborted) {
@@ -3983,19 +4412,8 @@ function createClient(configOrInput, options = {}) {
3983
4412
  release = await limiter.acquire(abort.signal);
3984
4413
  const selectedModel = await resolveSelectedModel(preparedRequest);
3985
4414
  const normalizedRequest = withSelectedModel(preparedRequest, selectedModel);
3986
- const requiresSchema = requiresToolSchema(normalizedRequest);
3987
- const candidates = router.resolveCandidates(
3988
- requiresSchema ? {
3989
- ...normalizedRequest,
3990
- routeHints: {
3991
- ...normalizedRequest.routeHints ?? {},
3992
- requiredCapabilities: {
3993
- ...normalizedRequest.routeHints?.requiredCapabilities ?? {},
3994
- supportsToolSchema: true
3995
- }
3996
- }
3997
- } : normalizedRequest
3998
- );
4415
+ const { requiresImage, requiresFile, request: capabilityScopedRequest } = resolveRequiredCapabilities(normalizedRequest);
4416
+ const candidates = router.resolveCandidates(capabilityScopedRequest);
3999
4417
  const startedAt = nowMs(runtime);
4000
4418
  const requestId = resolveEventRequestId(normalizedRequest.logContext);
4001
4419
  const candidateSummaries = candidates.map(summarizeRoute);
@@ -4003,7 +4421,10 @@ function createClient(configOrInput, options = {}) {
4003
4421
  const attempts = [];
4004
4422
  const wideAttempts = [];
4005
4423
  if (candidates.length === 0) {
4006
- const error = new AiConnectError("not_supported", `No eligible routes are available for operation "${normalizedRequest.operation}" in runtime "${runtime.kind}".`);
4424
+ const error = requiresImage || requiresFile ? new AiConnectError(
4425
+ "unsupported_capability",
4426
+ `No eligible route supports the required ${requiresFile ? "document/file" : "image"} input for operation "${normalizedRequest.operation}" in runtime "${runtime.kind}".`
4427
+ ) : new AiConnectError("not_supported", `No eligible routes are available for operation "${normalizedRequest.operation}" in runtime "${runtime.kind}".`);
4007
4428
  await emitWideEvent({
4008
4429
  timestamp: isoTimestamp(startedAt),
4009
4430
  event: "ai_connect.operation",
@@ -4479,12 +4900,32 @@ function trimTrailingSlash(value) {
4479
4900
  function appendPath(baseUrl, suffix) {
4480
4901
  return `${trimTrailingSlash(baseUrl)}/${suffix.replace(/^\/+/, "")}`;
4481
4902
  }
4903
+ function isAbortError(error) {
4904
+ if (error instanceof AiConnectError) {
4905
+ return error.code === "aborted";
4906
+ }
4907
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
4908
+ }
4909
+ function attachPartsToLastUserTurn(conversation, createEmptyUserTurn) {
4910
+ let targetIndex = -1;
4911
+ for (let index = conversation.length - 1; index >= 0; index -= 1) {
4912
+ if (conversation[index]?.role === "user") {
4913
+ targetIndex = index;
4914
+ break;
4915
+ }
4916
+ }
4917
+ if (targetIndex === -1) {
4918
+ conversation.push(createEmptyUserTurn());
4919
+ targetIndex = conversation.length - 1;
4920
+ }
4921
+ return conversation[targetIndex];
4922
+ }
4482
4923
  function resolveOpenAiEndpoint(route, operation) {
4483
4924
  const configured = route.transport.baseUrl?.trim();
4484
4925
  if (!configured) {
4485
4926
  return operation === "image" ? "https://api.openai.com/v1/images/generations" : "https://api.openai.com/v1/chat/completions";
4486
4927
  }
4487
- if (configured.endsWith("/chat/completions") || configured.endsWith("/images/generations")) {
4928
+ if (operation === "text" && configured.endsWith("/chat/completions") || operation === "image" && configured.endsWith("/images/generations")) {
4488
4929
  return configured;
4489
4930
  }
4490
4931
  return appendPath(
@@ -5315,7 +5756,7 @@ async function uploadAnthropicFile(payload, uploader) {
5315
5756
  }
5316
5757
  try {
5317
5758
  const base = uploader.context.route.transport.baseUrl?.trim();
5318
- const root = base ? base.replace(/\/(messages|models)$/, "") : "https://api.anthropic.com/v1";
5759
+ const root = base ? trimTrailingSlash(base).replace(/\/(messages|models)$/, "") : "https://api.anthropic.com/v1";
5319
5760
  const endpoint = appendPath(root, "/files");
5320
5761
  const form = new FormData();
5321
5762
  form.append(
@@ -5339,7 +5780,10 @@ async function uploadAnthropicFile(payload, uploader) {
5339
5780
  }
5340
5781
  const id = recordOf(data).id;
5341
5782
  return typeof id === "string" ? id : void 0;
5342
- } catch {
5783
+ } catch (error) {
5784
+ if (uploader.context.abort.signal.aborted || isAbortError(error)) {
5785
+ throw error;
5786
+ }
5343
5787
  return void 0;
5344
5788
  }
5345
5789
  }
@@ -5372,7 +5816,10 @@ async function uploadOpenAiFile(payload, uploader) {
5372
5816
  }
5373
5817
  const id = recordOf(data).id;
5374
5818
  return typeof id === "string" ? id : void 0;
5375
- } catch {
5819
+ } catch (error) {
5820
+ if (uploader.context.abort.signal.aborted || isAbortError(error)) {
5821
+ throw error;
5822
+ }
5376
5823
  return void 0;
5377
5824
  }
5378
5825
  }
@@ -5382,10 +5829,11 @@ async function uploadGeminiFile(payload, uploader) {
5382
5829
  }
5383
5830
  try {
5384
5831
  const base = uploader.context.route.transport.baseUrl?.trim();
5385
- const endpoint = base ? appendPath(
5386
- base.replace(/\/v1beta\/models.*$/, "/v1beta").replace(/\/+$/, ""),
5832
+ const root = base ? trimTrailingSlash(base.replace(/\/v1beta\/models.*$/, "/v1beta")) : void 0;
5833
+ const endpoint = root ? appendPath(
5834
+ root.endsWith("/v1beta") ? `${root.slice(0, -"/v1beta".length)}/upload/v1beta` : `${root}/upload`,
5387
5835
  "/files"
5388
- ).replace("/v1beta/files", "/upload/v1beta/files") : "https://generativelanguage.googleapis.com/upload/v1beta/files";
5836
+ ) : "https://generativelanguage.googleapis.com/upload/v1beta/files";
5389
5837
  const response = await uploader.fetchImpl(endpoint, {
5390
5838
  method: "POST",
5391
5839
  headers: {
@@ -5404,7 +5852,10 @@ async function uploadGeminiFile(payload, uploader) {
5404
5852
  const file = recordOf(recordOf(data).file);
5405
5853
  const uri = file.uri ?? recordOf(data).uri;
5406
5854
  return typeof uri === "string" ? uri : void 0;
5407
- } catch {
5855
+ } catch (error) {
5856
+ if (uploader.context.abort.signal.aborted || isAbortError(error)) {
5857
+ throw error;
5858
+ }
5408
5859
  return void 0;
5409
5860
  }
5410
5861
  }
@@ -5602,21 +6053,10 @@ function attachOpenAiParts(conversation, parts) {
5602
6053
  if (parts.length === 0) {
5603
6054
  return conversation;
5604
6055
  }
5605
- let targetIndex = -1;
5606
- for (let index = conversation.length - 1; index >= 0; index -= 1) {
5607
- if (conversation[index]?.role === "user") {
5608
- targetIndex = index;
5609
- break;
5610
- }
5611
- }
5612
- if (targetIndex === -1) {
5613
- conversation.push({
5614
- role: "user",
5615
- content: ""
5616
- });
5617
- targetIndex = conversation.length - 1;
5618
- }
5619
- const target = conversation[targetIndex];
6056
+ const target = attachPartsToLastUserTurn(conversation, () => ({
6057
+ role: "user",
6058
+ content: ""
6059
+ }));
5620
6060
  const baseParts = typeof target.content === "string" && target.content.trim().length > 0 ? [{ type: "text", text: target.content }] : Array.isArray(target.content) ? target.content : [];
5621
6061
  target.content = [...baseParts, ...parts];
5622
6062
  return conversation;
@@ -5702,21 +6142,10 @@ async function anthropicRequest(messages, attachments, uploader) {
5702
6142
  }
5703
6143
  const attachmentParts = await anthropicAttachmentParts(attachments, uploader);
5704
6144
  if (attachmentParts.length > 0) {
5705
- let targetIndex = -1;
5706
- for (let index = conversation.length - 1; index >= 0; index -= 1) {
5707
- if (conversation[index]?.role === "user") {
5708
- targetIndex = index;
5709
- break;
5710
- }
5711
- }
5712
- if (targetIndex === -1) {
5713
- conversation.push({
5714
- role: "user",
5715
- content: ""
5716
- });
5717
- targetIndex = conversation.length - 1;
5718
- }
5719
- const target = conversation[targetIndex];
6145
+ const target = attachPartsToLastUserTurn(conversation, () => ({
6146
+ role: "user",
6147
+ content: ""
6148
+ }));
5720
6149
  const baseParts = typeof target.content === "string" && target.content.trim().length > 0 ? [{ type: "text", text: target.content }] : Array.isArray(target.content) ? target.content : [];
5721
6150
  target.content = [...baseParts, ...attachmentParts];
5722
6151
  }
@@ -5727,12 +6156,27 @@ async function anthropicRequest(messages, attachments, uploader) {
5727
6156
  }
5728
6157
  async function geminiRequest(messages, attachments, uploader) {
5729
6158
  const system = messages.filter((message) => message.role === "system").map((message) => message.content).join("\n\n");
6159
+ const toolNameByCallId = /* @__PURE__ */ new Map();
6160
+ for (const message of messages) {
6161
+ if (hasAssistantToolCalls(message)) {
6162
+ for (const call of message.toolCalls) {
6163
+ toolNameByCallId.set(call.id, call.name);
6164
+ }
6165
+ }
6166
+ }
5730
6167
  const contents = messages.filter((message) => message.role !== "system").reduce(
5731
6168
  (conversation, message) => {
5732
6169
  if (isToolMessage(message)) {
6170
+ const resolvedName = message.toolName ?? toolNameByCallId.get(message.toolCallId);
6171
+ if (!resolvedName) {
6172
+ throw new AiConnectError(
6173
+ "validation_error",
6174
+ `Gemini functionResponse requires a tool name: the tool message for toolCallId "${message.toolCallId}" has no toolName and no matching assistant toolCall was found.`
6175
+ );
6176
+ }
5733
6177
  const response = {
5734
6178
  functionResponse: {
5735
- name: message.toolName ?? "tool",
6179
+ name: resolvedName,
5736
6180
  response: isRecord(message.data) ? message.data : {
5737
6181
  content: message.content,
5738
6182
  ...message.isError !== void 0 ? { isError: message.isError } : {}
@@ -5779,21 +6223,11 @@ async function geminiRequest(messages, attachments, uploader) {
5779
6223
  );
5780
6224
  const attachmentParts = await geminiAttachmentParts(attachments, uploader);
5781
6225
  if (attachmentParts.length > 0) {
5782
- let targetIndex = -1;
5783
- for (let index = contents.length - 1; index >= 0; index -= 1) {
5784
- if (contents[index]?.role === "user") {
5785
- targetIndex = index;
5786
- break;
5787
- }
5788
- }
5789
- if (targetIndex === -1) {
5790
- contents.push({
5791
- role: "user",
5792
- parts: []
5793
- });
5794
- targetIndex = contents.length - 1;
5795
- }
5796
- contents[targetIndex].parts.push(...attachmentParts);
6226
+ const target = attachPartsToLastUserTurn(contents, () => ({
6227
+ role: "user",
6228
+ parts: []
6229
+ }));
6230
+ target.parts.push(...attachmentParts);
5797
6231
  }
5798
6232
  return {
5799
6233
  ...system ? {
@@ -5840,7 +6274,23 @@ function extractThoughts(value) {
5840
6274
  }
5841
6275
  return [];
5842
6276
  });
5843
- const joined = thoughts.join("\n").trim();
6277
+ const joined = thoughts.join("\n").trim();
6278
+ return joined.length > 0 ? joined : void 0;
6279
+ }
6280
+ function extractThinking(value) {
6281
+ if (!Array.isArray(value)) {
6282
+ return void 0;
6283
+ }
6284
+ const thinking = value.flatMap((part) => {
6285
+ if (part && typeof part === "object" && part.type === "thinking") {
6286
+ const candidate = part.thinking;
6287
+ if (typeof candidate === "string") {
6288
+ return [candidate];
6289
+ }
6290
+ }
6291
+ return [];
6292
+ });
6293
+ const joined = thinking.join("\n").trim();
5844
6294
  return joined.length > 0 ? joined : void 0;
5845
6295
  }
5846
6296
  function imageAttachmentsFromPayload(payload, ...sources) {
@@ -5930,6 +6380,10 @@ function buildOpenAiChatBody(context, messages, parameters, responseFormat, opts
5930
6380
  model: context.route.model,
5931
6381
  messages,
5932
6382
  stream: opts.stream,
6383
+ // Ask OpenAI-compatible streaming routes for the trailing usage chunk; without
6384
+ // `stream_options.include_usage` the SSE stream carries no `usage` block and
6385
+ // `runOpenAiStream` would always report `usage: undefined` (H5).
6386
+ ...opts.stream ? { stream_options: { include_usage: true } } : {},
5933
6387
  ...parameters?.maxTokens !== void 0 ? { max_tokens: parameters.maxTokens } : {},
5934
6388
  ...parameters?.temperature !== void 0 ? { temperature: parameters.temperature } : {},
5935
6389
  ...parameters?.topP !== void 0 ? { top_p: parameters.topP } : {},
@@ -6084,6 +6538,11 @@ async function* runOpenAiStream(fetchImpl, context) {
6084
6538
  }
6085
6539
  const response = outcome.response;
6086
6540
  const streamWarnings = [...uploaderWarnings];
6541
+ if (parameters?.candidateCount && parameters.candidateCount > 1) {
6542
+ streamWarnings.push(
6543
+ "candidateCount > 1 is forwarded to OpenAI-compatible text routes, but the normalized result returns only the first choice."
6544
+ );
6545
+ }
6087
6546
  if (droppedResponseFormat) {
6088
6547
  streamWarnings.push(
6089
6548
  "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."
@@ -6229,8 +6688,7 @@ function buildOpenAiStreamResult(context, text, toolCalls, usage, warnings) {
6229
6688
  } : {}
6230
6689
  };
6231
6690
  }
6232
- async function runOpenAiImage(fetchImpl, context) {
6233
- const apiKey = resolveApiKey(context.route, context.runtime);
6691
+ async function runProviderImage(fetchImpl, context, opts) {
6234
6692
  const referenceImages = imageReferenceFiles(context);
6235
6693
  const bundle = buildImagePromptBundle({
6236
6694
  request: context.request,
@@ -6239,10 +6697,10 @@ async function runOpenAiImage(fetchImpl, context) {
6239
6697
  });
6240
6698
  const baseFields = buildImageRequestFields(context, bundle);
6241
6699
  const useEditEndpoint = shouldUseImageEditEndpoint(context);
6242
- const endpoint = useEditEndpoint ? resolveOpenAiImageEditEndpoint(context.route) : resolveOpenAiEndpoint(context.route, "image");
6700
+ const endpoint = useEditEndpoint ? opts.resolveEditEndpoint(context.route) : opts.resolveEndpoint(context.route);
6243
6701
  transportSummary(
6244
6702
  context,
6245
- useEditEndpoint ? "openai-image-edit" : "openai-image",
6703
+ useEditEndpoint ? `${opts.providerLabel}-image-edit` : `${opts.providerLabel}-image`,
6246
6704
  endpoint,
6247
6705
  "POST",
6248
6706
  false,
@@ -6255,23 +6713,21 @@ async function runOpenAiImage(fetchImpl, context) {
6255
6713
  );
6256
6714
  const response = await fetchImpl(endpoint, useEditEndpoint ? {
6257
6715
  method: "POST",
6258
- headers: {
6259
- authorization: `Bearer ${apiKey}`
6260
- },
6716
+ headers: { ...opts.authHeaders },
6261
6717
  body: await buildImageEditFormData(context, baseFields),
6262
6718
  signal: context.abort.signal
6263
6719
  } : {
6264
6720
  method: "POST",
6265
6721
  headers: {
6266
6722
  "content-type": "application/json",
6267
- authorization: `Bearer ${apiKey}`
6723
+ ...opts.authHeaders
6268
6724
  },
6269
6725
  body: JSON.stringify(baseFields),
6270
6726
  signal: context.abort.signal
6271
6727
  });
6272
6728
  const payload = await readPayload(response);
6273
6729
  if (!response.ok) {
6274
- throw classifyApiError("openai", response, payload);
6730
+ throw classifyApiError(opts.classifyProvider, response, payload);
6275
6731
  }
6276
6732
  return {
6277
6733
  data: {
@@ -6281,6 +6737,16 @@ async function runOpenAiImage(fetchImpl, context) {
6281
6737
  attachments: imageAttachmentsFromPayload(payload)
6282
6738
  };
6283
6739
  }
6740
+ async function runOpenAiImage(fetchImpl, context) {
6741
+ const apiKey = resolveApiKey(context.route, context.runtime);
6742
+ return runProviderImage(fetchImpl, context, {
6743
+ resolveEndpoint: (route) => resolveOpenAiEndpoint(route, "image"),
6744
+ resolveEditEndpoint: (route) => resolveOpenAiImageEditEndpoint(route),
6745
+ authHeaders: { authorization: `Bearer ${apiKey}` },
6746
+ providerLabel: "openai",
6747
+ classifyProvider: "openai"
6748
+ });
6749
+ }
6284
6750
  async function runAnthropic(fetchImpl, context) {
6285
6751
  const apiKey = resolveApiKey(context.route, context.runtime);
6286
6752
  const uploaderWarnings = [];
@@ -6292,6 +6758,8 @@ async function runAnthropic(fetchImpl, context) {
6292
6758
  const parameters = context.request.parameters;
6293
6759
  const requestBody = {
6294
6760
  model: context.route.model,
6761
+ // Anthropic REQUIRES `max_tokens` (unlike OpenAI/Gemini, where it is optional), so a
6762
+ // provider-specific default of 4096 is applied when the caller omits `maxTokens`.
6295
6763
  max_tokens: parameters?.maxTokens ?? 4096,
6296
6764
  ...parameters?.temperature !== void 0 ? { temperature: parameters.temperature } : {},
6297
6765
  ...parameters?.topP !== void 0 ? { top_p: parameters.topP } : {},
@@ -6321,8 +6789,12 @@ async function runAnthropic(fetchImpl, context) {
6321
6789
  throw classifyApiError("anthropic", response, payload);
6322
6790
  }
6323
6791
  const data = payload;
6792
+ const anthropicReasoning = extractThinking(data.content);
6324
6793
  return {
6325
6794
  text: extractText(data.content),
6795
+ // Anthropic extended-thinking blocks, separated from the answer (consumers route this
6796
+ // to a thinking UI), mirroring the Gemini `reasoning` path.
6797
+ ...anthropicReasoning ? { reasoning: anthropicReasoning } : {},
6326
6798
  data,
6327
6799
  ...anthropicToolCallsFromPayload(data).length > 0 ? { toolCalls: anthropicToolCallsFromPayload(data) } : {},
6328
6800
  ...uploaderWarnings.length > 0 ? { warnings: uploaderWarnings } : {},
@@ -6417,55 +6889,13 @@ async function runGemini(fetchImpl, context) {
6417
6889
  }
6418
6890
  async function runGeminiImage(fetchImpl, context) {
6419
6891
  const apiKey = resolveApiKey(context.route, context.runtime);
6420
- const referenceImages = imageReferenceFiles(context);
6421
- const bundle = buildImagePromptBundle({
6422
- request: context.request,
6423
- model: context.route.model,
6424
- referenceImages
6425
- });
6426
- const baseFields = buildImageRequestFields(context, bundle);
6427
- const useEditEndpoint = shouldUseImageEditEndpoint(context);
6428
- const endpoint = useEditEndpoint ? resolveGeminiImageEditEndpoint(context.route) : resolveGeminiImageEndpoint(context.route);
6429
- transportSummary(
6430
- context,
6431
- useEditEndpoint ? "gemini-image-edit" : "gemini-image",
6432
- endpoint,
6433
- "POST",
6434
- false,
6435
- {
6436
- ...baseFields,
6437
- source_image: Boolean(context.request.image?.sourceImage),
6438
- mask: Boolean(context.request.image?.mask),
6439
- reference_image_count: referenceImages.length
6440
- }
6441
- );
6442
- const response = await fetchImpl(endpoint, useEditEndpoint ? {
6443
- method: "POST",
6444
- headers: {
6445
- authorization: `Bearer ${apiKey}`
6446
- },
6447
- body: await buildImageEditFormData(context, baseFields),
6448
- signal: context.abort.signal
6449
- } : {
6450
- method: "POST",
6451
- headers: {
6452
- "content-type": "application/json",
6453
- authorization: `Bearer ${apiKey}`
6454
- },
6455
- body: JSON.stringify(baseFields),
6456
- signal: context.abort.signal
6892
+ return runProviderImage(fetchImpl, context, {
6893
+ resolveEndpoint: (route) => resolveGeminiImageEndpoint(route),
6894
+ resolveEditEndpoint: (route) => resolveGeminiImageEditEndpoint(route),
6895
+ authHeaders: { authorization: `Bearer ${apiKey}` },
6896
+ providerLabel: "gemini",
6897
+ classifyProvider: context.route.provider
6457
6898
  });
6458
- const payload = await readPayload(response);
6459
- if (!response.ok) {
6460
- throw classifyApiError(context.route.provider, response, payload);
6461
- }
6462
- return {
6463
- data: {
6464
- request: bundle,
6465
- response: payload
6466
- },
6467
- attachments: imageAttachmentsFromPayload(payload)
6468
- };
6469
6899
  }
6470
6900
  function verifyApiRoute(route, runtime) {
6471
6901
  resolveApiKey(route, runtime);
@@ -6544,9 +6974,12 @@ var AI_CONNECT_DEFAULT_SERVER_PRESETS = {
6544
6974
  transportId: "opencode-server"
6545
6975
  }
6546
6976
  };
6547
- var AI_CONNECT_DEFAULT_SERVER_COMMANDS = {
6548
- "opencode:opencode-server": AI_CONNECT_DEFAULT_SERVER_PRESETS.opencode.command
6549
- };
6977
+ var AI_CONNECT_DEFAULT_SERVER_COMMANDS = Object.fromEntries(
6978
+ Object.values(AI_CONNECT_DEFAULT_SERVER_PRESETS).map((preset) => [
6979
+ `${preset.id}:${preset.transportId}`,
6980
+ preset.command
6981
+ ])
6982
+ );
6550
6983
 
6551
6984
  // src/catalog.ts
6552
6985
  var TEXT_PROVIDER_CATALOG = [
@@ -6763,8 +7196,119 @@ import fs from "node:fs/promises";
6763
7196
  import os from "node:os";
6764
7197
  import path from "node:path";
6765
7198
  import { pathToFileURL } from "node:url";
7199
+
7200
+ // src/transport-runtime.ts
7201
+ var MINIMAL_ENV_KEYS = [
7202
+ "PATH",
7203
+ "HOME",
7204
+ "USERPROFILE",
7205
+ "HTTPS_PROXY",
7206
+ "HTTP_PROXY",
7207
+ "NO_PROXY",
7208
+ "https_proxy",
7209
+ "http_proxy",
7210
+ "no_proxy",
7211
+ "NODE_EXTRA_CA_CERTS",
7212
+ "SSL_CERT_FILE",
7213
+ "SSL_CERT_DIR",
7214
+ "SystemRoot",
7215
+ "windir",
7216
+ "APPDATA",
7217
+ "LOCALAPPDATA",
7218
+ "TEMP",
7219
+ "TMP",
7220
+ "PATHEXT",
7221
+ "ComSpec",
7222
+ "NUMBER_OF_PROCESSORS"
7223
+ ];
7224
+ function resolveChildEnv(opts = {}) {
7225
+ const extra = opts.extra ?? {};
7226
+ if (opts.envMode === "inherit") {
7227
+ const inherited = {};
7228
+ for (const [key, value] of Object.entries(process.env)) {
7229
+ if (typeof value === "string") {
7230
+ inherited[key] = value;
7231
+ }
7232
+ }
7233
+ return { ...inherited, ...extra };
7234
+ }
7235
+ const minimal = {};
7236
+ const keys = /* @__PURE__ */ new Set([...MINIMAL_ENV_KEYS, ...opts.allowlist ?? []]);
7237
+ for (const key of keys) {
7238
+ const value = process.env[key];
7239
+ if (typeof value === "string") {
7240
+ minimal[key] = value;
7241
+ }
7242
+ }
7243
+ return { ...minimal, ...extra };
7244
+ }
7245
+ function killProcessTree(child, signal, detached) {
7246
+ if (process.platform !== "win32" && detached && typeof child.pid === "number") {
7247
+ try {
7248
+ process.kill(-child.pid, signal);
7249
+ return;
7250
+ } catch {
7251
+ }
7252
+ }
7253
+ try {
7254
+ child.kill(signal);
7255
+ } catch {
7256
+ }
7257
+ }
7258
+ function resolveManagedCommand(key, optionsCommands, routeCommand, presetCommand) {
7259
+ const resolved = optionsCommands?.[key]?.trim() || routeCommand?.trim() || presetCommand?.trim();
7260
+ if (!resolved) {
7261
+ throw new AiConnectError(
7262
+ "validation_error",
7263
+ `No managed command is configured for "${key}".`
7264
+ );
7265
+ }
7266
+ return resolved;
7267
+ }
7268
+ var trackedChildren = /* @__PURE__ */ new Set();
7269
+ var exitHookRegistered = false;
7270
+ function killAllTrackedChildren() {
7271
+ for (const tracked of trackedChildren) {
7272
+ killProcessTree(tracked.child, "SIGKILL", tracked.detached);
7273
+ }
7274
+ trackedChildren.clear();
7275
+ }
7276
+ function ensureExitHook() {
7277
+ if (exitHookRegistered) {
7278
+ return;
7279
+ }
7280
+ exitHookRegistered = true;
7281
+ process.on("exit", () => {
7282
+ killAllTrackedChildren();
7283
+ });
7284
+ for (const signal of ["SIGINT", "SIGTERM"]) {
7285
+ const handler = () => {
7286
+ killAllTrackedChildren();
7287
+ process.removeListener(signal, handler);
7288
+ if (process.listenerCount(signal) === 0) {
7289
+ process.kill(process.pid, signal);
7290
+ }
7291
+ };
7292
+ process.on(signal, handler);
7293
+ }
7294
+ }
7295
+ function trackChild(child, detached) {
7296
+ ensureExitHook();
7297
+ const entry = { child, detached };
7298
+ trackedChildren.add(entry);
7299
+ const untrack = () => {
7300
+ trackedChildren.delete(entry);
7301
+ };
7302
+ child.once("close", untrack);
7303
+ child.once("exit", untrack);
7304
+ return untrack;
7305
+ }
7306
+
7307
+ // src/acp.ts
6766
7308
  var DEFAULT_TIMEOUT_MS = 6e4;
6767
7309
  var DEFAULT_SESSION_CACHE_IDLE_TTL_MS = 6e4;
7310
+ var MAX_ACP_STDERR_CHARS = 64 * 1024;
7311
+ var MAX_ACP_STREAM_QUEUE = 1024;
6768
7312
  var IMAGE_EXTENSIONS = /* @__PURE__ */ new Map([
6769
7313
  [".png", "image/png"],
6770
7314
  [".jpg", "image/jpeg"],
@@ -7053,24 +7597,29 @@ function prepareClaudeLaunchRuntime(base, launch) {
7053
7597
  launch
7054
7598
  };
7055
7599
  }
7600
+ var ACP_LAUNCH_PREPARERS = {
7601
+ "opencode-acp": prepareOpenCodeLaunchRuntime,
7602
+ "codex-acp": prepareCodexLaunchRuntime,
7603
+ "claude-code-acp": prepareClaudeLaunchRuntime
7604
+ };
7056
7605
  async function prepareAcpLaunchRuntime(route, options, commandLine, cwdOverride) {
7057
7606
  const launch = normalizeLaunchOptions(route, options);
7058
7607
  const base = {
7059
7608
  commandLine,
7060
7609
  cwd: path.resolve(cwdOverride ?? options?.cwd ?? process.cwd()),
7061
- env: {
7062
- ...process.env,
7063
- ...options?.env ?? {}
7064
- }
7610
+ // SYS-2: minimal env by default — do NOT hand the host's full process.env
7611
+ // (other-provider keys, CI tokens) to third-party ACP agents. Extra env is
7612
+ // layered via options.env.
7613
+ env: resolveChildEnv({ extra: options?.env ?? {} })
7065
7614
  };
7066
- if (route.transport.id === "opencode-acp") {
7067
- return prepareOpenCodeLaunchRuntime(base, launch);
7068
- }
7069
- if (route.transport.id === "codex-acp") {
7070
- return prepareCodexLaunchRuntime(base, launch);
7615
+ const preparer = ACP_LAUNCH_PREPARERS[route.transport.id];
7616
+ if (preparer) {
7617
+ return await preparer(base, launch);
7071
7618
  }
7072
- if (route.transport.id === "claude-code-acp") {
7073
- return prepareClaudeLaunchRuntime(base, launch);
7619
+ if (launch.contextMode !== "workspace" || launch.skillsMode !== "default") {
7620
+ console.warn(
7621
+ `[ai-connect] ACP transport "${route.transport.id}" has no launch-runtime preparer; requested contextMode/skillsMode isolation is not applied.`
7622
+ );
7074
7623
  }
7075
7624
  return {
7076
7625
  ...base,
@@ -7102,8 +7651,27 @@ function mimeTypeFromPath(filePath) {
7102
7651
  function isTextPath(filePath) {
7103
7652
  return TEXT_EXTENSIONS2.has(path.extname(filePath).toLowerCase());
7104
7653
  }
7105
- function isWithinRoot(rootDir, targetPath) {
7106
- const relative = path.relative(rootDir, targetPath);
7654
+ async function resolveRealPathTolerant(target) {
7655
+ try {
7656
+ return await fs.realpath(target);
7657
+ } catch {
7658
+ const parent = path.dirname(target);
7659
+ if (parent === target) {
7660
+ return target;
7661
+ }
7662
+ const realParent = await resolveRealPathTolerant(parent);
7663
+ return path.join(realParent, path.basename(target));
7664
+ }
7665
+ }
7666
+ async function isWithinRoot(rootDir, targetPath) {
7667
+ let realRoot;
7668
+ try {
7669
+ realRoot = await fs.realpath(rootDir);
7670
+ } catch {
7671
+ realRoot = path.resolve(rootDir);
7672
+ }
7673
+ const realTarget = await resolveRealPathTolerant(path.resolve(targetPath));
7674
+ const relative = path.relative(realRoot, realTarget);
7107
7675
  return relative.length === 0 || !relative.startsWith("..") && !path.isAbsolute(relative);
7108
7676
  }
7109
7677
  function buildMessageTranscript(context) {
@@ -7530,46 +8098,58 @@ function extractTextChunk(update) {
7530
8098
  }
7531
8099
  return typeof content.text === "string" && content.text.length > 0 ? content.text : void 0;
7532
8100
  }
8101
+ function isAuthErrorMessage(message) {
8102
+ return /unauthor|authentication|invalid[_\s-]?(api[_\s-]?key|credential|token)|forbidden|\b40[13]\b/i.test(
8103
+ message
8104
+ );
8105
+ }
7533
8106
  function normalizeAcpError(error) {
7534
8107
  if (error instanceof AiConnectError) {
7535
8108
  return error;
7536
8109
  }
8110
+ if (error instanceof Error) {
8111
+ const message = error.message || "ACP transport failed.";
8112
+ const errno = error.code;
8113
+ if (errno === "ENOENT" || errno === "EACCES" || errno === "ENOEXEC" || message.toLowerCase().includes("timed out")) {
8114
+ return new AiConnectError("local_harness_unavailable", message);
8115
+ }
8116
+ if (isAuthErrorMessage(message)) {
8117
+ return new AiConnectError("auth_error", message);
8118
+ }
8119
+ return new AiConnectError("temporary_unavailable", message);
8120
+ }
7537
8121
  if (isRecord2(error)) {
7538
8122
  const code = typeof error.code === "number" ? error.code : void 0;
7539
8123
  const message = typeof error.message === "string" ? error.message : "ACP transport failed.";
7540
8124
  if (code === -32601 || code === -32602) {
7541
8125
  return new AiConnectError("not_supported", message);
7542
8126
  }
7543
- if (message.toLowerCase().includes("auth")) {
8127
+ if (isAuthErrorMessage(message)) {
7544
8128
  return new AiConnectError("auth_error", message);
7545
8129
  }
7546
8130
  return new AiConnectError("temporary_unavailable", message);
7547
8131
  }
7548
- if (error instanceof Error) {
7549
- const message = error.message;
7550
- if ("code" in error && error.code === "ENOENT") {
7551
- return new AiConnectError("local_harness_unavailable", message);
7552
- }
7553
- if (message.toLowerCase().includes("timed out")) {
7554
- return new AiConnectError("local_harness_unavailable", message);
7555
- }
7556
- return new AiConnectError("temporary_unavailable", message);
7557
- }
7558
8132
  return new AiConnectError(
7559
8133
  "temporary_unavailable",
7560
8134
  "ACP transport failed."
7561
8135
  );
7562
8136
  }
7563
8137
  function resolveAcpCommand(route, options) {
7564
- const override = options?.commands?.[`${route.provider}:${route.transport.id}`] ?? options?.commands?.[route.transport.id] ?? options?.commands?.[route.provider];
7565
- const resolved = override ?? route.transport.command ?? AI_CONNECT_DEFAULT_ACP_COMMANDS[`${route.provider}:${route.transport.id}`];
7566
- if (!resolved) {
8138
+ const key = `${route.provider}:${route.transport.id}`;
8139
+ const optionsOverride = options?.commands?.[key] ?? options?.commands?.[route.transport.id] ?? options?.commands?.[route.provider];
8140
+ try {
8141
+ return resolveManagedCommand(
8142
+ key,
8143
+ optionsOverride ? { [key]: optionsOverride } : void 0,
8144
+ route.transport.command,
8145
+ AI_CONNECT_DEFAULT_ACP_COMMANDS[key]
8146
+ );
8147
+ } catch {
7567
8148
  throw new AiConnectError(
7568
8149
  "validation_error",
7569
8150
  `No ACP command is configured for route "${route.id}".`
7570
8151
  );
7571
8152
  }
7572
- return resolved;
7573
8153
  }
7574
8154
  function resolveAcpExecutable(route, options) {
7575
8155
  return splitCommandLine(resolveAcpCommand(route, options)).command;
@@ -7720,14 +8300,7 @@ async function settleClosePromise(closePromise, timeoutMs = 1500) {
7720
8300
  ]);
7721
8301
  }
7722
8302
  function killAcpProcess(child, signal) {
7723
- if (process.platform !== "win32" && typeof child.pid === "number") {
7724
- try {
7725
- process.kill(-child.pid, signal);
7726
- return;
7727
- } catch {
7728
- }
7729
- }
7730
- child.kill(signal);
8303
+ killProcessTree(child, signal, process.platform !== "win32");
7731
8304
  }
7732
8305
  var AcpConnection = class {
7733
8306
  commandLine;
@@ -7859,6 +8432,9 @@ var AcpConnection = class {
7859
8432
  if (stderrSlice.length > 0) {
7860
8433
  warnings.push(stderrSlice);
7861
8434
  }
8435
+ if (this.stderr.length > MAX_ACP_STDERR_CHARS) {
8436
+ this.stderr = this.stderr.slice(-MAX_ACP_STDERR_CHARS);
8437
+ }
7862
8438
  const text = this.activePrompt.text.trim();
7863
8439
  if (this.failOnHarnessOnlyTurn && this.activePrompt.harnessMarkers.length > 0 && text.length === 0) {
7864
8440
  this.activePrompt = void 0;
@@ -8028,6 +8604,12 @@ var AcpConnection = class {
8028
8604
  this.stderr = "";
8029
8605
  this.pending.clear();
8030
8606
  this.messageQueue = Promise.resolve();
8607
+ child.stdin?.on("error", (error) => {
8608
+ const message = error instanceof Error ? error.message : String(error);
8609
+ this.stderr += `[ai-connect] ACP stdin error: ${message}
8610
+ `;
8611
+ });
8612
+ trackChild(child, process.platform !== "win32");
8031
8613
  child.stdout.setEncoding("utf8");
8032
8614
  child.stdout.on("data", (chunk) => {
8033
8615
  this.buffer += chunk;
@@ -8091,8 +8673,14 @@ var AcpConnection = class {
8091
8673
  if (!stdin) {
8092
8674
  return;
8093
8675
  }
8094
- stdin.write(`${JSON.stringify(message)}
8676
+ try {
8677
+ stdin.write(`${JSON.stringify(message)}
8095
8678
  `);
8679
+ } catch (error) {
8680
+ const messageText = error instanceof Error ? error.message : String(error);
8681
+ this.stderr += `[ai-connect] ACP stdin write error (reply): ${messageText}
8682
+ `;
8683
+ }
8096
8684
  }
8097
8685
  failPending(error) {
8098
8686
  for (const entry of this.pending.values()) {
@@ -8113,7 +8701,7 @@ var AcpConnection = class {
8113
8701
  }
8114
8702
  if (message.method === "readTextFile") {
8115
8703
  const filePath = typeof params.path === "string" ? path.resolve(params.path) : void 0;
8116
- if (!filePath || !isWithinRoot(this.cwd, filePath)) {
8704
+ if (!filePath || !await isWithinRoot(this.cwd, filePath)) {
8117
8705
  this.reply({
8118
8706
  jsonrpc: "2.0",
8119
8707
  id: message.id,
@@ -8146,7 +8734,7 @@ var AcpConnection = class {
8146
8734
  }
8147
8735
  const filePath = typeof params.path === "string" ? path.resolve(params.path) : void 0;
8148
8736
  const content = typeof params.content === "string" ? params.content : void 0;
8149
- if (!filePath || content === void 0 || !isWithinRoot(this.cwd, filePath)) {
8737
+ if (!filePath || content === void 0 || !await isWithinRoot(this.cwd, filePath)) {
8150
8738
  this.reply({
8151
8739
  jsonrpc: "2.0",
8152
8740
  id: message.id,
@@ -8420,8 +9008,20 @@ var AcpConnection = class {
8420
9008
  );
8421
9009
  return;
8422
9010
  }
8423
- stdin.write(`${JSON.stringify(payload)}
9011
+ try {
9012
+ stdin.write(`${JSON.stringify(payload)}
8424
9013
  `);
9014
+ } catch (error) {
9015
+ clearTimeout(timer);
9016
+ this.pending.delete(id);
9017
+ const messageText = error instanceof Error ? error.message : String(error);
9018
+ reject(
9019
+ new AiConnectError(
9020
+ "local_harness_unavailable",
9021
+ `ACP command "${this.commandLine}" stdin write failed: ${messageText}`
9022
+ )
9023
+ );
9024
+ }
8425
9025
  });
8426
9026
  }
8427
9027
  };
@@ -8692,6 +9292,10 @@ function createAcpTransportManager(options) {
8692
9292
  let notify;
8693
9293
  const onDelta = (text) => {
8694
9294
  deltas.push(text);
9295
+ if (deltas.length > MAX_ACP_STREAM_QUEUE) {
9296
+ const overflow = deltas.splice(0, deltas.length - MAX_ACP_STREAM_QUEUE);
9297
+ deltas.unshift(overflow.join(""));
9298
+ }
8695
9299
  notify?.();
8696
9300
  };
8697
9301
  let settled = false;
@@ -8829,44 +9433,29 @@ ${message.content}`;
8829
9433
  }).join("\n\n");
8830
9434
  }
8831
9435
  function resolveCliCommand(route, options) {
8832
- if (route.transport.command?.trim()) {
8833
- return route.transport.command.trim();
8834
- }
8835
- const candidates = [
8836
- `${route.provider}:${route.transport.id}`,
8837
- route.transport.id,
8838
- route.provider
8839
- ];
8840
- for (const candidate of candidates) {
8841
- const override = options?.commands?.[candidate]?.trim();
8842
- if (override) {
8843
- return override;
8844
- }
8845
- }
9436
+ const key = `${route.provider}:${route.transport.id}`;
9437
+ const optionsOverride = options?.commands?.[key]?.trim() || options?.commands?.[route.transport.id]?.trim() || options?.commands?.[route.provider]?.trim() || void 0;
8846
9438
  const presetId = route.transport.cli?.preset ?? defaultCliPresetIdForRoute(route);
8847
- if (presetId) {
8848
- const presetCommand = getCliTransportPreset(presetId).command.trim();
8849
- if (presetCommand) {
8850
- return presetCommand;
8851
- }
8852
- }
8853
- const preset = AI_CONNECT_DEFAULT_CLI_COMMANDS[`${route.provider}:${route.transport.id}`];
8854
- if (preset) {
8855
- return preset;
9439
+ const presetCommand = (presetId ? getCliTransportPreset(presetId).command.trim() : "") || AI_CONNECT_DEFAULT_CLI_COMMANDS[key] || void 0;
9440
+ try {
9441
+ return resolveManagedCommand(
9442
+ key,
9443
+ optionsOverride ? { [key]: optionsOverride } : void 0,
9444
+ route.transport.command,
9445
+ presetCommand
9446
+ );
9447
+ } catch {
9448
+ throw new AiConnectError(
9449
+ "validation_error",
9450
+ `No CLI command preset is registered for route "${route.id}". Provide transport.command or cli.commands.`
9451
+ );
8856
9452
  }
8857
- throw new AiConnectError(
8858
- "validation_error",
8859
- `No CLI command preset is registered for route "${route.id}". Provide transport.command or cli.commands.`
8860
- );
8861
9453
  }
8862
9454
  function resolveCliExecutable(route, options) {
8863
9455
  return splitCommandLine2(resolveCliCommand(route, options)).command;
8864
9456
  }
8865
9457
  function buildCliEnvironment(options) {
8866
- return {
8867
- ...process.env,
8868
- ...options?.env ?? {}
8869
- };
9458
+ return resolveChildEnv({ extra: options?.env ?? {} });
8870
9459
  }
8871
9460
  function buildCliCwd(context, options) {
8872
9461
  return path2.resolve(
@@ -8967,9 +9556,13 @@ function resolveCliTransportOptions(route) {
8967
9556
  } : void 0;
8968
9557
  const merged = {
8969
9558
  ...preset?.options ?? {},
9559
+ // `...route.transport.cli` already spreads argsTemplate + parser (when set on
9560
+ // the route) over the preset defaults, so the previously-present conditional
9561
+ // re-spreads of those two fields were dead/redundant (they re-assigned the
9562
+ // identical value) and have been dropped. fileInput/preset below are NOT
9563
+ // redundant: fileInput is a per-field DEEP merge (mergedFileInput) and preset
9564
+ // is derived (route.transport.cli.preset ?? default), not a plain passthrough.
8970
9565
  ...route.transport.cli ?? {},
8971
- ...route.transport.cli?.argsTemplate ? { argsTemplate: route.transport.cli.argsTemplate } : {},
8972
- ...route.transport.cli?.parser ? { parser: route.transport.cli.parser } : {},
8973
9566
  ...mergedFileInput ? { fileInput: mergedFileInput } : {},
8974
9567
  ...presetId ? { preset: presetId } : {}
8975
9568
  };
@@ -9066,7 +9659,7 @@ function materializeTemplateArg(templateArg, values, parameterKeys) {
9066
9659
  }
9067
9660
  return values.outputFile;
9068
9661
  default:
9069
- if (templateArg.startsWith("--")) {
9662
+ if (templateArg.startsWith("--") && templateArg.length > 2) {
9070
9663
  parameterKeys.push(templateArg);
9071
9664
  }
9072
9665
  return templateArg;
@@ -9219,8 +9812,19 @@ function getValueByPath(value, dotPath) {
9219
9812
  }
9220
9813
  return current;
9221
9814
  }
9222
- function splitJsonlLines(stdout) {
9223
- return stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => JSON.parse(line));
9815
+ function parseCliJson(raw, context) {
9816
+ try {
9817
+ return JSON.parse(raw);
9818
+ } catch (error) {
9819
+ const detail = error instanceof Error ? error.message : String(error);
9820
+ throw new AiConnectError(
9821
+ "temporary_unavailable",
9822
+ `${context} produced output that is not valid JSON (${detail}).`
9823
+ );
9824
+ }
9825
+ }
9826
+ function splitJsonlLines(stdout, context = "CLI jsonl parser") {
9827
+ return stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => parseCliJson(line, context));
9224
9828
  }
9225
9829
  function normalizeErrorMessage(value) {
9226
9830
  if (typeof value === "string" && value.trim()) {
@@ -9246,8 +9850,8 @@ function findJsonlSelection(entries, selector) {
9246
9850
  }
9247
9851
  return void 0;
9248
9852
  }
9249
- function parseGenericJsonCli(stdout, parser) {
9250
- const payload = JSON.parse(stdout);
9853
+ function parseGenericJsonCli(stdout, parser, context) {
9854
+ const payload = parseCliJson(stdout, context);
9251
9855
  const errorValue = parser.errorPath ? getValueByPath(payload, parser.errorPath) : void 0;
9252
9856
  const errorMessage = normalizeErrorMessage(errorValue);
9253
9857
  if (errorMessage) {
@@ -9268,8 +9872,8 @@ function parseGenericJsonCli(stdout, parser) {
9268
9872
  data: payload
9269
9873
  };
9270
9874
  }
9271
- function parseGenericJsonlCli(stdout, parser) {
9272
- const entries = splitJsonlLines(stdout);
9875
+ function parseGenericJsonlCli(stdout, parser, context) {
9876
+ const entries = splitJsonlLines(stdout, context);
9273
9877
  const errorValue = parser.error ? findJsonlSelection(entries, parser.error) : void 0;
9274
9878
  const errorMessage = normalizeErrorMessage(errorValue);
9275
9879
  if (errorMessage) {
@@ -9343,7 +9947,7 @@ function parseCliModelList(stdout, parser, selector) {
9343
9947
  );
9344
9948
  }
9345
9949
  const records = parser.kind === "jsonl" ? splitJsonlLines(stdout) : (() => {
9346
- const payload = JSON.parse(stdout);
9950
+ const payload = parseCliJson(stdout, "CLI discovery json parser");
9347
9951
  const arr = selector.path ? getValueByPath(payload, selector.path) : payload;
9348
9952
  return Array.isArray(arr) ? arr : [];
9349
9953
  })();
@@ -9356,8 +9960,8 @@ function parseCliModelList(stdout, parser, selector) {
9356
9960
  }
9357
9961
  return models;
9358
9962
  }
9359
- function parseClaudeCli(stdout) {
9360
- const payload = JSON.parse(stdout);
9963
+ function parseClaudeCli(stdout, context) {
9964
+ const payload = parseCliJson(stdout, context);
9361
9965
  const text = typeof payload.result === "string" ? payload.result : typeof payload.response === "string" ? payload.response : typeof payload.text === "string" ? payload.text : void 0;
9362
9966
  if (!text) {
9363
9967
  throw new AiConnectError(
@@ -9372,8 +9976,8 @@ function parseClaudeCli(stdout) {
9372
9976
  data: payload
9373
9977
  };
9374
9978
  }
9375
- function parseCodexCli(stdout, outputFileContent) {
9376
- const events = splitJsonlLines(stdout);
9979
+ function parseCodexCli(stdout, outputFileContent, context) {
9980
+ const events = splitJsonlLines(stdout, context);
9377
9981
  let text = outputFileContent?.trim() || void 0;
9378
9982
  let usage;
9379
9983
  for (const event of events) {
@@ -9423,6 +10027,7 @@ function parseCliResult(route, result, outputFileContent) {
9423
10027
  );
9424
10028
  }
9425
10029
  const cliOptions = resolveCliTransportOptions(route);
10030
+ const parseContext = `CLI transport "${route.transport.id}"${cliOptions.preset ? ` (preset ${cliOptions.preset})` : ""}`;
9426
10031
  if (route.transport.cli?.parser) {
9427
10032
  const parser = cliOptions.parser;
9428
10033
  if (!parser) {
@@ -9435,9 +10040,9 @@ function parseCliResult(route, result, outputFileContent) {
9435
10040
  case "text":
9436
10041
  return parseTextCli(result.stdout, parser);
9437
10042
  case "json":
9438
- return parseGenericJsonCli(stdout, parser);
10043
+ return parseGenericJsonCli(stdout, parser, parseContext);
9439
10044
  default:
9440
- return parseGenericJsonlCli(stdout, parser);
10045
+ return parseGenericJsonlCli(stdout, parser, parseContext);
9441
10046
  }
9442
10047
  }
9443
10048
  switch (cliOptions.preset) {
@@ -9445,9 +10050,9 @@ function parseCliResult(route, result, outputFileContent) {
9445
10050
  return parseTextCli(stdout, { kind: "text" });
9446
10051
  case "claude":
9447
10052
  case "openclaude":
9448
- return parseClaudeCli(stdout);
10053
+ return parseClaudeCli(stdout, parseContext);
9449
10054
  case "codex":
9450
- return parseCodexCli(stdout, outputFileContent);
10055
+ return parseCodexCli(stdout, outputFileContent, parseContext);
9451
10056
  default:
9452
10057
  throw new AiConnectError(
9453
10058
  "not_supported",
@@ -9461,29 +10066,27 @@ function capturePhase(phases, name, startedAt) {
9461
10066
  durationMs: Date.now() - startedAt
9462
10067
  });
9463
10068
  }
10069
+ var MAX_CLI_OUTPUT_CHARS = 64 * 1024 * 1024;
9464
10070
  async function executeCliInvocation(invocation, timeoutMs, phases, signal) {
9465
10071
  const spawnStartedAt = Date.now();
10072
+ const detached = process.platform !== "win32";
9466
10073
  const child = spawn2(invocation.command, invocation.args, {
9467
10074
  cwd: invocation.cwd,
9468
10075
  env: invocation.env,
9469
- stdio: ["ignore", "pipe", "pipe"]
10076
+ stdio: ["ignore", "pipe", "pipe"],
10077
+ detached
9470
10078
  });
9471
10079
  capturePhase(phases, "spawn", spawnStartedAt);
9472
10080
  let stdout = "";
9473
10081
  let stderr = "";
9474
10082
  child.stdout.setEncoding("utf8");
9475
- child.stdout.on("data", (chunk) => {
9476
- stdout += chunk;
9477
- });
9478
10083
  child.stderr.setEncoding("utf8");
9479
- child.stderr.on("data", (chunk) => {
9480
- stderr += chunk;
9481
- });
9482
10084
  const executeStartedAt = Date.now();
9483
10085
  return await new Promise((resolve, reject) => {
9484
10086
  let abortListener;
10087
+ let overflowed = false;
9485
10088
  const timer = setTimeout(() => {
9486
- child.kill("SIGKILL");
10089
+ killProcessTree(child, "SIGKILL", detached);
9487
10090
  reject(
9488
10091
  new AiConnectError(
9489
10092
  "temporary_unavailable",
@@ -9498,15 +10101,43 @@ async function executeCliInvocation(invocation, timeoutMs, phases, signal) {
9498
10101
  abortListener = void 0;
9499
10102
  }
9500
10103
  };
10104
+ const enforceBufferCap = () => {
10105
+ if (overflowed || stdout.length + stderr.length <= MAX_CLI_OUTPUT_CHARS) {
10106
+ return;
10107
+ }
10108
+ overflowed = true;
10109
+ killProcessTree(child, "SIGKILL", detached);
10110
+ cleanup();
10111
+ reject(
10112
+ new AiConnectError(
10113
+ "temporary_unavailable",
10114
+ `CLI transport "${quoteArg(invocation.command)}" exceeded the ${MAX_CLI_OUTPUT_CHARS}-character output cap.`
10115
+ )
10116
+ );
10117
+ };
10118
+ child.stdout.on("data", (chunk) => {
10119
+ if (overflowed) {
10120
+ return;
10121
+ }
10122
+ stdout += chunk;
10123
+ enforceBufferCap();
10124
+ });
10125
+ child.stderr.on("data", (chunk) => {
10126
+ if (overflowed) {
10127
+ return;
10128
+ }
10129
+ stderr += chunk;
10130
+ enforceBufferCap();
10131
+ });
9501
10132
  if (signal) {
9502
10133
  if (signal.aborted) {
9503
- child.kill("SIGKILL");
10134
+ killProcessTree(child, "SIGKILL", detached);
9504
10135
  cleanup();
9505
10136
  reject(new AiConnectError("aborted", "CLI transport aborted before execution."));
9506
10137
  return;
9507
10138
  }
9508
10139
  abortListener = () => {
9509
- child.kill("SIGKILL");
10140
+ killProcessTree(child, "SIGKILL", detached);
9510
10141
  cleanup();
9511
10142
  reject(new AiConnectError("aborted", "CLI transport aborted."));
9512
10143
  };
@@ -9522,6 +10153,9 @@ async function executeCliInvocation(invocation, timeoutMs, phases, signal) {
9522
10153
  );
9523
10154
  });
9524
10155
  child.once("close", (exitCode) => {
10156
+ if (overflowed) {
10157
+ return;
10158
+ }
9525
10159
  cleanup();
9526
10160
  capturePhase(phases, "execute", executeStartedAt);
9527
10161
  resolve({
@@ -9625,7 +10259,10 @@ function createCliTransportManager(options) {
9625
10259
  )
9626
10260
  );
9627
10261
  } finally {
9628
- await cleanupCliInvocation(invocation);
10262
+ try {
10263
+ await cleanupCliInvocation(invocation);
10264
+ } catch {
10265
+ }
9629
10266
  }
9630
10267
  },
9631
10268
  async runPrompt(context) {
@@ -9665,7 +10302,10 @@ function createCliTransportManager(options) {
9665
10302
  }
9666
10303
  return result;
9667
10304
  } finally {
9668
- await cleanupCliInvocation(invocation);
10305
+ try {
10306
+ await cleanupCliInvocation(invocation);
10307
+ } catch {
10308
+ }
9669
10309
  }
9670
10310
  }
9671
10311
  };
@@ -9692,28 +10332,21 @@ function splitCommandLine3(commandLine) {
9692
10332
  };
9693
10333
  }
9694
10334
  function resolveServerCommand(route, options) {
9695
- if (route.transport.command?.trim()) {
9696
- return route.transport.command.trim();
9697
- }
9698
- const candidates = [
9699
- `${route.provider}:${route.transport.id}`,
9700
- route.transport.id,
9701
- route.provider
9702
- ];
9703
- for (const candidate of candidates) {
9704
- const override = options?.commands?.[candidate]?.trim();
9705
- if (override) {
9706
- return override;
9707
- }
9708
- }
9709
- const preset = AI_CONNECT_DEFAULT_SERVER_COMMANDS[`${route.provider}:${route.transport.id}`];
9710
- if (preset) {
9711
- return preset;
10335
+ const key = `${route.provider}:${route.transport.id}`;
10336
+ const optionsOverride = options?.commands?.[key]?.trim() || options?.commands?.[route.transport.id]?.trim() || options?.commands?.[route.provider]?.trim() || void 0;
10337
+ try {
10338
+ return resolveManagedCommand(
10339
+ key,
10340
+ optionsOverride ? { [key]: optionsOverride } : void 0,
10341
+ route.transport.command,
10342
+ AI_CONNECT_DEFAULT_SERVER_COMMANDS[key]
10343
+ );
10344
+ } catch {
10345
+ throw new AiConnectError(
10346
+ "validation_error",
10347
+ `No server command preset is registered for route "${route.id}". Provide transport.command or server.commands.`
10348
+ );
9712
10349
  }
9713
- throw new AiConnectError(
9714
- "validation_error",
9715
- `No server command preset is registered for route "${route.id}". Provide transport.command or server.commands.`
9716
- );
9717
10350
  }
9718
10351
  function resolveServerExecutable(route, options) {
9719
10352
  return splitCommandLine3(resolveServerCommand(route, options)).command;
@@ -9785,9 +10418,12 @@ async function findAvailablePort(host = "127.0.0.1") {
9785
10418
  async function waitForServerHealth(fetchLike, origin, timeoutMs) {
9786
10419
  const deadline = Date.now() + timeoutMs;
9787
10420
  let lastError;
10421
+ const perAttemptTimeoutMs = Math.min(2e3, Math.max(250, timeoutMs));
9788
10422
  while (Date.now() < deadline) {
9789
10423
  try {
9790
- const response = await fetchLike(joinUrl(origin, "/global/health"));
10424
+ const response = await fetchLike(joinUrl(origin, "/global/health"), {
10425
+ signal: AbortSignal.timeout(perAttemptTimeoutMs)
10426
+ });
9791
10427
  if (response.ok) {
9792
10428
  return;
9793
10429
  }
@@ -9817,14 +10453,14 @@ async function spawnServerInstance(route, options, phases, cwdOverride) {
9817
10453
  }
9818
10454
  args.push("--hostname", host, "--port", String(port));
9819
10455
  const spawnStartedAt = Date.now();
10456
+ const detached = process.platform !== "win32";
9820
10457
  const child = spawn3(resolved.command, args, {
9821
10458
  cwd: cwdOverride ?? options?.cwd ?? process.cwd(),
9822
- env: {
9823
- ...process.env,
9824
- ...options?.env ?? {}
9825
- },
9826
- stdio: ["ignore", "pipe", "pipe"]
10459
+ env: resolveChildEnv({ extra: options?.env ?? {} }),
10460
+ stdio: ["ignore", "pipe", "pipe"],
10461
+ detached
9827
10462
  });
10463
+ const untrack = trackChild(child, detached);
9828
10464
  let stderr = "";
9829
10465
  child.stderr.setEncoding("utf8");
9830
10466
  child.stderr.on("data", (chunk) => {
@@ -9840,7 +10476,8 @@ async function spawnServerInstance(route, options, phases, cwdOverride) {
9840
10476
  );
9841
10477
  capturePhase2(phases, "health", healthStartedAt);
9842
10478
  } catch (error) {
9843
- child.kill("SIGKILL");
10479
+ untrack();
10480
+ killProcessTree(child, "SIGKILL", detached);
9844
10481
  throw new AiConnectError(
9845
10482
  "local_harness_unavailable",
9846
10483
  stderr.trim() || (error instanceof Error ? error.message : "Failed to start local server transport.")
@@ -9848,15 +10485,17 @@ async function spawnServerInstance(route, options, phases, cwdOverride) {
9848
10485
  }
9849
10486
  return {
9850
10487
  origin,
10488
+ child,
9851
10489
  dispose: async () => {
10490
+ untrack();
9852
10491
  if (child.killed) {
9853
10492
  return;
9854
10493
  }
9855
- child.kill("SIGTERM");
10494
+ killProcessTree(child, "SIGTERM", detached);
9856
10495
  await new Promise((resolve) => {
9857
10496
  child.once("close", () => resolve());
9858
10497
  setTimeout(() => {
9859
- child.kill("SIGKILL");
10498
+ killProcessTree(child, "SIGKILL", detached);
9860
10499
  resolve();
9861
10500
  }, 1e3).unref?.();
9862
10501
  });
@@ -9956,7 +10595,17 @@ function createServerTransportManager(options) {
9956
10595
  const pending = spawnServerInstance(route, options, phases, cwdOverride);
9957
10596
  instances.set(key, pending);
9958
10597
  try {
9959
- return await pending;
10598
+ const instance = await pending;
10599
+ if (instance.child) {
10600
+ const evict = () => {
10601
+ if (instances.get(key) === pending) {
10602
+ instances.delete(key);
10603
+ }
10604
+ };
10605
+ instance.child.once("exit", evict);
10606
+ instance.child.once("error", evict);
10607
+ }
10608
+ return instance;
9960
10609
  } catch (error) {
9961
10610
  instances.delete(key);
9962
10611
  throw error;
@@ -9986,7 +10635,10 @@ function createServerTransportManager(options) {
9986
10635
  headers: {
9987
10636
  "content-type": "application/json"
9988
10637
  },
9989
- body: JSON.stringify({})
10638
+ body: JSON.stringify({}),
10639
+ // H6: honor caller cancel / operation timeout on session creation too
10640
+ // (was only wired on the message fetch).
10641
+ signal: context.abort.signal
9990
10642
  });
9991
10643
  capturePhase2(phases, "session/create", sessionStartedAt);
9992
10644
  if (!sessionResponse.ok) {
@@ -10055,7 +10707,10 @@ function createServerTransportManager(options) {
10055
10707
  };
10056
10708
  context.telemetry?.captureTransport(transport);
10057
10709
  const startedAt = Date.now();
10058
- const response = await fetchLike(joinUrl(instance.origin, "/config/providers"));
10710
+ const response = await fetchLike(
10711
+ joinUrl(instance.origin, "/config/providers"),
10712
+ { signal: context.abort.signal }
10713
+ );
10059
10714
  capturePhase2(phases, "config/providers", startedAt);
10060
10715
  if (!response.ok) {
10061
10716
  throw new AiConnectError(
@@ -10099,6 +10754,15 @@ function createServerTransportManager(options) {
10099
10754
  }
10100
10755
 
10101
10756
  // src/local-handlers.ts
10757
+ function normalizeVerifyIssue(error, fallbackCode, fallbackMessage) {
10758
+ const normalized = error instanceof AiConnectError ? error : new AiConnectError(fallbackCode, fallbackMessage);
10759
+ return [
10760
+ {
10761
+ code: "handler_issue",
10762
+ message: normalized.message
10763
+ }
10764
+ ];
10765
+ }
10102
10766
  function createLocalRouteHandlers(options = {}) {
10103
10767
  const acpTransport = createAcpTransportManager(options.acp);
10104
10768
  const cliTransport = createCliTransportManager(options.cli);
@@ -10129,16 +10793,11 @@ function createLocalRouteHandlers(options = {}) {
10129
10793
  }
10130
10794
  ];
10131
10795
  } catch (error) {
10132
- const normalized = error instanceof AiConnectError ? error : new AiConnectError(
10796
+ return normalizeVerifyIssue(
10797
+ error,
10133
10798
  "validation_error",
10134
10799
  "ACP route verification failed."
10135
10800
  );
10136
- return [
10137
- {
10138
- code: "handler_issue",
10139
- message: normalized.message
10140
- }
10141
- ];
10142
10801
  }
10143
10802
  }
10144
10803
  },
@@ -10177,16 +10836,11 @@ function createLocalRouteHandlers(options = {}) {
10177
10836
  }
10178
10837
  ];
10179
10838
  } catch (error) {
10180
- const normalized = error instanceof AiConnectError ? error : new AiConnectError(
10839
+ return normalizeVerifyIssue(
10840
+ error,
10181
10841
  "validation_error",
10182
10842
  "CLI route verification failed."
10183
10843
  );
10184
- return [
10185
- {
10186
- code: "handler_issue",
10187
- message: normalized.message
10188
- }
10189
- ];
10190
10844
  }
10191
10845
  }
10192
10846
  },
@@ -10222,16 +10876,11 @@ function createLocalRouteHandlers(options = {}) {
10222
10876
  }
10223
10877
  ];
10224
10878
  } catch (error) {
10225
- const normalized = error instanceof AiConnectError ? error : new AiConnectError(
10879
+ return normalizeVerifyIssue(
10880
+ error,
10226
10881
  "validation_error",
10227
10882
  "Server route verification failed."
10228
10883
  );
10229
- return [
10230
- {
10231
- code: "handler_issue",
10232
- message: normalized.message
10233
- }
10234
- ];
10235
10884
  }
10236
10885
  }
10237
10886
  }