@aexhq/sdk 0.37.3 → 0.37.4

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.
@@ -71,6 +71,17 @@ export interface CoordinatorStreamOptions {
71
71
  * watchdog → quiet runs may reconnect).
72
72
  */
73
73
  readonly pingIntervalMs?: number;
74
+ /**
75
+ * Event-quiet recheck window. A pong proves the SOCKET is alive, not the
76
+ * delivery pipeline behind it — a server-side subscription that died (reaped
77
+ * connection row, wedged fan-out) keeps answering pings while never delivering
78
+ * another event, so the idle watchdog alone would hang one frame short of the
79
+ * terminal forever. If no REAL event frame arrives within this many ms the
80
+ * client silently reconnects (resume from cursor) — the replay-on-connect path
81
+ * reads the event store directly, so a dead subscription self-heals. Default
82
+ * 90s. Set 0 to disable.
83
+ */
84
+ readonly eventQuietRecheckMs?: number;
74
85
  }
75
86
  export declare function streamCoordinatorEvents(opts: CoordinatorStreamOptions): AsyncGenerator<AexEvent, void, void>;
76
87
  /** Async-iterable filter — keep only events matching the predicate. */
@@ -41,6 +41,8 @@ const COORDINATOR_PING = "aex:ping";
41
41
  const DEFAULT_IDLE_TIMEOUT_MS = 45_000;
42
42
  /** Default client keep-alive ping cadence. */
43
43
  const DEFAULT_PING_INTERVAL_MS = 15_000;
44
+ /** Default event-quiet recheck window (a silent reconnect, so the cost of a false positive is small). */
45
+ const DEFAULT_EVENT_QUIET_RECHECK_MS = 90_000;
44
46
  export async function* streamCoordinatorEvents(opts) {
45
47
  const makeWs = opts.webSocketFactory ?? ((url) => new WebSocket(url));
46
48
  const isTerminal = opts.isTerminal ?? isTerminalType;
@@ -48,6 +50,7 @@ export async function* streamCoordinatorEvents(opts) {
48
50
  const maxReconnects = opts.maxReconnects ?? Number.POSITIVE_INFINITY;
49
51
  const idleTimeoutMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
50
52
  const pingIntervalMs = opts.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
53
+ const eventQuietRecheckMs = opts.eventQuietRecheckMs ?? DEFAULT_EVENT_QUIET_RECHECK_MS;
51
54
  let cursor = (opts.from ?? 0) - 1;
52
55
  let attempts = 0;
53
56
  let done = false;
@@ -70,6 +73,7 @@ export async function* streamCoordinatorEvents(opts) {
70
73
  };
71
74
  let idleTimer = null;
72
75
  let pingTimer = null;
76
+ let quietTimer = null;
73
77
  const stopTimers = () => {
74
78
  if (idleTimer !== null) {
75
79
  clearTimeout(idleTimer);
@@ -79,6 +83,10 @@ export async function* streamCoordinatorEvents(opts) {
79
83
  clearInterval(pingTimer);
80
84
  pingTimer = null;
81
85
  }
86
+ if (quietTimer !== null) {
87
+ clearTimeout(quietTimer);
88
+ quietTimer = null;
89
+ }
82
90
  };
83
91
  // Re-arm on every inbound frame. On expiry the socket is presumed half-open
84
92
  // → close it and let the loop fall through to reconnect (resume from cursor).
@@ -97,6 +105,23 @@ export async function* streamCoordinatorEvents(opts) {
97
105
  wake();
98
106
  }, idleTimeoutMs);
99
107
  };
108
+ // Re-arm only on REAL event frames. On expiry: silent reconnect (resume from
109
+ // cursor) — self-heals a dead server-side subscription a pong can't expose.
110
+ const armQuiet = () => {
111
+ if (eventQuietRecheckMs <= 0)
112
+ return;
113
+ if (quietTimer !== null)
114
+ clearTimeout(quietTimer);
115
+ quietTimer = setTimeout(() => {
116
+ quietTimer = null;
117
+ if (closed)
118
+ return;
119
+ closed = true;
120
+ disconnectReason = "quiet_recheck";
121
+ closeQuietly(ws);
122
+ wake();
123
+ }, eventQuietRecheckMs);
124
+ };
100
125
  ws.addEventListener("open", () => {
101
126
  armIdle();
102
127
  if (pingIntervalMs > 0 && typeof ws.send === "function") {
@@ -117,9 +142,12 @@ export async function* streamCoordinatorEvents(opts) {
117
142
  return;
118
143
  try {
119
144
  const evt = JSON.parse(data);
120
- if (typeof evt.sequence === "number" && evt.sequence > cursor) {
121
- queue.push(evt);
122
- wake();
145
+ if (typeof evt.sequence === "number") {
146
+ armQuiet(); // a real event frame proves the delivery pipeline, not just the socket
147
+ if (evt.sequence > cursor) {
148
+ queue.push(evt);
149
+ wake();
150
+ }
123
151
  }
124
152
  }
125
153
  catch {
@@ -152,8 +180,10 @@ export async function* streamCoordinatorEvents(opts) {
152
180
  };
153
181
  opts.signal?.addEventListener("abort", onAbort, { once: true });
154
182
  // Arm immediately: a connect that never reaches "open" (and fakes that
155
- // never emit it) must still time out rather than hang forever.
183
+ // never emit it) must still time out rather than hang forever. The quiet
184
+ // recheck arms here too so a socket fed only by pongs still recycles.
156
185
  armIdle();
186
+ armQuiet();
157
187
  try {
158
188
  while (true) {
159
189
  while (queue.length > 0) {
@@ -177,6 +207,12 @@ export async function* streamCoordinatorEvents(opts) {
177
207
  finally {
178
208
  stopTimers();
179
209
  opts.signal?.removeEventListener("abort", onAbort);
210
+ // A caller that `break`s the iterator triggers the generator's `return()`,
211
+ // which lands here with the socket still OPEN (the yield was suspended, no
212
+ // terminal/abort/transport-close ran). Close it so an early break never
213
+ // leaks a live WebSocket. Idempotent: the terminal/abort/reconnect paths
214
+ // have already closed it, and closeQuietly swallows a double close.
215
+ closeQuietly(ws);
180
216
  }
181
217
  if (done || opts.signal?.aborted)
182
218
  return;
@@ -188,7 +224,11 @@ export async function* streamCoordinatorEvents(opts) {
188
224
  console.warn(`[aex] event stream gave up after ${maxReconnects} reconnect attempt(s) (last: ${disconnectReason || "unknown"}); ended before a terminal event at seq ${cursor + 1}`);
189
225
  return;
190
226
  }
191
- console.warn(`[aex] event stream disconnected (${disconnectReason || "unknown"}); reconnecting attempt ${attempts} from seq ${cursor + 1}`);
227
+ // The quiet recheck is a routine self-heal on a legitimately quiet stream
228
+ // reconnecting silently keeps a long tool call from spamming the console.
229
+ if (disconnectReason !== "quiet_recheck") {
230
+ console.warn(`[aex] event stream disconnected (${disconnectReason || "unknown"}); reconnecting attempt ${attempts} from seq ${cursor + 1}`);
231
+ }
192
232
  await sleep(reconnectDelayMs, opts.signal);
193
233
  }
194
234
  }
@@ -1,4 +1,4 @@
1
- import { AexApiError } from "./sdk-errors.js";
1
+ import { AexApiError, AexError, AexNetworkError, redactUrl } from "./sdk-errors.js";
2
2
  import { AEX_DEFAULT_BASE_URL } from "./stable.js";
3
3
  /**
4
4
  * Thin transport used by every BFF-bound operation. The SDK class and
@@ -17,7 +17,13 @@ export class HttpClient {
17
17
  }
18
18
  const raw = options.baseUrl ?? AEX_DEFAULT_BASE_URL;
19
19
  const normalized = raw.endsWith("/") ? raw : `${raw}/`;
20
- this.#baseUrl = new URL(normalized);
20
+ try {
21
+ this.#baseUrl = new URL(normalized);
22
+ }
23
+ catch (err) {
24
+ throw new Error(`HttpClient: invalid aex baseUrl ${JSON.stringify(redactUrl(raw))} — ` +
25
+ `expected an absolute URL like "${AEX_DEFAULT_BASE_URL}"`, { cause: err });
26
+ }
21
27
  this.#apiToken = options.apiToken;
22
28
  this.#fetch = options.fetch ?? fetch;
23
29
  this.#debug = options.debug;
@@ -46,11 +52,18 @@ export class HttpClient {
46
52
  }
47
53
  }
48
54
  const startedMs = Date.now();
49
- const response = await this.#fetch(url, { ...init, headers });
55
+ let response;
56
+ try {
57
+ response = await this.#fetch(url, { ...init, headers });
58
+ }
59
+ catch (err) {
60
+ throw toNetworkError(init.method, url, err);
61
+ }
50
62
  this.#trace(init.method, url, response.status, startedMs);
51
63
  const body = await readJson(response);
52
64
  if (!response.ok) {
53
- throw new AexApiError(response.status, extractErrorMessage(body), body);
65
+ const errorBody = withResponseRequestId(body, response.headers);
66
+ throw new AexApiError(response.status, extractErrorMessage(errorBody), errorBody);
54
67
  }
55
68
  return body;
56
69
  }
@@ -64,15 +77,41 @@ export class HttpClient {
64
77
  ...normalizeHeaders(init.headers)
65
78
  };
66
79
  const startedMs = Date.now();
67
- const response = await this.#fetch(url, { ...init, headers });
80
+ let response;
81
+ try {
82
+ response = await this.#fetch(url, { ...init, headers });
83
+ }
84
+ catch (err) {
85
+ throw toNetworkError(init.method, url, err);
86
+ }
68
87
  this.#trace(init.method, url, response.status, startedMs);
69
88
  if (!response.ok) {
70
89
  const body = await readJson(response);
71
- throw new AexApiError(response.status, extractErrorMessage(body), body);
90
+ const errorBody = withResponseRequestId(body, response.headers);
91
+ throw new AexApiError(response.status, extractErrorMessage(errorBody), errorBody);
72
92
  }
73
93
  return { response };
74
94
  }
75
95
  }
96
+ /**
97
+ * Wrap a fetch rejection into an {@link AexNetworkError} carrying the
98
+ * request's method + redacted host/path. Caller-initiated aborts and
99
+ * already-structured aex errors (e.g. a retry layer's AexRateLimitError or
100
+ * AexNetworkError) pass through untouched.
101
+ */
102
+ function toNetworkError(method, url, err) {
103
+ if (err instanceof AexError)
104
+ return err;
105
+ // `DOMException` is not an `Error` subclass on every runtime, so match aborts by name.
106
+ if (err?.name === "AbortError")
107
+ return err;
108
+ return new AexNetworkError({
109
+ method: (method ?? "GET").toUpperCase(),
110
+ host: url.host,
111
+ path: url.pathname,
112
+ cause: err
113
+ });
114
+ }
76
115
  function normalizeHeaders(headers) {
77
116
  if (!headers)
78
117
  return {};
@@ -93,11 +132,44 @@ async function readJson(response) {
93
132
  return { raw: text };
94
133
  }
95
134
  }
135
+ function withResponseRequestId(body, headers) {
136
+ if (!body || typeof body !== "object" || Array.isArray(body))
137
+ return body;
138
+ const record = body;
139
+ if (typeof record.requestId === "string" && record.requestId.trim())
140
+ return body;
141
+ const requestId = responseRequestId(headers);
142
+ return requestId ? { ...record, requestId } : body;
143
+ }
144
+ function responseRequestId(headers) {
145
+ for (const name of ["x-request-id", "request-id"]) {
146
+ const value = headers.get(name)?.trim();
147
+ if (value)
148
+ return value;
149
+ }
150
+ return undefined;
151
+ }
96
152
  function extractErrorMessage(body) {
97
153
  if (body && typeof body === "object") {
98
154
  const obj = body;
99
- if (typeof obj.error === "string")
155
+ if (typeof obj.error === "string") {
156
+ // A 409 `session_busy` body carries the session's CURRENT status.
157
+ // Surface it: a send to a deleted (or cancelling/suspending) session
158
+ // otherwise reads as merely "busy", which is misleading for a session
159
+ // that will never accept a turn again.
160
+ const status = body.status;
161
+ if (obj.error === "session_busy" && typeof status === "string") {
162
+ return `session_busy (session status: ${status})`;
163
+ }
164
+ // Most aex API rejections are `{error: <code>, message: <human detail>}`.
165
+ // Keep the stable code first, but don't drop the server's actionable
166
+ // detail (e.g. asset_snapshot_source_missing's "upload and finalize it
167
+ // before referencing it").
168
+ if (typeof obj.message === "string" && obj.message.length > 0 && obj.message !== obj.error) {
169
+ return `${obj.error}: ${obj.message}`;
170
+ }
100
171
  return obj.error;
172
+ }
101
173
  if (obj.error && typeof obj.error === "object" && "message" in obj.error) {
102
174
  const message = obj.error.message;
103
175
  if (typeof message === "string")
@@ -54,7 +54,34 @@ export async function listRuns(http, query) {
54
54
  params.limit = String(query.limit);
55
55
  if (query?.cursor !== undefined)
56
56
  params.cursor = query.cursor;
57
- return http.request("/api/runs", {}, params);
57
+ const page = await http.request("/api/runs", {}, params);
58
+ // Defensive contract enforcement: some deployed planes leak non-run marker
59
+ // rows (settle-time ledger/spendmark items) into the run-list index. Those
60
+ // phantoms carry only { id, createdAt } and would surface as duplicate,
61
+ // status-less RunSummary entries. Drop anything missing the fields
62
+ // RunSummary declares required, so callers can trust the published type.
63
+ // The same enforcement covers `costUsd`: deployed planes serve `null` for
64
+ // runs with no settled telemetry, but RunSummary declares `costUsd?: number`
65
+ // — normalize `null` to absent so typed callers never see it.
66
+ let changed = false;
67
+ const runs = [];
68
+ for (const run of page.runs) {
69
+ if (typeof run.id !== "string" ||
70
+ typeof run.status !== "string" ||
71
+ typeof run.createdAt !== "string" ||
72
+ typeof run.updatedAt !== "string") {
73
+ changed = true;
74
+ continue;
75
+ }
76
+ if (typeof run.costUsd !== "number" && run.costUsd !== undefined) {
77
+ const { costUsd: _dropped, ...rest } = run;
78
+ runs.push(rest);
79
+ changed = true;
80
+ continue;
81
+ }
82
+ runs.push(run);
83
+ }
84
+ return changed ? { ...page, runs } : page;
58
85
  }
59
86
  function idempotencyHeaders(options) {
60
87
  return options?.idempotencyKey ? { "Idempotency-Key": options.idempotencyKey } : undefined;
@@ -197,12 +224,22 @@ export async function outputLink(http, runId, selectorOrQuery, options) {
197
224
  method: "POST",
198
225
  body: JSON.stringify({ expiresInSeconds })
199
226
  });
227
+ const effectiveExpiresIn = result.expiresInSeconds ?? expiresInSeconds;
200
228
  return {
201
229
  ...result,
202
- expiresInSeconds: result.expiresInSeconds ?? expiresInSeconds,
230
+ expiresInSeconds: effectiveExpiresIn,
231
+ expiresAt: result.expiresAt ?? syntheticExpiresAt(effectiveExpiresIn),
203
232
  output: result.output ?? output
204
233
  };
205
234
  }
235
+ /**
236
+ * The hosted API returns `{ url, expiresInSeconds }` without an absolute
237
+ * timestamp; the documented `link.expiresAt` is synthesized client-side from
238
+ * the mint time so it is always present on a returned link.
239
+ */
240
+ function syntheticExpiresAt(expiresInSeconds) {
241
+ return new Date(Date.now() + expiresInSeconds * 1000).toISOString();
242
+ }
206
243
  export async function createOutputLink(http, runId, selectorOrQuery, options) {
207
244
  return outputLink(http, runId, selectorOrQuery, options);
208
245
  }
@@ -212,9 +249,11 @@ export async function eventArchiveLink(http, runId, options) {
212
249
  method: "POST",
213
250
  body: JSON.stringify({ expiresInSeconds })
214
251
  });
252
+ const effectiveExpiresIn = result.expiresInSeconds ?? expiresInSeconds;
215
253
  return {
216
254
  ...result,
217
- expiresInSeconds: result.expiresInSeconds ?? expiresInSeconds
255
+ expiresInSeconds: effectiveExpiresIn,
256
+ expiresAt: result.expiresAt ?? syntheticExpiresAt(effectiveExpiresIn)
218
257
  };
219
258
  }
220
259
  export function resolveOutputFileSelector(outputs, selector, runId) {
@@ -35,11 +35,11 @@ export declare const PROVIDER_PUBLIC_SUPPORT: {
35
35
  readonly href: "../../../scripts/validate/capability-matrix.test.ts";
36
36
  }, {
37
37
  readonly label: "Installed-SDK Anthropic live user test";
38
- readonly href: "../../../apps/user-tests/test/live/live-sdk-anthropic-managed.test.ts";
38
+ readonly href: "../../../apps/user-tests/test/live/providers/live-sdk-anthropic-managed.test.ts";
39
39
  }];
40
40
  readonly managedEvidence: readonly [{
41
41
  readonly label: "Installed-SDK Anthropic live user test";
42
- readonly href: "../../../apps/user-tests/test/live/live-sdk-anthropic-managed.test.ts";
42
+ readonly href: "../../../apps/user-tests/test/live/providers/live-sdk-anthropic-managed.test.ts";
43
43
  }];
44
44
  };
45
45
  readonly deepseek: {
@@ -9,7 +9,7 @@ const COMMON_EVIDENCE = [
9
9
  const ANTHROPIC_LIVE_USER_EVIDENCE = [
10
10
  {
11
11
  label: "Installed-SDK Anthropic live user test",
12
- href: "../../../apps/user-tests/test/live/live-sdk-anthropic-managed.test.ts"
12
+ href: "../../../apps/user-tests/test/live/providers/live-sdk-anthropic-managed.test.ts"
13
13
  }
14
14
  ];
15
15
  const DEEPSEEK_LIVE_USER_EVIDENCE = [
@@ -135,7 +135,7 @@ export interface RunUnit {
135
135
  * must still render *something* for a buggy historical row rather than 500ing
136
136
  * the whole detail page.
137
137
  */
138
- export declare function parseRunUnitSubmission(input: unknown): RunUnitSubmission;
138
+ export declare function parseRunUnitSubmission(input: unknown, fallbackModel?: unknown): RunUnitSubmission;
139
139
  /**
140
140
  * Normalize a `GET /api/runs/:runId` payload into a RunUnit whose type contract
141
141
  * holds AT RUNTIME. The managed (AWS) plane returns a LEAN record (scalars +
@@ -34,19 +34,19 @@ import { PLATFORM_PACKAGE_ECOSYSTEMS } from "./submission.js";
34
34
  * must still render *something* for a buggy historical row rather than 500ing
35
35
  * the whole detail page.
36
36
  */
37
- export function parseRunUnitSubmission(input) {
37
+ export function parseRunUnitSubmission(input, fallbackModel) {
38
38
  if (!input || typeof input !== "object" || Array.isArray(input)) {
39
- return fallbackFlat();
39
+ return fallbackFlat(fallbackModel);
40
40
  }
41
41
  const value = input;
42
42
  if (value.kind === "submission") {
43
- return parseFlatProjection(value);
43
+ return parseFlatProjection(value, fallbackModel);
44
44
  }
45
45
  // Snapshot exists but does not match the flat shape — surface as an
46
46
  // empty flat submission so consumers can still render lifecycle bits.
47
- return fallbackFlat();
47
+ return fallbackFlat(fallbackModel);
48
48
  }
49
- function parseFlatProjection(value) {
49
+ function parseFlatProjection(value, fallbackModel) {
50
50
  const submissionRaw = isRecord(value.submission) ? value.submission : {};
51
51
  const outputsRaw = isRecord(submissionRaw.outputs) ? submissionRaw.outputs : {};
52
52
  const allowedDirs = toOptionalStringArray(outputsRaw.allowedDirs);
@@ -56,7 +56,7 @@ function parseFlatProjection(value) {
56
56
  const maxTotalBytes = toOptionalPositiveInteger(outputsRaw.maxTotalBytes);
57
57
  const maxFiles = toOptionalPositiveInteger(outputsRaw.maxFiles);
58
58
  const submission = {
59
- model: coerceRunUnitModel(submissionRaw.model),
59
+ model: coerceRunUnitModel(submissionRaw.model ?? fallbackModel),
60
60
  ...(typeof submissionRaw.system === "string" ? { system: submissionRaw.system } : {}),
61
61
  prompt: toStringArray(submissionRaw.prompt),
62
62
  agentsMd: [],
@@ -99,11 +99,11 @@ function parseSecurityProfile(value) {
99
99
  function toOptionalPositiveInteger(value) {
100
100
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
101
101
  }
102
- function fallbackFlat() {
102
+ function fallbackFlat(fallbackModel) {
103
103
  return {
104
104
  kind: "submission",
105
105
  submission: {
106
- model: Models.CLAUDE_HAIKU_4_5,
106
+ model: coerceRunUnitModel(fallbackModel),
107
107
  prompt: [],
108
108
  agentsMd: [],
109
109
  files: [],
@@ -154,7 +154,10 @@ export function normalizeRunUnit(raw) {
154
154
  : Array.isArray(r.attempts)
155
155
  ? r.attempts.length
156
156
  : 0,
157
- submission: parseRunUnitSubmission(r.submission),
157
+ // Plane responses that project a flat record (no `submission` snapshot)
158
+ // still carry the run's `model` at the top level — prefer it over the
159
+ // static fallback so `unit()` never claims a model the run did not use.
160
+ submission: parseRunUnitSubmission(r.submission, r.model),
158
161
  ...(isRecord(r.capsSnapshot) ? { capsSnapshot: r.capsSnapshot } : {}),
159
162
  attempts: arr(r.attempts),
160
163
  events: {
@@ -69,6 +69,12 @@ export interface Session {
69
69
  readonly usage?: UsageSummary;
70
70
  readonly costUsd?: number;
71
71
  readonly errorMessage?: string | null;
72
+ /**
73
+ * Settle-written failure taxonomy for an `error` session (e.g.
74
+ * `provider-permanent`, `budget_exhausted`) — the class a caller can branch
75
+ * on, complementing the human-readable `errorMessage`.
76
+ */
77
+ readonly failureClass?: string | null;
72
78
  readonly [key: string]: unknown;
73
79
  }
74
80
  export interface SessionSummary {
@@ -153,7 +159,7 @@ export interface UsageSummary {
153
159
  readonly totalTokens?: number;
154
160
  }
155
161
  /**
156
- * Filters for {@link import("./operations.js").listRuns} / `Aex.runs.list`.
162
+ * Filters for {@link import("./operations.js").listRuns} / the CLI's `aex runs`.
157
163
  * Every field is optional; omitting all of them lists the most recent runs in the
158
164
  * token's workspace. Workspace identity is derived server-side from the API token,
159
165
  * so there is no `workspaceId` here — a token can only ever enumerate its own runs.
@@ -187,7 +193,7 @@ export interface RunListPage {
187
193
  readonly nextCursor?: string;
188
194
  }
189
195
  /**
190
- * Cross-run output search query (`Aex.outputs.search`). Restrict to a
196
+ * Cross-run output search query (`Aex.sessions.searchOutputs`). Restrict to a
191
197
  * corpus with `runIds`; filter by filename substring / extension / content type.
192
198
  * The MVP composes this client-side (per-run `listOutputs` + filter) — a future
193
199
  * server-side `GET /api/outputs/search` can back the same contract with a real
@@ -322,7 +328,7 @@ export interface OutputFileDownload {
322
328
  readonly output: Output;
323
329
  readonly bytes: Uint8Array;
324
330
  }
325
- /** Options for `Aex.outputs.read` / {@link import("./operations.js").readOutputText}. */
331
+ /** Options for `Aex.sessions.outputs(id).read` / {@link import("./operations.js").readOutputText}. */
326
332
  export interface ReadOutputTextOptions {
327
333
  /**
328
334
  * Stop reading after this many bytes. Defaults to 50_000; clamped server-side
@@ -338,7 +344,7 @@ export interface ReadOutputTextOptions {
338
344
  }
339
345
  /**
340
346
  * A byte-capped, decoded text read of one output file, as returned by
341
- * `Aex.outputs.read`. Built for feeding run deliverables to an LLM
347
+ * `Aex.sessions.outputs(id).read`. Built for feeding run deliverables to an LLM
342
348
  * without loading the whole (possibly very large) file into memory or context:
343
349
  * the read streams and stops at `maxBytes`, so `text` is at most that many bytes
344
350
  * decoded as UTF-8. Check {@link truncated} before treating `text` as complete.
@@ -368,7 +374,12 @@ export interface OutputLink {
368
374
  readonly [key: string]: unknown;
369
375
  }
370
376
  export interface WhoAmI {
371
- readonly principalType: "api_token" | "user";
377
+ /**
378
+ * Kind of principal the bearer resolved to. OPTIONAL IN PRACTICE: current
379
+ * managed deployments do not serve it (`GET /whoami` returns only
380
+ * `workspaceId` + `scopes` + `limits`) — treat `undefined` as "api_token".
381
+ */
382
+ readonly principalType?: "api_token" | "user";
372
383
  readonly workspaceId?: string;
373
384
  readonly tokenId?: string;
374
385
  readonly tokenName?: string | null;
@@ -377,9 +388,10 @@ export interface WhoAmI {
377
388
  * Workspace-level caps the BFF will enforce on subsequent calls.
378
389
  * Surfaced so consumers (e.g. broll's app-side admission gate) can
379
390
  * decide whether to keep their own gate or rely on platform headers.
380
- * All fields optional — older BFFs may omit. Numbers are concrete
381
- * snapshots at the time of the `whoami` call; `null` means no app-visible
382
- * cap is applied for that field.
391
+ * All fields optional — older BFFs may omit, and current managed
392
+ * deployments serve the newer {@link limits} block INSTEAD of `caps`.
393
+ * Numbers are concrete snapshots at the time of the `whoami` call;
394
+ * `null` means no app-visible cap is applied for that field.
383
395
  */
384
396
  readonly caps?: {
385
397
  /** Token-bucket cap on POST /api/runs per minute, per workspace. */
@@ -1,8 +1,10 @@
1
- export type AexErrorCode = "RUN_CONFIG_INVALID" | "CREDENTIAL_INVALID" | "PROVIDER_ERROR" | "RUN_STATE_ERROR" | "CLEANUP_ERROR" | "RUNTIME_UNSUPPORTED" | "API_ERROR";
1
+ export type AexErrorCode = "RUN_CONFIG_INVALID" | "CREDENTIAL_INVALID" | "PROVIDER_ERROR" | "RUN_STATE_ERROR" | "CLEANUP_ERROR" | "RUNTIME_UNSUPPORTED" | "API_ERROR" | "NETWORK_ERROR";
2
2
  export declare class AexError extends Error {
3
3
  readonly code: AexErrorCode;
4
4
  readonly details?: unknown;
5
- constructor(code: AexErrorCode, message: string, details?: unknown);
5
+ constructor(code: AexErrorCode, message: string, details?: unknown, options?: {
6
+ readonly cause?: unknown;
7
+ });
6
8
  }
7
9
  export declare class RunConfigValidationError extends AexError {
8
10
  constructor(message: string, details?: unknown);
@@ -32,3 +34,43 @@ export declare class AexApiError extends AexError {
32
34
  readonly body: unknown;
33
35
  constructor(status: number, message: string, body: unknown);
34
36
  }
37
+ /**
38
+ * Thrown when a BFF-bound request fails BEFORE any HTTP response exists — DNS
39
+ * failure, connection refused, TLS error, socket reset. Wraps the raw fetch
40
+ * rejection (whose undici form is a bare `TypeError: fetch failed` with the
41
+ * useful code hidden on `cause.code`) into a message that names the request
42
+ * and the transport failure, e.g.
43
+ * `POST api.aex.dev/assets/presign failed: ECONNREFUSED (connect ECONNREFUSED 127.0.0.1:443)`.
44
+ * The original rejection is preserved on `cause`.
45
+ */
46
+ export declare class AexNetworkError extends AexError {
47
+ readonly method: string;
48
+ /** Request host — never carries credentials or the query string. */
49
+ readonly host: string;
50
+ readonly path: string;
51
+ /** Transport failure code (e.g. `ECONNREFUSED`), when detectable. */
52
+ readonly causeCode: string | undefined;
53
+ /** Attempts made when a retry layer exhausted its budget; `1` otherwise. */
54
+ readonly attempts: number;
55
+ constructor(args: {
56
+ readonly method: string;
57
+ readonly host: string;
58
+ readonly path: string;
59
+ readonly cause: unknown;
60
+ /** Set by the retry layer when it gave up: appended to the message. */
61
+ readonly attempts?: number;
62
+ readonly elapsedMs?: number;
63
+ });
64
+ }
65
+ /**
66
+ * Best-effort transport error code (`ECONNREFUSED`, `ENOTFOUND`, …): checks
67
+ * `err.code`, then `err.cause.code` (where undici hides it), then falls back
68
+ * to an `E…`-shaped token in the message.
69
+ */
70
+ export declare function extractErrorCode(err: unknown): string | undefined;
71
+ /**
72
+ * Redact a URL down to protocol + host + path: credentials become
73
+ * `[redacted]@` and any query string becomes `?[redacted]` (presigned URLs
74
+ * carry signatures there). Tolerates unparseable input.
75
+ */
76
+ export declare function redactUrl(url: string): string;