@vedmalex/ai-connect 0.7.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 (39) hide show
  1. package/README.md +37 -4
  2. package/dist/browser/index.js +717 -284
  3. package/dist/browser/index.js.map +3 -3
  4. package/dist/bun/index.js +1248 -450
  5. package/dist/bun/index.js.map +4 -4
  6. package/dist/bun/local.js +1194 -444
  7. package/dist/bun/local.js.map +4 -4
  8. package/dist/node/index.js +1248 -450
  9. package/dist/node/index.js.map +4 -4
  10. package/dist/node/local.js +1194 -444
  11. package/dist/node/local.js.map +4 -4
  12. package/dist/types/acp.d.ts +14 -0
  13. package/dist/types/acp.d.ts.map +1 -1
  14. package/dist/types/cli-presets.d.ts.map +1 -1
  15. package/dist/types/cli.d.ts.map +1 -1
  16. package/dist/types/client.d.ts.map +1 -1
  17. package/dist/types/config.d.ts.map +1 -1
  18. package/dist/types/default-handlers.d.ts.map +1 -1
  19. package/dist/types/errors.d.ts +11 -0
  20. package/dist/types/errors.d.ts.map +1 -1
  21. package/dist/types/fanout.d.ts.map +1 -1
  22. package/dist/types/files.d.ts +47 -1
  23. package/dist/types/files.d.ts.map +1 -1
  24. package/dist/types/local-handlers.d.ts.map +1 -1
  25. package/dist/types/logging.d.ts +18 -1
  26. package/dist/types/logging.d.ts.map +1 -1
  27. package/dist/types/mock-gateway.d.ts +20 -0
  28. package/dist/types/mock-gateway.d.ts.map +1 -1
  29. package/dist/types/model-reference.d.ts +5 -2
  30. package/dist/types/model-reference.d.ts.map +1 -1
  31. package/dist/types/router.d.ts.map +1 -1
  32. package/dist/types/server-presets.d.ts +1 -1
  33. package/dist/types/server-presets.d.ts.map +1 -1
  34. package/dist/types/server.d.ts.map +1 -1
  35. package/dist/types/transport-runtime.d.ts +75 -0
  36. package/dist/types/transport-runtime.d.ts.map +1 -0
  37. package/dist/types/types.d.ts +44 -0
  38. package/dist/types/types.d.ts.map +1 -1
  39. package/package.json +1 -1
@@ -12,7 +12,10 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
12
12
  // pi takes file/image attachments as separate `@<path>` argv tokens (REQ-024);
13
13
  // {files} expands one `@<staged-path>` token per attachment. pi stages all
14
14
  // categories (PDFs as-is, no extraction guarantee).
15
- argsTemplate: ["--print", "--model", "{model}", "{files}", "{prompt}"],
15
+ // H10 (flag injection): the `--` end-of-options token precedes {files}/{prompt}
16
+ // — both are BARE positionals here — so an `@<path>` reference or a prompt that
17
+ // starts with `-` is parsed by pi as a positional, never as a flag.
18
+ argsTemplate: ["--print", "--model", "{model}", "--", "{files}", "{prompt}"],
16
19
  discovery: {
17
20
  via: "none"
18
21
  },
@@ -32,13 +35,22 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
32
35
  transportId: "claude-cli",
33
36
  options: {
34
37
  preset: "claude",
35
- argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "{prompt}"],
38
+ // H10 (flag injection): `--` precedes the trailing {prompt} positional so a
39
+ // prompt starting with `-` (e.g. "--dangerously-skip-permissions") is parsed
40
+ // by the claude CLI as a positional, not a flag.
41
+ argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "--", "{prompt}"],
36
42
  discovery: {
37
43
  via: "acp",
38
44
  acp: {
39
45
  transportId: "claude-code-acp"
40
46
  }
41
47
  },
48
+ // SENTINEL (documentation-only): the `__preset__:*` textPath/errorPath values
49
+ // are NOT resolved as live JSON paths. This `parser` block records which
50
+ // dedicated parser a built-in preset maps to (parseClaudeCli); dispatch for a
51
+ // preset route goes through the `cliOptions.preset` switch in cli.ts
52
+ // (parseCliResult), which only reads a parser config when a ROUTE sets its own
53
+ // `cli.parser`. See the mirrored note in cli.ts::parseCliResult.
42
54
  parser: {
43
55
  kind: "json",
44
56
  textPath: "__preset__:claude.result",
@@ -54,7 +66,11 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
54
66
  transportId: "openclaude-cli",
55
67
  options: {
56
68
  preset: "openclaude",
57
- argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "{prompt}"],
69
+ // H10 (flag injection): `--` precedes the trailing {prompt} positional (see
70
+ // the claude preset above for rationale).
71
+ argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "--", "{prompt}"],
72
+ // SENTINEL (documentation-only): see the claude preset note — `__preset__:*`
73
+ // paths are markers, not live JSON paths; dispatch is via the preset switch.
58
74
  parser: {
59
75
  kind: "json",
60
76
  textPath: "__preset__:claude.result",
@@ -73,6 +89,11 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
73
89
  // codex exec takes image attachments via `-i/--image <FILE>` flags
74
90
  // (REQ-024); {files} expands a `--image <staged-path>` pair per image.
75
91
  // Images only — a non-image attachment cleanly rejects pre-spawn.
92
+ // H10 (flag injection): `--` sits AFTER {files} and before the trailing
93
+ // {prompt} positional. Unlike pi, codex files are passed via the explicit
94
+ // `--image <path>` flag (the path is that flag's VALUE, not a positional), so
95
+ // `--` must NOT precede {files} or it would neutralize `--image`. It guards
96
+ // only the {prompt} positional against being parsed as a flag.
76
97
  argsTemplate: [
77
98
  "exec",
78
99
  "--json",
@@ -81,6 +102,7 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
81
102
  "--model",
82
103
  "{model}",
83
104
  "{files}",
105
+ "--",
84
106
  "{prompt}"
85
107
  ],
86
108
  discovery: {
@@ -94,6 +116,11 @@ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
94
116
  perFileArgs: ["--image", "{path}"],
95
117
  categories: ["image"]
96
118
  },
119
+ // SENTINEL (documentation-only): the `__preset__:*` path values below are
120
+ // markers naming the dedicated parser this preset maps to (parseCodexCli), NOT
121
+ // live JSONL selectors. Dispatch for a preset route runs through the
122
+ // `cliOptions.preset` switch in cli.ts::parseCliResult; this block is only ever
123
+ // read as a live parser when a ROUTE overrides `cli.parser`.
97
124
  parser: {
98
125
  kind: "jsonl",
99
126
  text: {
@@ -130,6 +157,19 @@ var AiConnectError = class extends Error {
130
157
  this.code = code;
131
158
  this.details = details;
132
159
  }
160
+ /**
161
+ * `Error.prototype.message` is non-enumerable, so a bare `JSON.stringify(err)` silently drops
162
+ * it (LOW, review-report-seed.md). Provide an explicit projection so serializing an
163
+ * `AiConnectError` (logs, wide events, error-response bodies) always carries the message.
164
+ */
165
+ toJSON() {
166
+ return {
167
+ name: this.name,
168
+ code: this.code,
169
+ message: this.message,
170
+ details: this.details
171
+ };
172
+ }
133
173
  };
134
174
  function isAiConnectError(error) {
135
175
  return error instanceof AiConnectError;
@@ -160,6 +200,16 @@ function toAiConnectError(error, fallbackCode = "temporary_unavailable") {
160
200
  // src/config.ts
161
201
  var DEFAULT_OPERATIONS = ["text", "image", "file"];
162
202
  var DEFAULT_STRATEGY = "priority";
203
+ var CLI_TRANSPORT_ID_BY_PROVIDER = {
204
+ anthropic: "claude-cli",
205
+ openclaude: "openclaude-cli",
206
+ openai: "codex-cli",
207
+ pi: "pi-cli"
208
+ };
209
+ var ACP_TRANSPORT_ID_BY_PROVIDER = {
210
+ anthropic: "claude-code-acp",
211
+ openai: "codex-acp"
212
+ };
163
213
  var DEFAULT_FALLBACK_ACTIONS = {
164
214
  rate_limit: [
165
215
  "rotate-credential",
@@ -271,33 +321,64 @@ function assert(condition, message) {
271
321
  throw new AiConnectError("validation_error", message);
272
322
  }
273
323
  }
274
- function asPositiveInteger(value, fallback) {
324
+ function asPositiveInteger(value, fallback, fieldLabel) {
325
+ if (value === void 0) {
326
+ return fallback;
327
+ }
275
328
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
329
+ if (fieldLabel) {
330
+ console.warn(
331
+ `[ai-connect] ${fieldLabel} must be a positive finite number when provided; received ${JSON.stringify(value)}. Falling back to ${fallback}.`
332
+ );
333
+ }
276
334
  return fallback;
277
335
  }
278
336
  return Math.floor(value);
279
337
  }
280
- function asNonNegativeInteger(value, fallback) {
338
+ function asNonNegativeInteger(value, fallback, fieldLabel) {
339
+ if (value === void 0) {
340
+ return fallback;
341
+ }
281
342
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
343
+ if (fieldLabel) {
344
+ console.warn(
345
+ `[ai-connect] ${fieldLabel} must be a non-negative finite number when provided; received ${JSON.stringify(value)}. Falling back to ${fallback}.`
346
+ );
347
+ }
282
348
  return fallback;
283
349
  }
284
350
  return Math.floor(value);
285
351
  }
286
- function asOptionalContextWindow(value) {
352
+ function asOptionalContextWindow(value, fieldLabel) {
353
+ if (value === void 0) {
354
+ return void 0;
355
+ }
287
356
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
357
+ if (fieldLabel) {
358
+ console.warn(
359
+ `[ai-connect] ${fieldLabel} must be a positive finite number when provided; received ${JSON.stringify(value)}. Ignoring.`
360
+ );
361
+ }
288
362
  return void 0;
289
363
  }
290
364
  return Math.floor(value);
291
365
  }
292
- function normalizeModelInput(input, accountContextWindow) {
366
+ function normalizeModelInput(input, accountContextWindow, accountId, providerId) {
293
367
  if (typeof input === "string") {
294
368
  return {
295
369
  id: input.trim(),
296
370
  ...accountContextWindow !== void 0 ? { contextWindow: accountContextWindow } : {}
297
371
  };
298
372
  }
373
+ assert(
374
+ typeof input.id === "string" && input.id.trim().length > 0,
375
+ `Account "${accountId}" for provider "${providerId}" declared a model entry with a missing or empty "id".`
376
+ );
299
377
  const id = input.id.trim();
300
- const override = asOptionalContextWindow(input.contextWindow);
378
+ const override = asOptionalContextWindow(
379
+ input.contextWindow,
380
+ `Model "${id}" (account "${accountId}", provider "${providerId}") contextWindow`
381
+ );
301
382
  const contextWindow = override ?? accountContextWindow;
302
383
  return {
303
384
  id,
@@ -308,7 +389,8 @@ function asFanoutBound(value) {
308
389
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
309
390
  return Number.POSITIVE_INFINITY;
310
391
  }
311
- return Math.floor(value);
392
+ const floored = Math.floor(value);
393
+ return floored <= 0 ? Number.POSITIVE_INFINITY : floored;
312
394
  }
313
395
  function normalizeFanoutPolicy(policy) {
314
396
  return {
@@ -534,7 +616,7 @@ function normalizeTransport(providerId, input) {
534
616
  };
535
617
  }
536
618
  if (descriptor.kind === "cli") {
537
- const inferredId2 = providerId === "anthropic" ? "claude-cli" : providerId === "openclaude" ? "openclaude-cli" : providerId === "openai" ? "codex-cli" : providerId === "pi" ? "pi-cli" : "cli";
619
+ const inferredId2 = CLI_TRANSPORT_ID_BY_PROVIDER[providerId] ?? "cli";
538
620
  return {
539
621
  kind: "cli",
540
622
  id: descriptor.id?.trim() || inferredId2,
@@ -551,7 +633,7 @@ function normalizeTransport(providerId, input) {
551
633
  ...descriptor.baseUrl?.trim() ? { baseUrl: descriptor.baseUrl.trim() } : {}
552
634
  };
553
635
  }
554
- const inferredId = providerId === "anthropic" ? "claude-code-acp" : providerId === "openai" ? "codex-acp" : "acp";
636
+ const inferredId = ACP_TRANSPORT_ID_BY_PROVIDER[providerId] ?? "acp";
555
637
  const normalizedLaunch = descriptor.launch ? (() => {
556
638
  const contextMode = descriptor.launch?.contextMode ?? "workspace";
557
639
  const skillsMode = descriptor.launch?.skillsMode ?? "default";
@@ -648,20 +730,26 @@ function defaultCapabilities(transport, runtime) {
648
730
  }
649
731
  function normalizeCredential(providerId, accountId, transport, input, index) {
650
732
  const id = input.id?.trim() || `${accountId}-credential-${index + 1}`;
651
- const source = input.source?.trim() || (input.apiKey ? "literal" : input.apiKeyEnv ? "env" : transport.kind === "api" ? "env" : "session");
733
+ const trimmedApiKey = input.apiKey?.trim();
734
+ const trimmedApiKeyEnv = input.apiKeyEnv?.trim();
735
+ const source = input.source?.trim() || (trimmedApiKey ? "literal" : trimmedApiKeyEnv ? "env" : transport.kind === "api" ? "env" : "session");
652
736
  if (transport.kind === "api") {
653
737
  assert(
654
- Boolean(input.apiKey?.trim()) || Boolean(input.apiKeyEnv?.trim()),
738
+ Boolean(trimmedApiKey) || Boolean(trimmedApiKeyEnv),
655
739
  `API account "${accountId}" for provider "${providerId}" requires apiKey or apiKeyEnv credentials.`
656
740
  );
657
741
  }
658
742
  return {
659
743
  id,
660
744
  source,
661
- weight: asPositiveInteger(input.weight, 1),
745
+ weight: asPositiveInteger(
746
+ input.weight,
747
+ 1,
748
+ `Credential "${id}" (account "${accountId}", provider "${providerId}") weight`
749
+ ),
662
750
  metadata: input.metadata ?? {},
663
- ...input.apiKey?.trim() ? { apiKey: input.apiKey.trim() } : {},
664
- ...input.apiKeyEnv?.trim() ? { apiKeyEnv: input.apiKeyEnv.trim() } : {},
751
+ ...trimmedApiKey ? { apiKey: trimmedApiKey } : {},
752
+ ...trimmedApiKeyEnv ? { apiKeyEnv: trimmedApiKeyEnv } : {},
665
753
  ...input.apiKeyDelimiter ? { apiKeyDelimiter: input.apiKeyDelimiter } : {}
666
754
  };
667
755
  }
@@ -695,6 +783,31 @@ function buildRouteId(route) {
695
783
  slugify(route.model)
696
784
  ].join(":");
697
785
  }
786
+ function assertRouteIdUniqueness(routes) {
787
+ const byId = /* @__PURE__ */ new Map();
788
+ for (const route of routes) {
789
+ const group = byId.get(route.id);
790
+ if (group) {
791
+ group.push(route);
792
+ } else {
793
+ byId.set(route.id, [route]);
794
+ }
795
+ }
796
+ for (const [id, group] of byId) {
797
+ if (group.length <= 1) {
798
+ continue;
799
+ }
800
+ const distinctModels = [...new Set(group.map((route) => route.model))];
801
+ if (distinctModels.length <= 1) {
802
+ continue;
803
+ }
804
+ const first = group[0];
805
+ assert(
806
+ false,
807
+ `Route id "${id}" is ambiguous: model(s) ${distinctModels.map((model) => `"${model}"`).join(", ")} on account "${first.accountId}" (provider "${first.provider}") slugify to the same route id. Rename one of the models so they no longer collide after slugification.`
808
+ );
809
+ }
810
+ }
698
811
  function routeAliases(route) {
699
812
  return [
700
813
  route.provider,
@@ -819,7 +932,8 @@ function defineConfig(input) {
819
932
  const transport = normalizeTransport(providerId, accountInput.transport);
820
933
  const runtime = normalizeRuntime(transport, accountInput.runtime);
821
934
  const accountContextWindow = asOptionalContextWindow(
822
- accountInput.contextWindow
935
+ accountInput.contextWindow,
936
+ `Account "${accountId}" (provider "${providerId}") contextWindow`
823
937
  );
824
938
  const accountModelAllowlistMode = accountInput.modelAllowlistMode ?? "strict";
825
939
  const accountContextMode = accountInput.contextMode ?? "workspace";
@@ -829,7 +943,9 @@ function defineConfig(input) {
829
943
  Array.isArray(accountInput.models) && accountInput.models.length > 0,
830
944
  `Account "${accountId}" for provider "${providerId}" must declare at least one model.`
831
945
  );
832
- const models = accountInput.models.map((model) => normalizeModelInput(model, accountContextWindow)).filter((model) => model.id.length > 0);
946
+ const models = accountInput.models.map(
947
+ (model) => normalizeModelInput(model, accountContextWindow, accountId, providerId)
948
+ ).filter((model) => model.id.length > 0);
833
949
  assert(
834
950
  models.length > 0,
835
951
  `Account "${accountId}" for provider "${providerId}" must declare at least one model.`
@@ -859,8 +975,16 @@ function defineConfig(input) {
859
975
  credentials,
860
976
  capabilities,
861
977
  verify,
862
- weight: asPositiveInteger(accountInput.weight, 1),
863
- priority: asNonNegativeInteger(accountInput.priority, accountIndex),
978
+ weight: asPositiveInteger(
979
+ accountInput.weight,
980
+ 1,
981
+ `Account "${accountId}" (provider "${providerId}") weight`
982
+ ),
983
+ priority: asNonNegativeInteger(
984
+ accountInput.priority,
985
+ accountIndex,
986
+ `Account "${accountId}" (provider "${providerId}") priority`
987
+ ),
864
988
  enabled: accountInput.enabled ?? true
865
989
  };
866
990
  accounts.push(normalizedAccount);
@@ -907,6 +1031,7 @@ function defineConfig(input) {
907
1031
  };
908
1032
  }
909
1033
  assert(routes.length > 0, "At least one enabled route must be produced.");
1034
+ assertRouteIdUniqueness(routes);
910
1035
  finalizeRotationCapabilities(routes);
911
1036
  const strategy = input.routing?.strategy ?? DEFAULT_STRATEGY;
912
1037
  const resolutionInput = input.routing?.resolution;
@@ -980,6 +1105,10 @@ function resolveRouteSelectors(selectors, routes, policy) {
980
1105
  }
981
1106
 
982
1107
  // src/fanout.ts
1108
+ function abortedError() {
1109
+ return new AiConnectError("aborted", "Operation aborted.");
1110
+ }
1111
+ var MAX_DELAY_MS = 24 * 60 * 60 * 1e3;
983
1112
  function createFanoutLimiter(policy, runtime) {
984
1113
  const now = () => runtime.now?.() ?? Date.now();
985
1114
  const { maxConcurrency, requestsPerSecond, maxCalls } = policy;
@@ -1043,10 +1172,10 @@ function createFanoutLimiter(policy, runtime) {
1043
1172
  if (index >= 0) {
1044
1173
  concurrencyQueue.splice(index, 1);
1045
1174
  }
1046
- rejectWaiter(waiter, new AiConnectError("aborted", "Operation aborted."));
1175
+ rejectWaiter(waiter, abortedError());
1047
1176
  };
1048
1177
  if (signal.aborted) {
1049
- reject(new AiConnectError("aborted", "Operation aborted."));
1178
+ reject(abortedError());
1050
1179
  return;
1051
1180
  }
1052
1181
  waiter.signal = signal;
@@ -1062,7 +1191,7 @@ function createFanoutLimiter(policy, runtime) {
1062
1191
  }
1063
1192
  while (true) {
1064
1193
  if (signal?.aborted) {
1065
- throw new AiConnectError("aborted", "Operation aborted.");
1194
+ throw abortedError();
1066
1195
  }
1067
1196
  refill();
1068
1197
  if (tokens >= 1) {
@@ -1074,6 +1203,7 @@ function createFanoutLimiter(policy, runtime) {
1074
1203
  }
1075
1204
  }
1076
1205
  function delay(ms, signal) {
1206
+ const safeMs = Number.isFinite(ms) && ms >= 0 ? Math.min(ms, MAX_DELAY_MS) : MAX_DELAY_MS;
1077
1207
  return new Promise((resolve, reject) => {
1078
1208
  let cancelTimer;
1079
1209
  const onAbort = () => {
@@ -1081,11 +1211,11 @@ function createFanoutLimiter(policy, runtime) {
1081
1211
  if (signal) {
1082
1212
  signal.removeEventListener("abort", onAbort);
1083
1213
  }
1084
- reject(new AiConnectError("aborted", "Operation aborted."));
1214
+ reject(abortedError());
1085
1215
  };
1086
1216
  if (signal) {
1087
1217
  if (signal.aborted) {
1088
- reject(new AiConnectError("aborted", "Operation aborted."));
1218
+ reject(abortedError());
1089
1219
  return;
1090
1220
  }
1091
1221
  signal.addEventListener("abort", onAbort, { once: true });
@@ -1097,9 +1227,9 @@ function createFanoutLimiter(policy, runtime) {
1097
1227
  resolve();
1098
1228
  };
1099
1229
  if (runtime.setTimer) {
1100
- cancelTimer = runtime.setTimer(ms, done);
1230
+ cancelTimer = runtime.setTimer(safeMs, done);
1101
1231
  } else {
1102
- const handle = setTimeout(done, ms);
1232
+ const handle = setTimeout(done, safeMs);
1103
1233
  cancelTimer = () => clearTimeout(handle);
1104
1234
  }
1105
1235
  });
@@ -1113,8 +1243,13 @@ function createFanoutLimiter(policy, runtime) {
1113
1243
  );
1114
1244
  }
1115
1245
  callsStarted += 1;
1116
- await awaitRateToken(signal);
1117
- await awaitConcurrencySlot(signal);
1246
+ try {
1247
+ await awaitRateToken(signal);
1248
+ await awaitConcurrencySlot(signal);
1249
+ } catch (error) {
1250
+ callsStarted -= 1;
1251
+ throw error;
1252
+ }
1118
1253
  let released = false;
1119
1254
  return () => {
1120
1255
  if (released) {
@@ -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 ? {
@@ -5843,6 +6277,22 @@ function extractThoughts(value) {
5843
6277
  const joined = thoughts.join("\n").trim();
5844
6278
  return joined.length > 0 ? joined : void 0;
5845
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();
6294
+ return joined.length > 0 ? joined : void 0;
6295
+ }
5846
6296
  function imageAttachmentsFromPayload(payload, ...sources) {
5847
6297
  return extractImagePayloads(payload, ...sources).map(
5848
6298
  (item) => preparePortableFile(item)
@@ -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 } : {},
@@ -6410,63 +6882,21 @@ async function runGemini(fetchImpl, context) {
6410
6882
  calls: 1,
6411
6883
  ...data.usageMetadata?.promptTokenCount !== void 0 ? { inputTokens: data.usageMetadata.promptTokenCount } : {},
6412
6884
  ...data.usageMetadata?.candidatesTokenCount !== void 0 ? { outputTokens: data.usageMetadata.candidatesTokenCount } : {},
6413
- ...data.usageMetadata?.thoughtsTokenCount !== void 0 ? { reasoningTokens: data.usageMetadata.thoughtsTokenCount } : {}
6414
- }
6415
- } : {}
6416
- };
6417
- }
6418
- async function runGeminiImage(fetchImpl, context) {
6419
- 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
6457
- });
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)
6885
+ ...data.usageMetadata?.thoughtsTokenCount !== void 0 ? { reasoningTokens: data.usageMetadata.thoughtsTokenCount } : {}
6886
+ }
6887
+ } : {}
6468
6888
  };
6469
6889
  }
6890
+ async function runGeminiImage(fetchImpl, context) {
6891
+ const apiKey = resolveApiKey(context.route, context.runtime);
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
6898
+ });
6899
+ }
6470
6900
  function verifyApiRoute(route, runtime) {
6471
6901
  resolveApiKey(route, runtime);
6472
6902
  return [];
@@ -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"],
@@ -6797,6 +7341,20 @@ var TEXT_EXTENSIONS2 = /* @__PURE__ */ new Set([
6797
7341
  function isRecord2(value) {
6798
7342
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
6799
7343
  }
7344
+ var ACP_HARNESS_NOISE_PATTERNS = [
7345
+ /^модель переключена на .+?\.?(?:\s*готов к работе[.!…]*)?$/iu,
7346
+ /^готов к работе[.!…]*$/iu,
7347
+ /^жду (?:вашего вопроса|вопроса или задачи|вашей задачи|вашего ответа|явного запроса)[^]*$/iu,
7348
+ /^это сообщение помечено как локальная команда[^]*$/iu,
7349
+ /^<local-command-(?:caveat|stdout|name|message|args)[^>]*>[^]*$/iu
7350
+ ];
7351
+ function isHarnessNoiseLine(line) {
7352
+ const trimmed = line.trim();
7353
+ if (trimmed.length === 0) {
7354
+ return false;
7355
+ }
7356
+ return ACP_HARNESS_NOISE_PATTERNS.some((pattern) => pattern.test(trimmed));
7357
+ }
6800
7358
  function splitCommandLine(commandLine) {
6801
7359
  const matches = commandLine.match(/"[^"]*"|'[^']*'|[^\s]+/g) ?? [];
6802
7360
  const parts = matches.map((part) => {
@@ -7039,24 +7597,29 @@ function prepareClaudeLaunchRuntime(base, launch) {
7039
7597
  launch
7040
7598
  };
7041
7599
  }
7600
+ var ACP_LAUNCH_PREPARERS = {
7601
+ "opencode-acp": prepareOpenCodeLaunchRuntime,
7602
+ "codex-acp": prepareCodexLaunchRuntime,
7603
+ "claude-code-acp": prepareClaudeLaunchRuntime
7604
+ };
7042
7605
  async function prepareAcpLaunchRuntime(route, options, commandLine, cwdOverride) {
7043
7606
  const launch = normalizeLaunchOptions(route, options);
7044
7607
  const base = {
7045
7608
  commandLine,
7046
7609
  cwd: path.resolve(cwdOverride ?? options?.cwd ?? process.cwd()),
7047
- env: {
7048
- ...process.env,
7049
- ...options?.env ?? {}
7050
- }
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 ?? {} })
7051
7614
  };
7052
- if (route.transport.id === "opencode-acp") {
7053
- return prepareOpenCodeLaunchRuntime(base, launch);
7054
- }
7055
- if (route.transport.id === "codex-acp") {
7056
- return prepareCodexLaunchRuntime(base, launch);
7615
+ const preparer = ACP_LAUNCH_PREPARERS[route.transport.id];
7616
+ if (preparer) {
7617
+ return await preparer(base, launch);
7057
7618
  }
7058
- if (route.transport.id === "claude-code-acp") {
7059
- 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
+ );
7060
7623
  }
7061
7624
  return {
7062
7625
  ...base,
@@ -7088,8 +7651,27 @@ function mimeTypeFromPath(filePath) {
7088
7651
  function isTextPath(filePath) {
7089
7652
  return TEXT_EXTENSIONS2.has(path.extname(filePath).toLowerCase());
7090
7653
  }
7091
- function isWithinRoot(rootDir, targetPath) {
7092
- 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);
7093
7675
  return relative.length === 0 || !relative.startsWith("..") && !path.isAbsolute(relative);
7094
7676
  }
7095
7677
  function buildMessageTranscript(context) {
@@ -7516,46 +8098,58 @@ function extractTextChunk(update) {
7516
8098
  }
7517
8099
  return typeof content.text === "string" && content.text.length > 0 ? content.text : void 0;
7518
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
+ }
7519
8106
  function normalizeAcpError(error) {
7520
8107
  if (error instanceof AiConnectError) {
7521
8108
  return error;
7522
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
+ }
7523
8121
  if (isRecord2(error)) {
7524
8122
  const code = typeof error.code === "number" ? error.code : void 0;
7525
8123
  const message = typeof error.message === "string" ? error.message : "ACP transport failed.";
7526
8124
  if (code === -32601 || code === -32602) {
7527
8125
  return new AiConnectError("not_supported", message);
7528
8126
  }
7529
- if (message.toLowerCase().includes("auth")) {
8127
+ if (isAuthErrorMessage(message)) {
7530
8128
  return new AiConnectError("auth_error", message);
7531
8129
  }
7532
8130
  return new AiConnectError("temporary_unavailable", message);
7533
8131
  }
7534
- if (error instanceof Error) {
7535
- const message = error.message;
7536
- if ("code" in error && error.code === "ENOENT") {
7537
- return new AiConnectError("local_harness_unavailable", message);
7538
- }
7539
- if (message.toLowerCase().includes("timed out")) {
7540
- return new AiConnectError("local_harness_unavailable", message);
7541
- }
7542
- return new AiConnectError("temporary_unavailable", message);
7543
- }
7544
8132
  return new AiConnectError(
7545
8133
  "temporary_unavailable",
7546
8134
  "ACP transport failed."
7547
8135
  );
7548
8136
  }
7549
8137
  function resolveAcpCommand(route, options) {
7550
- const override = options?.commands?.[`${route.provider}:${route.transport.id}`] ?? options?.commands?.[route.transport.id] ?? options?.commands?.[route.provider];
7551
- const resolved = override ?? route.transport.command ?? AI_CONNECT_DEFAULT_ACP_COMMANDS[`${route.provider}:${route.transport.id}`];
7552
- 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 {
7553
8148
  throw new AiConnectError(
7554
8149
  "validation_error",
7555
8150
  `No ACP command is configured for route "${route.id}".`
7556
8151
  );
7557
8152
  }
7558
- return resolved;
7559
8153
  }
7560
8154
  function resolveAcpExecutable(route, options) {
7561
8155
  return splitCommandLine(resolveAcpCommand(route, options)).command;
@@ -7633,6 +8227,7 @@ function buildAcpLifecycle(route, authRequest, mode) {
7633
8227
  }
7634
8228
  steps.push("session/new");
7635
8229
  if (mode === "prompt") {
8230
+ steps.push("session/set_model");
7636
8231
  steps.push("session/prompt");
7637
8232
  }
7638
8233
  return { steps, keys };
@@ -7705,14 +8300,7 @@ async function settleClosePromise(closePromise, timeoutMs = 1500) {
7705
8300
  ]);
7706
8301
  }
7707
8302
  function killAcpProcess(child, signal) {
7708
- if (process.platform !== "win32" && typeof child.pid === "number") {
7709
- try {
7710
- process.kill(-child.pid, signal);
7711
- return;
7712
- } catch {
7713
- }
7714
- }
7715
- child.kill(signal);
8303
+ killProcessTree(child, signal, process.platform !== "win32");
7716
8304
  }
7717
8305
  var AcpConnection = class {
7718
8306
  commandLine;
@@ -7720,6 +8308,9 @@ var AcpConnection = class {
7720
8308
  env;
7721
8309
  timeoutMs;
7722
8310
  permissionMode;
8311
+ selectModel;
8312
+ suppressHarnessNoise;
8313
+ failOnHarnessOnlyTurn;
7723
8314
  command;
7724
8315
  args;
7725
8316
  child;
@@ -7741,6 +8332,9 @@ var AcpConnection = class {
7741
8332
  this.env = options.env;
7742
8333
  this.timeoutMs = options.timeoutMs;
7743
8334
  this.permissionMode = options.permissionMode;
8335
+ this.selectModel = options.selectModel;
8336
+ this.suppressHarnessNoise = options.suppressHarnessNoise;
8337
+ this.failOnHarnessOnlyTurn = options.failOnHarnessOnlyTurn;
7744
8338
  const resolved = splitCommandLine(options.commandLine);
7745
8339
  this.command = resolved.command;
7746
8340
  this.args = resolved.args;
@@ -7773,8 +8367,18 @@ var AcpConnection = class {
7773
8367
  usage: void 0,
7774
8368
  attachments: [],
7775
8369
  warnings: [],
8370
+ suppressNoise: this.suppressHarnessNoise,
8371
+ lineBuffer: "",
8372
+ harnessMarkers: [],
7776
8373
  ...onDelta ? { onDelta } : {}
7777
8374
  };
8375
+ await this.selectSessionModel(
8376
+ sessionId,
8377
+ context.route,
8378
+ session,
8379
+ transport.phases ?? [],
8380
+ this.activePrompt.warnings
8381
+ );
7778
8382
  let stopReason;
7779
8383
  const abortSignal = context.abort.signal;
7780
8384
  let abortListener;
@@ -7817,13 +8421,30 @@ var AcpConnection = class {
7817
8421
  }
7818
8422
  this.bumpIdleTimer();
7819
8423
  }
8424
+ this.flushHarnessLineBuffer();
7820
8425
  const stderrSlice = this.stderr.slice(stderrOffset).trim();
7821
8426
  const warnings = [...this.activePrompt.warnings];
8427
+ if (this.activePrompt.harnessMarkers.length > 0) {
8428
+ warnings.push(
8429
+ `ACP: filtered ${this.activePrompt.harnessMarkers.length} interactive-harness line(s) from the response.`
8430
+ );
8431
+ }
7822
8432
  if (stderrSlice.length > 0) {
7823
8433
  warnings.push(stderrSlice);
7824
8434
  }
8435
+ if (this.stderr.length > MAX_ACP_STDERR_CHARS) {
8436
+ this.stderr = this.stderr.slice(-MAX_ACP_STDERR_CHARS);
8437
+ }
8438
+ const text = this.activePrompt.text.trim();
8439
+ if (this.failOnHarnessOnlyTurn && this.activePrompt.harnessMarkers.length > 0 && text.length === 0) {
8440
+ this.activePrompt = void 0;
8441
+ throw new AiConnectError(
8442
+ "temporary_unavailable",
8443
+ "ACP turn returned only interactive-harness output (no task output)."
8444
+ );
8445
+ }
7825
8446
  const result = {
7826
- text: this.activePrompt.text.trim(),
8447
+ text,
7827
8448
  attachments: [...this.activePrompt.attachments],
7828
8449
  warnings,
7829
8450
  ...this.activePrompt.usage ? { usage: this.activePrompt.usage } : {},
@@ -7983,6 +8604,12 @@ var AcpConnection = class {
7983
8604
  this.stderr = "";
7984
8605
  this.pending.clear();
7985
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");
7986
8613
  child.stdout.setEncoding("utf8");
7987
8614
  child.stdout.on("data", (chunk) => {
7988
8615
  this.buffer += chunk;
@@ -8046,8 +8673,14 @@ var AcpConnection = class {
8046
8673
  if (!stdin) {
8047
8674
  return;
8048
8675
  }
8049
- stdin.write(`${JSON.stringify(message)}
8676
+ try {
8677
+ stdin.write(`${JSON.stringify(message)}
8050
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
+ }
8051
8684
  }
8052
8685
  failPending(error) {
8053
8686
  for (const entry of this.pending.values()) {
@@ -8068,7 +8701,7 @@ var AcpConnection = class {
8068
8701
  }
8069
8702
  if (message.method === "readTextFile") {
8070
8703
  const filePath = typeof params.path === "string" ? path.resolve(params.path) : void 0;
8071
- if (!filePath || !isWithinRoot(this.cwd, filePath)) {
8704
+ if (!filePath || !await isWithinRoot(this.cwd, filePath)) {
8072
8705
  this.reply({
8073
8706
  jsonrpc: "2.0",
8074
8707
  id: message.id,
@@ -8101,7 +8734,7 @@ var AcpConnection = class {
8101
8734
  }
8102
8735
  const filePath = typeof params.path === "string" ? path.resolve(params.path) : void 0;
8103
8736
  const content = typeof params.content === "string" ? params.content : void 0;
8104
- if (!filePath || content === void 0 || !isWithinRoot(this.cwd, filePath)) {
8737
+ if (!filePath || content === void 0 || !await isWithinRoot(this.cwd, filePath)) {
8105
8738
  this.reply({
8106
8739
  jsonrpc: "2.0",
8107
8740
  id: message.id,
@@ -8130,6 +8763,108 @@ var AcpConnection = class {
8130
8763
  }
8131
8764
  });
8132
8765
  }
8766
+ /**
8767
+ * Select the route's model through the ACP protocol (TASK-033 / UR-002).
8768
+ * Best-effort matrix: skip when disabled / no model / no advertised catalog /
8769
+ * already current; warn (not silently default) when the requested model is not
8770
+ * in the agent's `availableModels`; otherwise `session/set_model` and surface
8771
+ * any error as a warning. Never throws — a failed protocol selection must not
8772
+ * fail the generation.
8773
+ */
8774
+ async selectSessionModel(sessionId, route, sessionResult, phases, warnings) {
8775
+ if (!this.selectModel) {
8776
+ return;
8777
+ }
8778
+ const desiredModel = route.model?.trim();
8779
+ if (!desiredModel) {
8780
+ return;
8781
+ }
8782
+ const catalog = modelCatalogFromSession(sessionResult);
8783
+ if (!catalog) {
8784
+ return;
8785
+ }
8786
+ if (catalog.currentModelId === desiredModel) {
8787
+ return;
8788
+ }
8789
+ const available = catalog.availableModels ?? [];
8790
+ if (available.length > 0 && !available.some((entry) => entry.modelId === desiredModel)) {
8791
+ warnings.push(
8792
+ `ACP agent's available models [${available.map((entry) => entry.modelId).join(", ")}] do not include route model "${desiredModel}"; model not switched via protocol.`
8793
+ );
8794
+ return;
8795
+ }
8796
+ try {
8797
+ await measurePhase(
8798
+ phases,
8799
+ "session/set_model",
8800
+ async () => this.request("session/set_model", {
8801
+ sessionId,
8802
+ modelId: desiredModel
8803
+ })
8804
+ );
8805
+ } catch (error) {
8806
+ const message = error instanceof Error ? error.message : isRecord2(error) && typeof error.message === "string" ? error.message : String(error);
8807
+ warnings.push(
8808
+ `ACP session/set_model("${desiredModel}") failed: ${message}`
8809
+ );
8810
+ }
8811
+ }
8812
+ /**
8813
+ * Append `agent_message_chunk` text (TASK-033 / UR-003). With suppression off
8814
+ * (legacy) the chunk streams + accumulates raw. With suppression on, the chunk
8815
+ * is line-buffered: each COMPLETE line is classified and either dropped (a
8816
+ * harness marker — no delta, no accumulation) or emitted; the trailing partial
8817
+ * line waits in `lineBuffer` for the next chunk or the end-of-turn flush.
8818
+ */
8819
+ appendAgentText(chunk) {
8820
+ const state = this.activePrompt;
8821
+ if (!state) {
8822
+ return;
8823
+ }
8824
+ if (!state.suppressNoise) {
8825
+ state.onDelta?.(chunk);
8826
+ state.text += chunk;
8827
+ return;
8828
+ }
8829
+ state.lineBuffer += chunk;
8830
+ let newlineIndex = state.lineBuffer.indexOf("\n");
8831
+ while (newlineIndex !== -1) {
8832
+ const line = state.lineBuffer.slice(0, newlineIndex + 1);
8833
+ state.lineBuffer = state.lineBuffer.slice(newlineIndex + 1);
8834
+ this.emitAgentLine(line);
8835
+ newlineIndex = state.lineBuffer.indexOf("\n");
8836
+ }
8837
+ }
8838
+ /** Flush any trailing partial line through the harness-noise classifier. */
8839
+ flushHarnessLineBuffer() {
8840
+ const state = this.activePrompt;
8841
+ if (!state || !state.suppressNoise) {
8842
+ return;
8843
+ }
8844
+ const remaining = state.lineBuffer;
8845
+ state.lineBuffer = "";
8846
+ if (remaining.length > 0) {
8847
+ this.emitAgentLine(remaining);
8848
+ }
8849
+ }
8850
+ /**
8851
+ * Emit one classified line: a curated harness marker is recorded + dropped
8852
+ * (no delta, no accumulation); any other line streams + accumulates. `line`
8853
+ * may carry a trailing newline — classification runs on the content.
8854
+ */
8855
+ emitAgentLine(line) {
8856
+ const state = this.activePrompt;
8857
+ if (!state) {
8858
+ return;
8859
+ }
8860
+ const content = line.endsWith("\n") ? line.slice(0, -1) : line;
8861
+ if (isHarnessNoiseLine(content)) {
8862
+ state.harnessMarkers.push(content.trim());
8863
+ return;
8864
+ }
8865
+ state.onDelta?.(line);
8866
+ state.text += line;
8867
+ }
8133
8868
  handleNotification(message) {
8134
8869
  if (message.method !== "session/update" || !this.activePrompt) {
8135
8870
  return;
@@ -8144,8 +8879,7 @@ var AcpConnection = class {
8144
8879
  }
8145
8880
  const chunk = extractTextChunk(update);
8146
8881
  if (update.sessionUpdate === "agent_message_chunk" && chunk) {
8147
- this.activePrompt.onDelta?.(chunk);
8148
- this.activePrompt.text += chunk;
8882
+ this.appendAgentText(chunk);
8149
8883
  }
8150
8884
  if (update.sessionUpdate === "agent_thought_chunk" && chunk) {
8151
8885
  this.activePrompt.warnings.push(chunk);
@@ -8274,8 +9008,20 @@ var AcpConnection = class {
8274
9008
  );
8275
9009
  return;
8276
9010
  }
8277
- stdin.write(`${JSON.stringify(payload)}
9011
+ try {
9012
+ stdin.write(`${JSON.stringify(payload)}
8278
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
+ }
8279
9025
  });
8280
9026
  }
8281
9027
  };
@@ -8332,7 +9078,10 @@ function createAcpTransportManager(options) {
8332
9078
  cwd: runtime.cwd,
8333
9079
  env: runtime.env,
8334
9080
  timeoutMs: promptTimeout(options),
8335
- permissionMode: options?.permissionMode ?? "deny-all"
9081
+ permissionMode: options?.permissionMode ?? "deny-all",
9082
+ selectModel: options?.selectModel ?? true,
9083
+ suppressHarnessNoise: options?.suppressHarnessNoise ?? true,
9084
+ failOnHarnessOnlyTurn: options?.failOnHarnessOnlyTurn ?? true
8336
9085
  };
8337
9086
  }
8338
9087
  function getPool(key) {
@@ -8543,6 +9292,10 @@ function createAcpTransportManager(options) {
8543
9292
  let notify;
8544
9293
  const onDelta = (text) => {
8545
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
+ }
8546
9299
  notify?.();
8547
9300
  };
8548
9301
  let settled = false;
@@ -8680,44 +9433,29 @@ ${message.content}`;
8680
9433
  }).join("\n\n");
8681
9434
  }
8682
9435
  function resolveCliCommand(route, options) {
8683
- if (route.transport.command?.trim()) {
8684
- return route.transport.command.trim();
8685
- }
8686
- const candidates = [
8687
- `${route.provider}:${route.transport.id}`,
8688
- route.transport.id,
8689
- route.provider
8690
- ];
8691
- for (const candidate of candidates) {
8692
- const override = options?.commands?.[candidate]?.trim();
8693
- if (override) {
8694
- return override;
8695
- }
8696
- }
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;
8697
9438
  const presetId = route.transport.cli?.preset ?? defaultCliPresetIdForRoute(route);
8698
- if (presetId) {
8699
- const presetCommand = getCliTransportPreset(presetId).command.trim();
8700
- if (presetCommand) {
8701
- return presetCommand;
8702
- }
8703
- }
8704
- const preset = AI_CONNECT_DEFAULT_CLI_COMMANDS[`${route.provider}:${route.transport.id}`];
8705
- if (preset) {
8706
- 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
+ );
8707
9452
  }
8708
- throw new AiConnectError(
8709
- "validation_error",
8710
- `No CLI command preset is registered for route "${route.id}". Provide transport.command or cli.commands.`
8711
- );
8712
9453
  }
8713
9454
  function resolveCliExecutable(route, options) {
8714
9455
  return splitCommandLine2(resolveCliCommand(route, options)).command;
8715
9456
  }
8716
9457
  function buildCliEnvironment(options) {
8717
- return {
8718
- ...process.env,
8719
- ...options?.env ?? {}
8720
- };
9458
+ return resolveChildEnv({ extra: options?.env ?? {} });
8721
9459
  }
8722
9460
  function buildCliCwd(context, options) {
8723
9461
  return path2.resolve(
@@ -8818,9 +9556,13 @@ function resolveCliTransportOptions(route) {
8818
9556
  } : void 0;
8819
9557
  const merged = {
8820
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.
8821
9565
  ...route.transport.cli ?? {},
8822
- ...route.transport.cli?.argsTemplate ? { argsTemplate: route.transport.cli.argsTemplate } : {},
8823
- ...route.transport.cli?.parser ? { parser: route.transport.cli.parser } : {},
8824
9566
  ...mergedFileInput ? { fileInput: mergedFileInput } : {},
8825
9567
  ...presetId ? { preset: presetId } : {}
8826
9568
  };
@@ -8917,7 +9659,7 @@ function materializeTemplateArg(templateArg, values, parameterKeys) {
8917
9659
  }
8918
9660
  return values.outputFile;
8919
9661
  default:
8920
- if (templateArg.startsWith("--")) {
9662
+ if (templateArg.startsWith("--") && templateArg.length > 2) {
8921
9663
  parameterKeys.push(templateArg);
8922
9664
  }
8923
9665
  return templateArg;
@@ -9070,8 +9812,19 @@ function getValueByPath(value, dotPath) {
9070
9812
  }
9071
9813
  return current;
9072
9814
  }
9073
- function splitJsonlLines(stdout) {
9074
- 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));
9075
9828
  }
9076
9829
  function normalizeErrorMessage(value) {
9077
9830
  if (typeof value === "string" && value.trim()) {
@@ -9097,8 +9850,8 @@ function findJsonlSelection(entries, selector) {
9097
9850
  }
9098
9851
  return void 0;
9099
9852
  }
9100
- function parseGenericJsonCli(stdout, parser) {
9101
- const payload = JSON.parse(stdout);
9853
+ function parseGenericJsonCli(stdout, parser, context) {
9854
+ const payload = parseCliJson(stdout, context);
9102
9855
  const errorValue = parser.errorPath ? getValueByPath(payload, parser.errorPath) : void 0;
9103
9856
  const errorMessage = normalizeErrorMessage(errorValue);
9104
9857
  if (errorMessage) {
@@ -9119,8 +9872,8 @@ function parseGenericJsonCli(stdout, parser) {
9119
9872
  data: payload
9120
9873
  };
9121
9874
  }
9122
- function parseGenericJsonlCli(stdout, parser) {
9123
- const entries = splitJsonlLines(stdout);
9875
+ function parseGenericJsonlCli(stdout, parser, context) {
9876
+ const entries = splitJsonlLines(stdout, context);
9124
9877
  const errorValue = parser.error ? findJsonlSelection(entries, parser.error) : void 0;
9125
9878
  const errorMessage = normalizeErrorMessage(errorValue);
9126
9879
  if (errorMessage) {
@@ -9194,7 +9947,7 @@ function parseCliModelList(stdout, parser, selector) {
9194
9947
  );
9195
9948
  }
9196
9949
  const records = parser.kind === "jsonl" ? splitJsonlLines(stdout) : (() => {
9197
- const payload = JSON.parse(stdout);
9950
+ const payload = parseCliJson(stdout, "CLI discovery json parser");
9198
9951
  const arr = selector.path ? getValueByPath(payload, selector.path) : payload;
9199
9952
  return Array.isArray(arr) ? arr : [];
9200
9953
  })();
@@ -9207,8 +9960,8 @@ function parseCliModelList(stdout, parser, selector) {
9207
9960
  }
9208
9961
  return models;
9209
9962
  }
9210
- function parseClaudeCli(stdout) {
9211
- const payload = JSON.parse(stdout);
9963
+ function parseClaudeCli(stdout, context) {
9964
+ const payload = parseCliJson(stdout, context);
9212
9965
  const text = typeof payload.result === "string" ? payload.result : typeof payload.response === "string" ? payload.response : typeof payload.text === "string" ? payload.text : void 0;
9213
9966
  if (!text) {
9214
9967
  throw new AiConnectError(
@@ -9223,8 +9976,8 @@ function parseClaudeCli(stdout) {
9223
9976
  data: payload
9224
9977
  };
9225
9978
  }
9226
- function parseCodexCli(stdout, outputFileContent) {
9227
- const events = splitJsonlLines(stdout);
9979
+ function parseCodexCli(stdout, outputFileContent, context) {
9980
+ const events = splitJsonlLines(stdout, context);
9228
9981
  let text = outputFileContent?.trim() || void 0;
9229
9982
  let usage;
9230
9983
  for (const event of events) {
@@ -9274,6 +10027,7 @@ function parseCliResult(route, result, outputFileContent) {
9274
10027
  );
9275
10028
  }
9276
10029
  const cliOptions = resolveCliTransportOptions(route);
10030
+ const parseContext = `CLI transport "${route.transport.id}"${cliOptions.preset ? ` (preset ${cliOptions.preset})` : ""}`;
9277
10031
  if (route.transport.cli?.parser) {
9278
10032
  const parser = cliOptions.parser;
9279
10033
  if (!parser) {
@@ -9286,9 +10040,9 @@ function parseCliResult(route, result, outputFileContent) {
9286
10040
  case "text":
9287
10041
  return parseTextCli(result.stdout, parser);
9288
10042
  case "json":
9289
- return parseGenericJsonCli(stdout, parser);
10043
+ return parseGenericJsonCli(stdout, parser, parseContext);
9290
10044
  default:
9291
- return parseGenericJsonlCli(stdout, parser);
10045
+ return parseGenericJsonlCli(stdout, parser, parseContext);
9292
10046
  }
9293
10047
  }
9294
10048
  switch (cliOptions.preset) {
@@ -9296,9 +10050,9 @@ function parseCliResult(route, result, outputFileContent) {
9296
10050
  return parseTextCli(stdout, { kind: "text" });
9297
10051
  case "claude":
9298
10052
  case "openclaude":
9299
- return parseClaudeCli(stdout);
10053
+ return parseClaudeCli(stdout, parseContext);
9300
10054
  case "codex":
9301
- return parseCodexCli(stdout, outputFileContent);
10055
+ return parseCodexCli(stdout, outputFileContent, parseContext);
9302
10056
  default:
9303
10057
  throw new AiConnectError(
9304
10058
  "not_supported",
@@ -9312,29 +10066,27 @@ function capturePhase(phases, name, startedAt) {
9312
10066
  durationMs: Date.now() - startedAt
9313
10067
  });
9314
10068
  }
10069
+ var MAX_CLI_OUTPUT_CHARS = 64 * 1024 * 1024;
9315
10070
  async function executeCliInvocation(invocation, timeoutMs, phases, signal) {
9316
10071
  const spawnStartedAt = Date.now();
10072
+ const detached = process.platform !== "win32";
9317
10073
  const child = spawn2(invocation.command, invocation.args, {
9318
10074
  cwd: invocation.cwd,
9319
10075
  env: invocation.env,
9320
- stdio: ["ignore", "pipe", "pipe"]
10076
+ stdio: ["ignore", "pipe", "pipe"],
10077
+ detached
9321
10078
  });
9322
10079
  capturePhase(phases, "spawn", spawnStartedAt);
9323
10080
  let stdout = "";
9324
10081
  let stderr = "";
9325
10082
  child.stdout.setEncoding("utf8");
9326
- child.stdout.on("data", (chunk) => {
9327
- stdout += chunk;
9328
- });
9329
10083
  child.stderr.setEncoding("utf8");
9330
- child.stderr.on("data", (chunk) => {
9331
- stderr += chunk;
9332
- });
9333
10084
  const executeStartedAt = Date.now();
9334
10085
  return await new Promise((resolve, reject) => {
9335
10086
  let abortListener;
10087
+ let overflowed = false;
9336
10088
  const timer = setTimeout(() => {
9337
- child.kill("SIGKILL");
10089
+ killProcessTree(child, "SIGKILL", detached);
9338
10090
  reject(
9339
10091
  new AiConnectError(
9340
10092
  "temporary_unavailable",
@@ -9349,15 +10101,43 @@ async function executeCliInvocation(invocation, timeoutMs, phases, signal) {
9349
10101
  abortListener = void 0;
9350
10102
  }
9351
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
+ });
9352
10132
  if (signal) {
9353
10133
  if (signal.aborted) {
9354
- child.kill("SIGKILL");
10134
+ killProcessTree(child, "SIGKILL", detached);
9355
10135
  cleanup();
9356
10136
  reject(new AiConnectError("aborted", "CLI transport aborted before execution."));
9357
10137
  return;
9358
10138
  }
9359
10139
  abortListener = () => {
9360
- child.kill("SIGKILL");
10140
+ killProcessTree(child, "SIGKILL", detached);
9361
10141
  cleanup();
9362
10142
  reject(new AiConnectError("aborted", "CLI transport aborted."));
9363
10143
  };
@@ -9373,6 +10153,9 @@ async function executeCliInvocation(invocation, timeoutMs, phases, signal) {
9373
10153
  );
9374
10154
  });
9375
10155
  child.once("close", (exitCode) => {
10156
+ if (overflowed) {
10157
+ return;
10158
+ }
9376
10159
  cleanup();
9377
10160
  capturePhase(phases, "execute", executeStartedAt);
9378
10161
  resolve({
@@ -9476,7 +10259,10 @@ function createCliTransportManager(options) {
9476
10259
  )
9477
10260
  );
9478
10261
  } finally {
9479
- await cleanupCliInvocation(invocation);
10262
+ try {
10263
+ await cleanupCliInvocation(invocation);
10264
+ } catch {
10265
+ }
9480
10266
  }
9481
10267
  },
9482
10268
  async runPrompt(context) {
@@ -9516,7 +10302,10 @@ function createCliTransportManager(options) {
9516
10302
  }
9517
10303
  return result;
9518
10304
  } finally {
9519
- await cleanupCliInvocation(invocation);
10305
+ try {
10306
+ await cleanupCliInvocation(invocation);
10307
+ } catch {
10308
+ }
9520
10309
  }
9521
10310
  }
9522
10311
  };
@@ -9543,28 +10332,21 @@ function splitCommandLine3(commandLine) {
9543
10332
  };
9544
10333
  }
9545
10334
  function resolveServerCommand(route, options) {
9546
- if (route.transport.command?.trim()) {
9547
- return route.transport.command.trim();
9548
- }
9549
- const candidates = [
9550
- `${route.provider}:${route.transport.id}`,
9551
- route.transport.id,
9552
- route.provider
9553
- ];
9554
- for (const candidate of candidates) {
9555
- const override = options?.commands?.[candidate]?.trim();
9556
- if (override) {
9557
- return override;
9558
- }
9559
- }
9560
- const preset = AI_CONNECT_DEFAULT_SERVER_COMMANDS[`${route.provider}:${route.transport.id}`];
9561
- if (preset) {
9562
- 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
+ );
9563
10349
  }
9564
- throw new AiConnectError(
9565
- "validation_error",
9566
- `No server command preset is registered for route "${route.id}". Provide transport.command or server.commands.`
9567
- );
9568
10350
  }
9569
10351
  function resolveServerExecutable(route, options) {
9570
10352
  return splitCommandLine3(resolveServerCommand(route, options)).command;
@@ -9636,9 +10418,12 @@ async function findAvailablePort(host = "127.0.0.1") {
9636
10418
  async function waitForServerHealth(fetchLike, origin, timeoutMs) {
9637
10419
  const deadline = Date.now() + timeoutMs;
9638
10420
  let lastError;
10421
+ const perAttemptTimeoutMs = Math.min(2e3, Math.max(250, timeoutMs));
9639
10422
  while (Date.now() < deadline) {
9640
10423
  try {
9641
- const response = await fetchLike(joinUrl(origin, "/global/health"));
10424
+ const response = await fetchLike(joinUrl(origin, "/global/health"), {
10425
+ signal: AbortSignal.timeout(perAttemptTimeoutMs)
10426
+ });
9642
10427
  if (response.ok) {
9643
10428
  return;
9644
10429
  }
@@ -9668,14 +10453,14 @@ async function spawnServerInstance(route, options, phases, cwdOverride) {
9668
10453
  }
9669
10454
  args.push("--hostname", host, "--port", String(port));
9670
10455
  const spawnStartedAt = Date.now();
10456
+ const detached = process.platform !== "win32";
9671
10457
  const child = spawn3(resolved.command, args, {
9672
10458
  cwd: cwdOverride ?? options?.cwd ?? process.cwd(),
9673
- env: {
9674
- ...process.env,
9675
- ...options?.env ?? {}
9676
- },
9677
- stdio: ["ignore", "pipe", "pipe"]
10459
+ env: resolveChildEnv({ extra: options?.env ?? {} }),
10460
+ stdio: ["ignore", "pipe", "pipe"],
10461
+ detached
9678
10462
  });
10463
+ const untrack = trackChild(child, detached);
9679
10464
  let stderr = "";
9680
10465
  child.stderr.setEncoding("utf8");
9681
10466
  child.stderr.on("data", (chunk) => {
@@ -9691,7 +10476,8 @@ async function spawnServerInstance(route, options, phases, cwdOverride) {
9691
10476
  );
9692
10477
  capturePhase2(phases, "health", healthStartedAt);
9693
10478
  } catch (error) {
9694
- child.kill("SIGKILL");
10479
+ untrack();
10480
+ killProcessTree(child, "SIGKILL", detached);
9695
10481
  throw new AiConnectError(
9696
10482
  "local_harness_unavailable",
9697
10483
  stderr.trim() || (error instanceof Error ? error.message : "Failed to start local server transport.")
@@ -9699,15 +10485,17 @@ async function spawnServerInstance(route, options, phases, cwdOverride) {
9699
10485
  }
9700
10486
  return {
9701
10487
  origin,
10488
+ child,
9702
10489
  dispose: async () => {
10490
+ untrack();
9703
10491
  if (child.killed) {
9704
10492
  return;
9705
10493
  }
9706
- child.kill("SIGTERM");
10494
+ killProcessTree(child, "SIGTERM", detached);
9707
10495
  await new Promise((resolve) => {
9708
10496
  child.once("close", () => resolve());
9709
10497
  setTimeout(() => {
9710
- child.kill("SIGKILL");
10498
+ killProcessTree(child, "SIGKILL", detached);
9711
10499
  resolve();
9712
10500
  }, 1e3).unref?.();
9713
10501
  });
@@ -9807,7 +10595,17 @@ function createServerTransportManager(options) {
9807
10595
  const pending = spawnServerInstance(route, options, phases, cwdOverride);
9808
10596
  instances.set(key, pending);
9809
10597
  try {
9810
- 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;
9811
10609
  } catch (error) {
9812
10610
  instances.delete(key);
9813
10611
  throw error;
@@ -9837,7 +10635,10 @@ function createServerTransportManager(options) {
9837
10635
  headers: {
9838
10636
  "content-type": "application/json"
9839
10637
  },
9840
- 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
9841
10642
  });
9842
10643
  capturePhase2(phases, "session/create", sessionStartedAt);
9843
10644
  if (!sessionResponse.ok) {
@@ -9906,7 +10707,10 @@ function createServerTransportManager(options) {
9906
10707
  };
9907
10708
  context.telemetry?.captureTransport(transport);
9908
10709
  const startedAt = Date.now();
9909
- 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
+ );
9910
10714
  capturePhase2(phases, "config/providers", startedAt);
9911
10715
  if (!response.ok) {
9912
10716
  throw new AiConnectError(
@@ -9950,6 +10754,15 @@ function createServerTransportManager(options) {
9950
10754
  }
9951
10755
 
9952
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
+ }
9953
10766
  function createLocalRouteHandlers(options = {}) {
9954
10767
  const acpTransport = createAcpTransportManager(options.acp);
9955
10768
  const cliTransport = createCliTransportManager(options.cli);
@@ -9980,16 +10793,11 @@ function createLocalRouteHandlers(options = {}) {
9980
10793
  }
9981
10794
  ];
9982
10795
  } catch (error) {
9983
- const normalized = error instanceof AiConnectError ? error : new AiConnectError(
10796
+ return normalizeVerifyIssue(
10797
+ error,
9984
10798
  "validation_error",
9985
10799
  "ACP route verification failed."
9986
10800
  );
9987
- return [
9988
- {
9989
- code: "handler_issue",
9990
- message: normalized.message
9991
- }
9992
- ];
9993
10801
  }
9994
10802
  }
9995
10803
  },
@@ -10028,16 +10836,11 @@ function createLocalRouteHandlers(options = {}) {
10028
10836
  }
10029
10837
  ];
10030
10838
  } catch (error) {
10031
- const normalized = error instanceof AiConnectError ? error : new AiConnectError(
10839
+ return normalizeVerifyIssue(
10840
+ error,
10032
10841
  "validation_error",
10033
10842
  "CLI route verification failed."
10034
10843
  );
10035
- return [
10036
- {
10037
- code: "handler_issue",
10038
- message: normalized.message
10039
- }
10040
- ];
10041
10844
  }
10042
10845
  }
10043
10846
  },
@@ -10073,16 +10876,11 @@ function createLocalRouteHandlers(options = {}) {
10073
10876
  }
10074
10877
  ];
10075
10878
  } catch (error) {
10076
- const normalized = error instanceof AiConnectError ? error : new AiConnectError(
10879
+ return normalizeVerifyIssue(
10880
+ error,
10077
10881
  "validation_error",
10078
10882
  "Server route verification failed."
10079
10883
  );
10080
- return [
10081
- {
10082
- code: "handler_issue",
10083
- message: normalized.message
10084
- }
10085
- ];
10086
10884
  }
10087
10885
  }
10088
10886
  }