@runuai/host 0.9.170 → 0.9.172

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.
@@ -65,6 +65,14 @@
65
65
  * transition the authority does. Should saying so fail, the
66
66
  * runner is pending again, withdraws what it advertised, and says so later.
67
67
  *
68
+ * What stops the switch is said to the cloud as well as logged (`status`,
69
+ * on every capability frame; `onStatusChange` when it changes): the latest
70
+ * refusal or blocker, who gave it (the cloud's answer, or this runner), each
71
+ * blocker apart and every Compose project the retirement could not prove
72
+ * Uai's. A transient one (the container runtime still preparing after a
73
+ * reconnect, an unanswered ask) never replaces a substantive one: an
74
+ * operator reading Connections sees what to fix, not what fixes itself.
75
+ *
68
76
  * The record holds no secret: `{"schemaVersion":1,"step":"local",
69
77
  * "stopped":[{"taskId":"…","runEpoch":3}]}`. One the runner cannot read
70
78
  * counts as `local`, never as `legacy`: a damaged record may stand for a
@@ -88,6 +96,14 @@ import {
88
96
  import { dirname } from "node:path";
89
97
 
90
98
  import type { CredentialsActivationPhase } from "../src/cloud-credentials";
99
+ import {
100
+ MAX_CREDENTIALS_SWITCH_BLOCKER_CHARS,
101
+ MAX_CREDENTIALS_SWITCH_BLOCKERS,
102
+ MAX_CREDENTIALS_SWITCH_DETAIL_CHARS,
103
+ MAX_CREDENTIALS_SWITCH_PROJECTS,
104
+ type CredentialsSwitchCapability,
105
+ type CredentialsSwitchStuck,
106
+ } from "../src/protocol";
91
107
  import type { CredentialsMode } from "./cloud-credential-host";
92
108
  import type { RetirementPlan } from "./legacy-runtime-retirement";
93
109
  import { isSafeHostTaskId } from "./task-identity";
@@ -167,6 +183,12 @@ export interface ActivationAuthorityDeps<Retirement = RetirementPlan> {
167
183
  /** Ask the runner's supervisor for a restart. */
168
184
  restart: (why: string) => void;
169
185
  log: (line: string) => void;
186
+ /** `status()` changed: advertise it again. */
187
+ onStatusChange?: () => void;
188
+ /** What `status()` says of a blocker, made fit to leave the machine (the
189
+ * home directory as `~`). */
190
+ redact?: (text: string) => string;
191
+ now?: () => number;
170
192
  }
171
193
 
172
194
  export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
@@ -191,6 +213,14 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
191
213
  #lastNote: string | null = null;
192
214
  /** Leaving the runner's own credentials failed in this process. */
193
215
  #stuck = false;
216
+ /** What stops the switch now, as `status()` says it. */
217
+ #blocker: CredentialsSwitchStuck | null = null;
218
+ /** Every Compose project the current blocker names, past the status's
219
+ * bound too: what the log lists, and what tells one blocker from another
220
+ * when only names past that bound changed. */
221
+ #blockerProjects = "";
222
+ /** `status()` as last told to `onStatusChange`'s caller. */
223
+ #published: string | null = null;
194
224
 
195
225
  constructor(deps: ActivationAuthorityDeps<Retirement>) {
196
226
  this.#deps = deps;
@@ -218,6 +248,13 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
218
248
  return this.#record?.step ?? null;
219
249
  }
220
250
 
251
+ /** Where the switch stands, for the capability frame (see the header). */
252
+ status(): CredentialsSwitchCapability {
253
+ const state = this.mode();
254
+ const stuck = state === "active" ? null : this.#blocker;
255
+ return stuck === null ? { state } : { state, stuck: { ...stuck } };
256
+ }
257
+
221
258
  mode(): CredentialsMode {
222
259
  if (this.#record === null) {
223
260
  return this.#initiallyPending ? "pending" : "legacy";
@@ -249,9 +286,12 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
249
286
  advance(): Promise<void> {
250
287
  if (this.#advancing === null) {
251
288
  this.#advancing = this.#advanceOnce()
252
- .catch((error: unknown) => this.#note(`activation stopped: ${message(error)}; it tries again later`))
289
+ .catch((error: unknown) =>
290
+ this.#block("runner", error, `activation stopped: ${message(error)}; it tries again later`),
291
+ )
253
292
  .finally(() => {
254
293
  this.#advancing = null;
294
+ this.#publish();
255
295
  });
256
296
  }
257
297
  return this.#advancing;
@@ -266,10 +306,13 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
266
306
  }
267
307
  let plan: Retirement | null = null;
268
308
  if (this.#record === null) {
269
- if (!this.#deps.eligible()) return;
270
- const prepared = await this.#deps.ask("prepare");
309
+ if (!this.#deps.eligible()) {
310
+ this.#blocker = null;
311
+ return;
312
+ }
313
+ const prepared = await this.#ask("prepare");
271
314
  if (!prepared.ok) {
272
- this.#note(`the cloud did not agree to cloud credentials yet: ${prepared.error}`);
315
+ this.#block("cloud", prepared.error, `the cloud did not agree to cloud credentials yet: ${prepared.error}`);
273
316
  return;
274
317
  }
275
318
  const paused = this.#deps.pauseLegacyStarts();
@@ -287,7 +330,9 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
287
330
  this.#record ??= readActivationRecord(this.#deps.recordPath);
288
331
  if (this.#record === null) {
289
332
  paused.resume();
290
- this.#note(
333
+ this.#block(
334
+ "runner",
335
+ error,
291
336
  this.#initiallyPending
292
337
  ? `the v1-required task machine stays pending, with no v0 fallback: ${message(error)}`
293
338
  : `the runner stays on its own credentials, nothing changed: ${message(error)}`,
@@ -295,6 +340,7 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
295
340
  return;
296
341
  }
297
342
  }
343
+ this.#blocker = null;
298
344
  this.#deps.log("[credentials] switching to cloud credentials: leaving the runner's own");
299
345
  }
300
346
  if (!this.#synced()) return;
@@ -319,11 +365,16 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
319
365
  plan ??= await this.#deps.planRetirement();
320
366
  await this.#retire(plan);
321
367
  await this.#deps.claimFloor();
322
- const committed = await this.#deps.ask("commit");
368
+ const committed = await this.#ask("commit");
323
369
  if (!committed.ok) {
324
- this.#note(`the cloud did not record this runner's switch yet: ${committed.error}; it runs no task meanwhile`);
370
+ this.#block(
371
+ "cloud",
372
+ committed.error,
373
+ `the cloud did not record this runner's switch yet: ${committed.error}; it runs no task meanwhile`,
374
+ );
325
375
  return;
326
376
  }
377
+ this.#blocker = null;
327
378
  this.#write({ ...this.#record, step: "cloud" });
328
379
  }
329
380
  if (this.#record?.step === "cloud") {
@@ -350,7 +401,9 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
350
401
  this.#write(record);
351
402
  }
352
403
  } catch (error) {
353
- this.#note(
404
+ this.#block(
405
+ "runner",
406
+ error,
354
407
  `the activation record is not known to be on disk yet (${message(error)}): the switch waits, running no task, ` +
355
408
  "until it is",
356
409
  );
@@ -373,7 +426,9 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
373
426
  try {
374
427
  await this.#deps.sweep();
375
428
  } catch (error) {
376
- this.#note(
429
+ this.#block(
430
+ "runner",
431
+ error,
377
432
  `the recovery sweep failed (${message(error)}): the runner stays pending, running no task, and sweeps again later`,
378
433
  );
379
434
  return;
@@ -397,10 +452,15 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
397
452
  }
398
453
  if (failure === null) {
399
454
  this.#announced = true;
455
+ this.#blocker = null;
400
456
  this.#deps.log("[credentials] tasks run on cloud credentials");
401
457
  return;
402
458
  }
403
- this.#note(`saying the runner is active failed (${message(failure)}): it is pending again, and tries again later`);
459
+ this.#block(
460
+ "runner",
461
+ failure,
462
+ `saying the runner is active failed (${message(failure)}): it is pending again, and tries again later`,
463
+ );
404
464
  try {
405
465
  this.#deps.onActiveWithdrawn();
406
466
  } catch (error) {
@@ -441,6 +501,67 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
441
501
  this.#durable = true;
442
502
  }
443
503
 
504
+ /** One phase asked of the cloud. An ask that could not be made or was not
505
+ * answered says nothing about the switch: transient. */
506
+ async #ask(phase: CredentialsActivationPhase): Promise<ActivationAnswer> {
507
+ try {
508
+ return await this.#deps.ask(phase);
509
+ } catch (error) {
510
+ throw new TransientActivationError(message(error));
511
+ }
512
+ }
513
+
514
+ /** A refusal or blocker: logged, and what `status()` says unless it is
515
+ * transient and a substantive one is already said (see the header). */
516
+ #block(by: CredentialsSwitchStuck["by"], why: unknown, line: string): void {
517
+ this.#note(line);
518
+ const transient = isTransient(why);
519
+ if (transient && this.#blocker !== null && this.#blocker.transient !== true) return;
520
+ const redact = this.#deps.redact ?? ((text: string) => text);
521
+ const detail = bounded(redact(oneLine(typeof why === "string" ? why : message(why))), MAX_CREDENTIALS_SWITCH_DETAIL_CHARS);
522
+ const next: CredentialsSwitchStuck = { by, detail, since: (this.#deps.now ?? Date.now)() };
523
+ const structured = why as { blockers?: unknown; unprovenProjects?: unknown } | null;
524
+ const blockers = stringList(structured?.blockers);
525
+ if (blockers.length > 0) {
526
+ next.blockers = blockers
527
+ .slice(0, MAX_CREDENTIALS_SWITCH_BLOCKERS)
528
+ .map((blocker) => bounded(redact(oneLine(blocker)), MAX_CREDENTIALS_SWITCH_BLOCKER_CHARS));
529
+ }
530
+ const projects = stringList(structured?.unprovenProjects);
531
+ if (projects.length > 0) {
532
+ next.composeProjects = { names: projects.slice(0, MAX_CREDENTIALS_SWITCH_PROJECTS), total: projects.length };
533
+ }
534
+ if (transient) next.transient = true;
535
+ const previous = this.#blocker;
536
+ const allProjects = projects.join(" ");
537
+ // The same blocker again keeps when it was first seen. "The same" counts
538
+ // every project, not only the ones the status has room for.
539
+ if (previous !== null && sameBlocker(previous, next) && allProjects === this.#blockerProjects) return;
540
+ this.#blocker = next;
541
+ this.#blockerProjects = allProjects;
542
+ // The status names at most MAX_CREDENTIALS_SWITCH_PROJECTS, and the
543
+ // line above is cut short: the log names every one, once per blocker,
544
+ // for the operator Connections sends here for the rest.
545
+ if (projects.length > 0) {
546
+ this.#deps.log(
547
+ `[credentials] Compose projects nothing proves Uai's (${projects.length}), each to remove ` +
548
+ `(docker compose -p <name> down) or rename: ${allProjects}`,
549
+ );
550
+ }
551
+ }
552
+
553
+ /** `onStatusChange` when `status()` says something new. */
554
+ #publish(): void {
555
+ const now = JSON.stringify(this.status());
556
+ if (now === this.#published) return;
557
+ this.#published = now;
558
+ try {
559
+ this.#deps.onStatusChange?.();
560
+ } catch (error) {
561
+ this.#note(`advertising the switch's status failed: ${message(error)}`);
562
+ }
563
+ }
564
+
444
565
  /** The same note twice in a row is logged once: a refusal is asked about
445
566
  * again every few minutes. */
446
567
  #note(line: string): void {
@@ -450,6 +571,33 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
450
571
  }
451
572
  }
452
573
 
574
+ class TransientActivationError extends Error {
575
+ readonly transient = true;
576
+ }
577
+
578
+ function isTransient(why: unknown): boolean {
579
+ return why !== null && typeof why === "object" && (why as { transient?: unknown }).transient === true;
580
+ }
581
+
582
+ function sameBlocker(a: CredentialsSwitchStuck, b: CredentialsSwitchStuck): boolean {
583
+ const { since: _a, ...left } = a;
584
+ const { since: _b, ...right } = b;
585
+ return JSON.stringify(left) === JSON.stringify(right);
586
+ }
587
+
588
+ function stringList(value: unknown): string[] {
589
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item !== "") : [];
590
+ }
591
+
592
+ function oneLine(text: string): string {
593
+ return text.replace(/\s*\n\s*(?:- )?/g, "; ").trim();
594
+ }
595
+
596
+ function bounded(text: string, max: number): string {
597
+ const nonEmpty = text === "" ? "unknown" : text;
598
+ return nonEmpty.length <= max ? nonEmpty : `${nonEmpty.slice(0, max - 1)}…`;
599
+ }
600
+
453
601
  /** The record; null when there is none. One the runner cannot read is
454
602
  * `local` with nothing owed (see the header). */
455
603
  export function readActivationRecord(path: string): ActivationRecord | null {
@@ -0,0 +1,395 @@
1
+ /** ADR-164 sign-in-box discovery and protocol admission. Provider response
2
+ * bodies and error text never leave this module. All production I/O goes
3
+ * through the DNS-pinned safeRequest transport. */
4
+ import {
5
+ customConnectionEnabled,
6
+ validConnectionModels,
7
+ parseCustomConnectionMaterial,
8
+ parseEngineConnectCustomDiscoverFrame,
9
+ parseEngineConnectCustomSaveFrame,
10
+ parseEngineConnectCustomManualSaveFrame,
11
+ type EngineConnectCustomManualSaveFrame,
12
+ type CustomConnectionAuthMode,
13
+ type CustomConnectionDiscoverResult,
14
+ type CustomConnectionMaterial,
15
+ type CustomConnectionReason,
16
+ type CustomConnectionSaveResult,
17
+ type EngineConnectCustomDiscoverFrame,
18
+ type EngineConnectCustomSaveFrame,
19
+ } from "../src/custom-connections";
20
+ import { buildEngineCapture, parseCaptureFlow, writeCapture } from "./capture-flow";
21
+ import type { CredentialStore } from "./credential-store";
22
+ import { readOpenRouterBudget } from "./openrouter-budget";
23
+ import { readSafeBody, safeRequest, SafeHttpRefusal, type SafeHttpRequest, type SafeHttpResponse } from "./safe-http";
24
+
25
+ export const CUSTOM_CONNECTION_DISCOVERY_MAX_BYTES = 1024 * 1024;
26
+ export const CUSTOM_CONNECTION_DISCOVERY_MAX_PAGES = 5;
27
+ export const CUSTOM_CONNECTION_DISCOVERY_MAX_MODELS = 2000;
28
+ export const CUSTOM_CONNECTION_ATTEMPT_MS = 20_000;
29
+ const PROBE_MAX_BYTES = 1024 * 1024;
30
+
31
+ export interface CustomConnectionCaptureOptions {
32
+ /** Tests may replace the transport. Production always defaults safeRequest. */
33
+ request?: typeof safeRequest;
34
+ signal?: AbortSignal;
35
+ now?: () => number;
36
+ }
37
+
38
+ class ConnectionFailure extends Error {
39
+ constructor(readonly reason: CustomConnectionReason, readonly retryable = reason === "timeout") {
40
+ super(reason);
41
+ }
42
+ }
43
+
44
+ function object(value: unknown): Record<string, unknown> | null {
45
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
46
+ }
47
+
48
+ function failure(error: unknown, signal?: AbortSignal): ConnectionFailure {
49
+ if (error instanceof ConnectionFailure) return error;
50
+ if (signal?.aborted || (error instanceof SafeHttpRefusal && error.reason === "timeout")) return new ConnectionFailure("timeout", true);
51
+ if (error instanceof SafeHttpRefusal) {
52
+ if (error.reason === "non_public_address") return new ConnectionFailure("private-address");
53
+ if (["url", "request_too_large", "response_too_large"].includes(error.reason)) return new ConnectionFailure("wrong-protocol");
54
+ }
55
+ return new ConnectionFailure("unreachable");
56
+ }
57
+
58
+ function statusFailure(status: number): ConnectionFailure {
59
+ if (status === 401) return new ConnectionFailure("unauthorized");
60
+ if (status === 403) return new ConnectionFailure("forbidden");
61
+ if (status === 404) return new ConnectionFailure("model-not-found");
62
+ if (status === 429) return new ConnectionFailure("rate-limited");
63
+ if (status >= 500 && status <= 599) return new ConnectionFailure("upstream-error", true);
64
+ return new ConnectionFailure("wrong-protocol");
65
+ }
66
+
67
+ function headers(settings: CustomConnectionMaterial, auth: CustomConnectionAuthMode = settings.authMode): Record<string, string> {
68
+ return {
69
+ ...(auth === "bearer" ? { authorization: `Bearer ${settings.apiKey}` } : { "x-api-key": settings.apiKey }),
70
+ ...(settings.engine === "claude" && settings.providerType !== "bedrock" ? { "anthropic-version": "2023-06-01" } : {}),
71
+ };
72
+ }
73
+
74
+ function deadline(options: CustomConnectionCaptureOptions): AbortSignal {
75
+ const timeout = AbortSignal.timeout(CUSTOM_CONNECTION_ATTEMPT_MS);
76
+ return options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
77
+ }
78
+
79
+ async function exchange(request: SafeHttpRequest, options: CustomConnectionCaptureOptions, signal: AbortSignal): Promise<SafeHttpResponse> {
80
+ try {
81
+ return await (options.request ?? safeRequest)({ ...request, signal }, {
82
+ connectMs: CUSTOM_CONNECTION_ATTEMPT_MS,
83
+ headersMs: CUSTOM_CONNECTION_ATTEMPT_MS,
84
+ idleMs: CUSTOM_CONNECTION_ATTEMPT_MS,
85
+ maxRequestBodyBytes: 16 * 1024,
86
+ });
87
+ } catch (error) {
88
+ throw failure(error, signal);
89
+ }
90
+ }
91
+
92
+ async function body(response: SafeHttpResponse, maxBytes: number, signal: AbortSignal): Promise<Buffer> {
93
+ try { return await readSafeBody(response, maxBytes); } catch (error) { throw failure(error, signal); }
94
+ }
95
+
96
+ function decodeJson(raw: Buffer): unknown {
97
+ try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw)); } catch { throw new ConnectionFailure("wrong-protocol"); }
98
+ }
99
+
100
+ async function json(request: SafeHttpRequest, options: CustomConnectionCaptureOptions, signal: AbortSignal, maxBytes: number): Promise<{ value: Record<string, unknown>; bytes: number }> {
101
+ const response = await exchange(request, options, signal);
102
+ if (response.status !== 200) {
103
+ response.body.destroy();
104
+ throw statusFailure(response.status);
105
+ }
106
+ const raw = await body(response, maxBytes, signal);
107
+ const value = object(decodeJson(raw));
108
+ if (!value) throw new ConnectionFailure("wrong-protocol");
109
+ return { value, bytes: raw.byteLength };
110
+ }
111
+
112
+ function validated(settings: CustomConnectionMaterial, enabled: EngineConnectCustomDiscoverFrame["enabled"], now: number): CustomConnectionMaterial {
113
+ const parsed = parseCustomConnectionMaterial(settings);
114
+ if (!parsed || !customConnectionEnabled(parsed, enabled)) throw new ConnectionFailure("unsupported");
115
+ if (parsed.expiresAt !== undefined && parsed.expiresAt !== null && parsed.expiresAt <= now) throw new ConnectionFailure("expired");
116
+ return parsed;
117
+ }
118
+
119
+ function modelId(value: unknown, key: string): value is string {
120
+ return typeof value === "string" && value.length > 0 && value.length <= 200 && !/[\s\u0000-\u001f\u007f]/.test(value) && !value.includes(key);
121
+ }
122
+
123
+ /** Pagination never changes the destination or resource carrying the key. */
124
+ function nextPage(value: Record<string, unknown>, current: URL, initial: URL): URL | null {
125
+ if (value.has_more === true) {
126
+ if (typeof value.last_id !== "string" || !value.last_id || value.last_id.length > 512) throw new ConnectionFailure("wrong-protocol");
127
+ const next = new URL(current);
128
+ next.searchParams.set("after_id", value.last_id);
129
+ return next;
130
+ }
131
+ if (typeof value.nextToken === "string" && value.nextToken) {
132
+ if (value.nextToken.length > 4096) throw new ConnectionFailure("wrong-protocol");
133
+ const next = new URL(current);
134
+ next.searchParams.set("nextToken", value.nextToken);
135
+ return next;
136
+ }
137
+ const links = object(value.links);
138
+ if (links?.next !== undefined && links.next !== null) {
139
+ if (typeof links.next !== "string" || links.next.length > 4096) throw new ConnectionFailure("wrong-protocol");
140
+ const next = new URL(links.next, initial);
141
+ if (next.origin !== initial.origin || next.pathname !== initial.pathname || next.username || next.password || next.hash) throw new ConnectionFailure("wrong-protocol");
142
+ return next;
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /** Discover without writing a capture. A denied listing is a non-fatal UI
148
+ * outcome: the owner can enter model IDs and test them directly. */
149
+ export async function discoverCustomConnection(frame: EngineConnectCustomDiscoverFrame, options: CustomConnectionCaptureOptions = {}): Promise<CustomConnectionDiscoverResult> {
150
+ const signal = deadline(options);
151
+ try {
152
+ const parsedFrame = parseEngineConnectCustomDiscoverFrame(frame);
153
+ if (!parsedFrame || parsedFrame.settings.internal === true) throw new ConnectionFailure("unsupported");
154
+ const settings = validated(parsedFrame.settings, parsedFrame.enabled, (options.now ?? Date.now)());
155
+ if (settings.providerType === "bedrock" && settings.engine === "codex" && settings.transport === "runtime") return { ok: false, reason: "unsupported" };
156
+ const models = new Set<string>();
157
+ const streamingFoundationIds = new Set<string>();
158
+ let bytes = 0;
159
+ let pages = 0;
160
+ const bedrockClaude = settings.providerType === "bedrock" && settings.engine === "claude";
161
+ const roots = bedrockClaude
162
+ ? [`https://bedrock.${settings.region}.amazonaws.com/foundation-models`, `https://bedrock.${settings.region}.amazonaws.com/inference-profiles`]
163
+ : [settings.providerType === "bedrock" ? `https://bedrock-mantle.${settings.region}.api.aws/v1/models` : `${settings.baseUrl}${settings.engine === "claude" ? "/v1" : ""}/models`];
164
+ let truncated = false;
165
+ discovery: for (const root of roots) {
166
+ const initial = new URL(root);
167
+ if (settings.engine === "claude" && settings.providerType !== "bedrock") initial.searchParams.set("limit", "1000");
168
+ let url: URL | null = initial;
169
+ const seen = new Set<string>();
170
+ while (url !== null) {
171
+ if (pages >= CUSTOM_CONNECTION_DISCOVERY_MAX_PAGES) { truncated = true; break discovery; }
172
+ if (seen.has(url.href)) throw new ConnectionFailure("wrong-protocol");
173
+ seen.add(url.href);
174
+ pages++;
175
+ const result = await json({ url: url.href, headers: headers(settings) }, options, signal, CUSTOM_CONNECTION_DISCOVERY_MAX_BYTES - bytes);
176
+ bytes += result.bytes;
177
+ const list = bedrockClaude ? result.value[root.endsWith("foundation-models") ? "modelSummaries" : "inferenceProfileSummaries"] : result.value.data;
178
+ if (!Array.isArray(list)) throw new ConnectionFailure("wrong-protocol");
179
+ for (const entry of list) {
180
+ const row = object(entry);
181
+ if (!row) throw new ConnectionFailure("wrong-protocol");
182
+ const id = bedrockClaude ? row.modelId ?? row.inferenceProfileId : row.id;
183
+ if (!modelId(id, settings.apiKey)) throw new ConnectionFailure("invalid-models");
184
+ if (bedrockClaude) {
185
+ if (root.endsWith("foundation-models")) {
186
+ if (row.providerName !== "Anthropic" || row.responseStreamingSupported !== true) continue;
187
+ streamingFoundationIds.add(id);
188
+ } else {
189
+ if (!Array.isArray(row.models) || !row.models.length || !row.models.every(value => {
190
+ const arn = object(value)?.modelArn;
191
+ return typeof arn === "string" && streamingFoundationIds.has(arn.slice(arn.lastIndexOf("/") + 1));
192
+ })) continue;
193
+ if (row.status !== undefined && row.status !== "ACTIVE") continue;
194
+ }
195
+ } else if (settings.providerType === "bedrock") {
196
+ // Mantle lists several incompatible route families. Names alone
197
+ // are not proof that a model serves the /openai/v1 transport.
198
+ const paths = row.supported_api_paths ?? row.supportedApiPaths;
199
+ if (row.api_path !== "/openai/v1" && row.apiPath !== "/openai/v1" && !(Array.isArray(paths) && paths.includes("/openai/v1"))) continue;
200
+ }
201
+ if (!models.has(id) && models.size >= CUSTOM_CONNECTION_DISCOVERY_MAX_MODELS) {
202
+ truncated = true;
203
+ break discovery;
204
+ }
205
+ models.add(id);
206
+ }
207
+ url = nextPage(result.value, url, initial);
208
+ }
209
+ }
210
+ if (settings.providerType === "bedrock" && settings.transport === "mantle" && models.size === 0) return { ok: false, reason: "unsupported" };
211
+ return { ok: true, models: [...models], ...(truncated ? { truncated: true } : {}) };
212
+ } catch (error) {
213
+ return { ok: false, reason: failure(error, signal).reason };
214
+ }
215
+ }
216
+
217
+ function validMessages(value: unknown): boolean {
218
+ const message = object(value);
219
+ const usage = object(message?.usage);
220
+ return Boolean(message && message.type === "message" && message.role === "assistant" && typeof message.id === "string" && message.id.length > 0 && typeof message.model === "string" && Array.isArray(message.content) && usage && [usage.input_tokens, usage.output_tokens].every(n => Number.isSafeInteger(n) && (n as number) >= 0));
221
+ }
222
+
223
+ /** Responses terminals are protocol outcomes, not merely successful HTTP. */
224
+ export function validateResponsesProbe(raw: Buffer): void {
225
+ let text: string;
226
+ try { text = new TextDecoder("utf-8", { fatal: true }).decode(raw).replace(/\r\n/g, "\n"); } catch { throw new ConnectionFailure("wrong-protocol"); }
227
+ const chunks = text.split("\n\n");
228
+ const tail = chunks.pop();
229
+ if (tail?.trim()) throw new ConnectionFailure("wrong-protocol");
230
+ let terminal = false;
231
+ for (const chunk of chunks) {
232
+ let event: string | undefined;
233
+ const data: string[] = [];
234
+ for (const line of chunk.split("\n")) {
235
+ if (line.startsWith(":")) continue;
236
+ if (line.startsWith("event:")) event = line.slice(6).trim();
237
+ else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
238
+ else if (line && !line.startsWith("id:") && !line.startsWith("retry:")) throw new ConnectionFailure("wrong-protocol");
239
+ }
240
+ if (!data.length) continue;
241
+ if (data.join("\n") === "[DONE]") {
242
+ if (!terminal) throw new ConnectionFailure("wrong-protocol");
243
+ continue;
244
+ }
245
+ if (terminal) throw new ConnectionFailure("wrong-protocol");
246
+ const value = object(decodeJson(Buffer.from(data.join("\n"))));
247
+ if (!value || typeof value.type !== "string" || (event !== undefined && event !== value.type)) throw new ConnectionFailure("wrong-protocol");
248
+ if (value.type === "error" || value.type === "response.failed") throw new ConnectionFailure("upstream-error");
249
+ if (value.type === "response.completed") {
250
+ if (object(value.response)?.status !== "completed") throw new ConnectionFailure("wrong-protocol");
251
+ terminal = true;
252
+ } else if (value.type === "response.incomplete") {
253
+ const response = object(value.response);
254
+ if (response?.status !== "incomplete" || object(response.incomplete_details)?.reason !== "max_output_tokens") throw new ConnectionFailure("wrong-protocol");
255
+ terminal = true;
256
+ }
257
+ }
258
+ if (!terminal) throw new ConnectionFailure("wrong-protocol");
259
+ }
260
+
261
+ /** AWS EventStream has two CRC32 checks: prelude and the complete message. */
262
+ export function eventStreamCrc32(bytes: Uint8Array): number {
263
+ let crc = 0xffffffff;
264
+ for (const byte of bytes) {
265
+ crc ^= byte;
266
+ for (let i = 0; i < 8; i++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
267
+ }
268
+ return (crc ^ 0xffffffff) >>> 0;
269
+ }
270
+
271
+ export function validateBedrockProbe(raw: Buffer): void {
272
+ let offset = 0;
273
+ let started = false;
274
+ let finishedMessage = false;
275
+ let stopped = false;
276
+ while (offset < raw.length) {
277
+ if (raw.length - offset < 16) throw new ConnectionFailure("wrong-protocol");
278
+ const length = raw.readUInt32BE(offset);
279
+ const headersLength = raw.readUInt32BE(offset + 4);
280
+ if (length < 16 || length > PROBE_MAX_BYTES || headersLength > length - 16 || offset + length > raw.length || raw.readUInt32BE(offset + 8) !== eventStreamCrc32(raw.subarray(offset, offset + 8)) || raw.readUInt32BE(offset + length - 4) !== eventStreamCrc32(raw.subarray(offset, offset + length - 4))) throw new ConnectionFailure("wrong-protocol");
281
+ const headerEnd = offset + 12 + headersLength;
282
+ let cursor = offset + 12;
283
+ const parsedHeaders = new Map<string, string>();
284
+ while (cursor < headerEnd) {
285
+ const nameLength = raw[cursor++]!;
286
+ if (!nameLength || cursor + nameLength + 3 > headerEnd) throw new ConnectionFailure("wrong-protocol");
287
+ const name = raw.subarray(cursor, cursor + nameLength).toString("utf8");
288
+ cursor += nameLength;
289
+ if (raw[cursor++] !== 7) throw new ConnectionFailure("wrong-protocol");
290
+ const valueLength = raw.readUInt16BE(cursor);
291
+ cursor += 2;
292
+ if (cursor + valueLength > headerEnd || parsedHeaders.has(name)) throw new ConnectionFailure("wrong-protocol");
293
+ parsedHeaders.set(name, raw.subarray(cursor, cursor + valueLength).toString("utf8"));
294
+ cursor += valueLength;
295
+ }
296
+ if (parsedHeaders.get(":message-type") !== "event" || parsedHeaders.get(":event-type") !== "chunk") {
297
+ const type = parsedHeaders.get(":event-type") ?? parsedHeaders.get(":exception-type") ?? "";
298
+ throw new ConnectionFailure(/throttl/i.test(type) ? "rate-limited" : /timeout/i.test(type) ? "timeout" : "upstream-error");
299
+ }
300
+ const payload = object(decodeJson(raw.subarray(headerEnd, offset + length - 4)));
301
+ if (!payload || typeof payload.bytes !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(payload.bytes)) throw new ConnectionFailure("wrong-protocol");
302
+ const event = object(decodeJson(Buffer.from(payload.bytes, "base64")));
303
+ if (!event || typeof event.type !== "string" || stopped) throw new ConnectionFailure("wrong-protocol");
304
+ if (event.type === "error") throw new ConnectionFailure("upstream-error");
305
+ if (event.type === "message_start") {
306
+ if (started || !validMessages(event.message)) throw new ConnectionFailure("wrong-protocol");
307
+ started = true;
308
+ } else if (event.type === "message_stop") {
309
+ if (!started || !finishedMessage) throw new ConnectionFailure("wrong-protocol");
310
+ stopped = true;
311
+ } else if (event.type === "message_delta") {
312
+ if (!started || typeof object(event.delta)?.stop_reason !== "string" || !Number.isSafeInteger(object(event.usage)?.output_tokens)) throw new ConnectionFailure("wrong-protocol");
313
+ finishedMessage = true;
314
+ } else if (!started || !["content_block_start", "content_block_delta", "content_block_stop", "ping"].includes(event.type)) throw new ConnectionFailure("wrong-protocol");
315
+ offset += length;
316
+ }
317
+ if (!started || !stopped) throw new ConnectionFailure("wrong-protocol");
318
+ }
319
+
320
+ async function probe(settings: CustomConnectionMaterial, model: string, auth: CustomConnectionAuthMode, options: CustomConnectionCaptureOptions, allowFallback: boolean): Promise<CustomConnectionAuthMode> {
321
+ for (let attempt = 0; attempt < 2; attempt++) {
322
+ const signal = deadline(options);
323
+ try {
324
+ const bedrock = settings.providerType === "bedrock" && settings.engine === "claude";
325
+ const request: SafeHttpRequest = {
326
+ url: bedrock ? `${settings.baseUrl}/model/${encodeURIComponent(model)}/invoke-with-response-stream` : `${settings.baseUrl}${settings.engine === "claude" ? "/v1/messages" : "/responses"}`,
327
+ method: "POST",
328
+ headers: { ...headers(settings, auth), "content-type": "application/json", accept: bedrock ? "application/vnd.amazon.eventstream" : settings.engine === "codex" ? "text/event-stream" : "application/json" },
329
+ body: JSON.stringify(settings.engine === "codex" ? { model, input: "Hi", store: false, stream: true, max_output_tokens: 16 } : { ...(bedrock ? { anthropic_version: "bedrock-2023-05-31" } : { model }), messages: [{ role: "user", content: "Hi" }], max_tokens: 1 }),
330
+ };
331
+ let response = await exchange(request, options, signal);
332
+ let usedAuth = auth;
333
+ if (response.status === 401 && allowFallback && settings.providerType === "custom" && settings.engine === "claude" && auth === "bearer") {
334
+ response.body.destroy();
335
+ response = await exchange({ ...request, headers: { ...headers(settings, "x-api-key"), "content-type": "application/json", accept: "application/json" } }, options, signal);
336
+ usedAuth = "x-api-key";
337
+ }
338
+ if (response.status !== 200) { response.body.destroy(); throw statusFailure(response.status); }
339
+ const raw = await body(response, PROBE_MAX_BYTES, signal);
340
+ if (bedrock) validateBedrockProbe(raw);
341
+ else if (settings.engine === "codex") validateResponsesProbe(raw);
342
+ else if (!validMessages(decodeJson(raw))) throw new ConnectionFailure("wrong-protocol");
343
+ return usedAuth;
344
+ } catch (error) {
345
+ const refused = failure(error, signal);
346
+ if (attempt === 0 && refused.retryable && !options.signal?.aborted) continue;
347
+ throw refused;
348
+ }
349
+ }
350
+ throw new ConnectionFailure("upstream-error");
351
+ }
352
+
353
+ /** All selected models are probed before the single immutable write. */
354
+ export async function saveCustomConnection(frame: EngineConnectCustomSaveFrame, store: CredentialStore | null, options: CustomConnectionCaptureOptions = {}): Promise<CustomConnectionSaveResult> {
355
+ try {
356
+ if (!parseCustomConnectionMaterial(frame?.settings) || frame.settings.internal === true) throw new ConnectionFailure("unsupported");
357
+ const parsed = parseEngineConnectCustomSaveFrame(frame);
358
+ if (!parsed || !validConnectionModels(parsed.models, parsed.defaultModel, parsed.backgroundModel, parsed.settings.engine, parsed.settings.apiKey)) throw new ConnectionFailure("invalid-models");
359
+ const settings = validated(parsed.settings, parsed.enabled, (options.now ?? Date.now)());
360
+ const flow = parseCaptureFlow(parsed.flow);
361
+ if (!flow || !store?.putImmutable) throw new ConnectionFailure("unsupported");
362
+ // Refuse an envelope that will never fit before spending any inference
363
+ // tokens. The successful auth mode is checked again before persistence.
364
+ try { buildEngineCapture(flow, { authKind: "custom-connection", connection: settings }); } catch { throw new ConnectionFailure("unsupported"); }
365
+ const authMode = await probe(settings, parsed.defaultModel, "bearer", options, true);
366
+ if (parsed.backgroundModel && parsed.backgroundModel !== parsed.defaultModel) await probe(settings, parsed.backgroundModel, authMode, options, false);
367
+ const connection = { ...settings, authMode };
368
+ const budget = connection.providerType === "openrouter" ? await readOpenRouterBudget(connection.apiKey, options) : undefined;
369
+ validated(connection, parsed.enabled, (options.now ?? Date.now)());
370
+ let built;
371
+ try { built = buildEngineCapture(flow, { authKind: "custom-connection", connection }); } catch { throw new ConnectionFailure("unsupported"); }
372
+ if ("error" in built) throw new ConnectionFailure("unsupported");
373
+ const capture = await writeCapture(store, flow, built, { signal: options.signal });
374
+ return { ok: true, capture, authMode, ...(budget ? { budget } : {}) };
375
+ } catch (error) {
376
+ return { ok: false, reason: failure(error, options.signal).reason };
377
+ }
378
+ }
379
+
380
+ /** Internal saves deliberately have no transport or resolver path. The person
381
+ * selects Claude's header; the fenced write records exactly that choice. */
382
+ export async function saveCustomConnectionManual(frame: EngineConnectCustomManualSaveFrame, store: CredentialStore | null, options: Pick<CustomConnectionCaptureOptions, "signal" | "now"> = {}): Promise<CustomConnectionSaveResult> {
383
+ try {
384
+ const parsed = parseEngineConnectCustomManualSaveFrame(frame);
385
+ if (!parsed) throw new ConnectionFailure("unsupported");
386
+ if (!validConnectionModels(parsed.models, parsed.defaultModel, parsed.backgroundModel, parsed.settings.engine, parsed.settings.apiKey)) throw new ConnectionFailure("invalid-models");
387
+ const connection = validated(parsed.settings, parsed.enabled, (options.now ?? Date.now)());
388
+ const flow = parseCaptureFlow(parsed.flow);
389
+ if (!flow || !store?.putImmutable) throw new ConnectionFailure("unsupported");
390
+ const built = buildEngineCapture(flow, { authKind: "custom-connection", connection });
391
+ if ("error" in built) throw new ConnectionFailure("unsupported");
392
+ const capture = await writeCapture(store, flow, built, { signal: options.signal });
393
+ return { ok: true, capture, authMode: connection.authMode };
394
+ } catch (error) { return { ok: false, reason: failure(error, options.signal).reason }; }
395
+ }