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