threadwire 0.1.14 → 0.1.17

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.
@@ -30,6 +30,9 @@ const DEFAULT_LIMITS = Object.freeze({
30
30
  retentionMs: 7 * 24 * 60 * 60 * 1000
31
31
  })
32
32
  const TEXT_CONTENT_TYPES = new Set(["application/json", "application/x-ndjson"])
33
+ const DELIVERY_STATES = new Set([
34
+ "delivery_pending", "delivery_succeeded", "delivery_failed", "delivery_not_requested"
35
+ ])
33
36
 
34
37
  export class EvidenceAccessError extends Error {
35
38
  constructor() {
@@ -80,8 +83,6 @@ export class EvidenceStore {
80
83
  this.capabilities = new Map()
81
84
  /** @type {WeakSet<object>} */
82
85
  this.scopes = new WeakSet()
83
- /** @type {Map<string, Set<string>>} */
84
- this.redactions = new Map()
85
86
  /** @type {Map<string, number>} */
86
87
  this.activeReaders = new Map()
87
88
  this.storeBytes = baseBytes
@@ -177,7 +178,7 @@ export class EvidenceStore {
177
178
 
178
179
  /**
179
180
  * @param {{destinationId: string, runId: string}} owner
180
- * @param {{contentType: string, redactions?: string[]}} options
181
+ * @param {{contentType: string, redactions?: string[], delivery?: EvidenceDelivery}} options
181
182
  */
182
183
  async createArtifact(owner, options) {
183
184
  this.assertScope(owner)
@@ -186,13 +187,15 @@ export class EvidenceStore {
186
187
  const id = randomBytes(32).toString("base64url")
187
188
  const handle = `evidence_${randomBytes(32).toString("base64url")}`
188
189
  const now = this.clock()
190
+ const delivery = options.delivery === undefined ? undefined : validateDelivery(options.delivery)
189
191
  const metadata = /** @type {EvidenceMetadata} */ ({
190
192
  version: 2, id, state: "pending", owner: {...owner}, contentType,
191
193
  capabilityHash: capabilityHash(handle),
192
194
  policyVersion: 1, framingVersion: 1,
193
195
  redactionPolicy: {algorithm: "aes-256-gcm", nonce: "", ciphertext: "", tag: ""},
194
196
  payloadCommitment: "A".repeat(43),
195
- bytes: 0, storedBytes: 0, lines: 0, events: 0, createdAt: now, expiresAt: now + this.limits.retentionMs
197
+ bytes: 0, storedBytes: 0, lines: 0, events: 0, createdAt: now, expiresAt: now + this.limits.retentionMs,
198
+ ...(delivery === undefined ? {} : {delivery})
196
199
  })
197
200
  metadata.redactionPolicy = encryptRedactions(handle, redactions, metadata)
198
201
  const overheadBytes = durableArtifactReservation(metadata)
@@ -230,11 +233,10 @@ export class EvidenceStore {
230
233
  if (!file) throw new Error("Evidence pending payload is unavailable")
231
234
  this.artifacts.set(id, metadata)
232
235
  this.capabilities.set(metadata.capabilityHash, id)
233
- this.redactions.set(id, new Set(redactions))
234
236
  return new EvidenceWriter(this, metadata, file, handle, finishOperation, redactions, owner)
235
237
  }
236
238
 
237
- /** @param {object} owner @param {unknown} request */
239
+ /** @param {object} owner @param {unknown} request @returns {Promise<EvidenceReadResult>} */
238
240
  async read(owner, request) {
239
241
  this.assertScope(owner)
240
242
  const validated = validateEvidenceReadRequest(request, this.limits)
@@ -284,14 +286,16 @@ export class EvidenceStore {
284
286
  data: binary.toString("base64"),
285
287
  encoding: "base64",
286
288
  redacted,
287
- truncated: truncated || binary.length < selected.length
289
+ truncated: truncated || binary.length < selected.length,
290
+ ...deliveryResult(metadata)
288
291
  }, this.limits.maxReadBytes)
289
292
  }
290
293
  return fitSerializedResult({
291
294
  contentType: metadata.contentType,
292
295
  data: decodeUtf8(selected),
293
296
  redacted,
294
- truncated
297
+ truncated,
298
+ ...deliveryResult(metadata)
295
299
  }, this.limits.maxReadBytes)
296
300
  } finally {
297
301
  const readers = (this.activeReaders.get(id) ?? 1) - 1
@@ -301,7 +305,7 @@ export class EvidenceStore {
301
305
  }
302
306
  }
303
307
 
304
- /** @param {unknown} request */
308
+ /** @param {unknown} request @returns {Promise<EvidenceReadResult>} */
305
309
  async readBearer(request) {
306
310
  const validated = validateEvidenceReadRequest(request, this.limits)
307
311
  const id = this.lookupCapability(validated.handle)
@@ -317,7 +321,44 @@ export class EvidenceStore {
317
321
  }
318
322
  }
319
323
 
320
- /** @param {{chatId: string, threadId: number | null, senderId: string}} requester @param {unknown} request */
324
+ /** @param {object} owner @param {string} handle @param {"delivery_succeeded" | "delivery_failed"} state */
325
+ recordDelivery(owner, handle, state) {
326
+ const finish = this.beginOperation()
327
+ return this.enqueueMaintenance(() => this.recordDeliveryOnce(owner, handle, state)).finally(finish)
328
+ }
329
+
330
+ /** @param {object} owner @param {string} handle @param {"delivery_succeeded" | "delivery_failed"} state */
331
+ async recordDeliveryOnce(owner, handle, state) {
332
+ await this.assertRootIdentity()
333
+ this.assertScope(owner)
334
+ if (state !== "delivery_succeeded" && state !== "delivery_failed") throw new Error("Invalid terminal delivery state")
335
+ const id = this.lookupCapability(handle)
336
+ if (!id) throw new EvidenceAccessError()
337
+ const metadata = this.artifacts.get(id)
338
+ if (!metadata || metadata.state !== "ready" || !sameOwner(metadata.owner, owner) || metadata.delivery === undefined) {
339
+ throw new EvidenceAccessError()
340
+ }
341
+ if (metadata.delivery.state !== "delivery_pending") throw new Error("Evidence delivery state is already terminal")
342
+ const redactions = decryptRedactions(handle, metadata.redactionPolicy, metadata)
343
+ const updated = /** @type {EvidenceMetadata} */ ({
344
+ ...metadata,
345
+ delivery: {identity: metadata.delivery.identity, state}
346
+ })
347
+ updated.reservedBytes = durableArtifactReservation(updated)
348
+ updated.redactionPolicy = encryptRedactions(handle, redactions, updated)
349
+ const reservationDelta = updated.reservedBytes - metadata.reservedBytes
350
+ if (reservationDelta > 0) this.reserve(metadata.owner.runId, reservationDelta, 0)
351
+ try {
352
+ await atomicJson(this.root, `${id}${META_SUFFIX}`, updated)
353
+ } catch (error) {
354
+ if (reservationDelta > 0) this.reserve(metadata.owner.runId, -reservationDelta, 0)
355
+ throw error
356
+ }
357
+ if (reservationDelta < 0) this.reserve(metadata.owner.runId, reservationDelta, 0)
358
+ this.artifacts.set(id, updated)
359
+ }
360
+
361
+ /** @param {{chatId: string, threadId: number | null, senderId: string}} requester @param {unknown} request @returns {Promise<EvidenceReadResult>} */
321
362
  async readTelegram(requester, request) {
322
363
  const validated = validateEvidenceReadRequest(request, this.limits)
323
364
  const id = this.lookupCapability(validated.handle)
@@ -352,7 +393,6 @@ export class EvidenceStore {
352
393
  this.capabilities.delete(metadata.capabilityHash)
353
394
  this.release(metadata)
354
395
  this.artifactSlots -= 1
355
- this.redactions.delete(id)
356
396
  }
357
397
  }
358
398
 
@@ -379,7 +419,6 @@ export class EvidenceStore {
379
419
  this.capabilities.delete(metadata.capabilityHash)
380
420
  this.release(metadata)
381
421
  this.artifactSlots -= 1
382
- this.redactions.delete(id)
383
422
  }
384
423
 
385
424
  /** @template T @param {() => Promise<T>} operation @returns {Promise<T>} */
@@ -435,7 +474,6 @@ export class EvidenceStore {
435
474
  this.artifactSlots += 1
436
475
  this.artifacts.set(id, metadata)
437
476
  this.capabilities.set(metadata.capabilityHash, id)
438
- this.redactions.set(id, new Set(this.runtimeRedactions))
439
477
  } catch {
440
478
  await Promise.all([...files].map((suffix) => safeUnlink(this.path(id, suffix))))
441
479
  }
@@ -601,7 +639,6 @@ class EvidenceWriter {
601
639
  this.store.artifactSlots -= 1
602
640
  this.store.artifacts.delete(this.metadata.id)
603
641
  this.store.capabilities.delete(this.metadata.capabilityHash)
604
- this.store.redactions.delete(this.metadata.id)
605
642
  await safeUnlink(this.store.path(this.metadata.id, PENDING_SUFFIX))
606
643
  await safeUnlink(this.store.path(this.metadata.id, PAYLOAD_SUFFIX))
607
644
  await safeUnlink(this.store.path(this.metadata.id, META_SUFFIX))
@@ -632,7 +669,6 @@ class EvidenceWriter {
632
669
  this.store.artifactSlots -= 1
633
670
  this.store.artifacts.delete(this.metadata.id)
634
671
  this.store.capabilities.delete(this.metadata.capabilityHash)
635
- this.store.redactions.delete(this.metadata.id)
636
672
  await safeUnlink(this.store.path(this.metadata.id, PENDING_SUFFIX))
637
673
  await safeUnlink(this.store.path(this.metadata.id, META_SUFFIX))
638
674
  await syncDirectory(this.store.root)
@@ -875,7 +911,7 @@ function utf8Prefix(buffer, limit) {
875
911
  return buffer.subarray(0, limit)
876
912
  }
877
913
 
878
- /** @param {{contentType: string, data: string, encoding?: string, redacted: boolean, truncated: boolean}} result @param {number} limit */
914
+ /** @param {EvidenceReadResult} result @param {number} limit @returns {EvidenceReadResult} */
879
915
  function fitSerializedResult(result, limit) {
880
916
  if (Buffer.byteLength(`${JSON.stringify(result)}\n`) <= limit) return result
881
917
  let bytes = Buffer.from(result.data, "utf8")
@@ -1313,14 +1349,16 @@ function decryptRedactions(handle, policy, metadata) {
1313
1349
 
1314
1350
  /** @param {EvidenceMetadata} metadata */
1315
1351
  function redactionAad(metadata) {
1316
- return Buffer.from(JSON.stringify([
1352
+ const values = [
1317
1353
  "threadwire-evidence-policy", metadata.policyVersion, metadata.version, metadata.framingVersion, metadata.id,
1318
1354
  metadata.state,
1319
1355
  metadata.capabilityHash, metadata.owner.destinationId, metadata.owner.runId,
1320
1356
  metadata.contentType, metadata.createdAt, metadata.expiresAt,
1321
1357
  metadata.bytes, metadata.storedBytes, metadata.reservedBytes,
1322
1358
  metadata.lines, metadata.events, metadata.payloadCommitment
1323
- ]), "utf8")
1359
+ ]
1360
+ if (metadata.delivery !== undefined) values.push(metadata.delivery.identity, metadata.delivery.state)
1361
+ return Buffer.from(JSON.stringify(values), "utf8")
1324
1362
  }
1325
1363
 
1326
1364
  /** @param {EvidenceMetadata} metadata */
@@ -1374,6 +1412,22 @@ function validateRedactions(values) {
1374
1412
  return values
1375
1413
  }
1376
1414
 
1415
+ /** @param {unknown} value @returns {EvidenceDelivery} */
1416
+ function validateDelivery(value) {
1417
+ if (
1418
+ !plainObject(value) || !exactDataKeys(value, ["identity", "state"])
1419
+ || typeof value.identity !== "string"
1420
+ || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value.identity)
1421
+ || typeof value.state !== "string" || !DELIVERY_STATES.has(value.state)
1422
+ ) throw new Error("Invalid evidence delivery metadata")
1423
+ return /** @type {EvidenceDelivery} */ ({identity: value.identity, state: value.state})
1424
+ }
1425
+
1426
+ /** @param {EvidenceMetadata} metadata */
1427
+ function deliveryResult(metadata) {
1428
+ return metadata.delivery === undefined ? {} : {delivery: {...metadata.delivery}}
1429
+ }
1430
+
1377
1431
  /** @param {Record<string, unknown>} left @param {object} right */
1378
1432
  function sameOwner(left, right) {
1379
1433
  return left.destinationId === /** @type {{destinationId?: unknown}} */ (right).destinationId
@@ -1382,11 +1436,13 @@ function sameOwner(left, right) {
1382
1436
 
1383
1437
  /** @param {unknown} value @param {string} id @param {EvidenceLimits} limits */
1384
1438
  function validMetadata(value, id, limits) {
1439
+ const metadataKeys = [
1440
+ "version", "policyVersion", "framingVersion", "id", "state", "owner", "contentType", "capabilityHash",
1441
+ "redactionPolicy", "payloadCommitment", "bytes", "storedBytes", "reservedBytes", "lines", "events", "createdAt", "expiresAt"
1442
+ ]
1443
+ if (plainObject(value) && value.delivery !== undefined) metadataKeys.push("delivery")
1385
1444
  return plainObject(value) && value.version === 2 && value.id === id && value.state === "ready"
1386
- && exactKeys(value, [
1387
- "version", "policyVersion", "framingVersion", "id", "state", "owner", "contentType", "capabilityHash",
1388
- "redactionPolicy", "payloadCommitment", "bytes", "storedBytes", "reservedBytes", "lines", "events", "createdAt", "expiresAt"
1389
- ])
1445
+ && exactKeys(value, metadataKeys)
1390
1446
  && value.policyVersion === 1 && value.framingVersion === 1
1391
1447
  && plainObject(value.owner) && typeof value.owner.destinationId === "string" && validIdentity(value.owner.destinationId)
1392
1448
  && typeof value.owner.runId === "string" && validIdentity(value.owner.runId)
@@ -1404,6 +1460,17 @@ function validMetadata(value, id, limits) {
1404
1460
  && typeof value.events === "number" && nonnegative(value.events) && value.events <= limits.maxArtifactEvents
1405
1461
  && typeof value.createdAt === "number" && typeof value.expiresAt === "number"
1406
1462
  && nonnegative(value.createdAt) && positive(value.expiresAt) && value.createdAt < value.expiresAt
1463
+ && (value.delivery === undefined || validDelivery(value.delivery))
1464
+ }
1465
+
1466
+ /** @param {unknown} value */
1467
+ function validDelivery(value) {
1468
+ try {
1469
+ validateDelivery(value)
1470
+ return true
1471
+ } catch {
1472
+ return false
1473
+ }
1407
1474
  }
1408
1475
 
1409
1476
  /** @param {unknown} value */
@@ -1455,8 +1522,11 @@ function validateLimits(limits) {
1455
1522
  * owner: {destinationId: string, runId: string}, contentType: string,
1456
1523
  * capabilityHash: string,
1457
1524
  * redactionPolicy: {algorithm: "aes-256-gcm", nonce: string, ciphertext: string, tag: string}, payloadCommitment: string,
1458
- * bytes: number, storedBytes: number, reservedBytes: number, lines: number, events: number, createdAt: number, expiresAt: number
1525
+ * bytes: number, storedBytes: number, reservedBytes: number, lines: number, events: number, createdAt: number, expiresAt: number,
1526
+ * delivery?: EvidenceDelivery
1459
1527
  * }} EvidenceMetadata
1528
+ * @typedef {{identity: string, state: "delivery_pending" | "delivery_succeeded" | "delivery_failed" | "delivery_not_requested"}} EvidenceDelivery
1529
+ * @typedef {{contentType: string, data: string, encoding?: string, redacted: boolean, truncated: boolean, delivery?: EvidenceDelivery}} EvidenceReadResult
1460
1530
  * @typedef {{
1461
1531
  * handle: string,
1462
1532
  * bytes?: {offset: number, limit: number},
@@ -16,6 +16,7 @@ const MAX_LINES = 1_024
16
16
  * failures classify with fixed, credential-free messages.
17
17
  * @param {{
18
18
  * env?: NodeJS.ProcessEnv,
19
+ * executable?: string | undefined,
19
20
  * spawnImplementation?: typeof spawn | undefined,
20
21
  * timeoutMs?: number
21
22
  * }} options
@@ -25,7 +26,7 @@ export async function probeCodexCapacity(options) {
25
26
  const environment = options.env ?? process.env
26
27
  const spawnImplementation = options.spawnImplementation ?? spawn
27
28
  const timeoutMs = options.timeoutMs ?? DEFAULT_CAPACITY_TIMEOUT_MS
28
- const executable = providerExecutable("THREADWIRE_CODEX_BIN", DEFAULT_EXECUTABLE, environment)
29
+ const executable = options.executable ?? providerExecutable("THREADWIRE_CODEX_BIN", DEFAULT_EXECUTABLE, environment)
29
30
  const result = await appServerExchange(spawnImplementation, executable, environment, timeoutMs)
30
31
  return normalizeCodexRateLimits(result)
31
32
  }
@@ -1,10 +1,11 @@
1
1
  // @ts-check
2
2
 
3
+ import {accessSync, constants, statSync} from "node:fs"
4
+
3
5
  /**
4
6
  * Resolve the terminal provider executable Threadwire spawns. The default is the
5
- * structurally separate libexec adapter (never the /opt/data/bin front-door), so
6
- * relaying can never recurse. A non-empty environment override supports staged
7
- * cutover and rollback (for example, pointing back at the previous shim path).
7
+ * real provider CLI. A non-empty environment override supports staged cutover
8
+ * and rollback, but only when the override path exists and is executable.
8
9
  * @param {string} overrideVariable
9
10
  * @param {string} defaultPath
10
11
  * @param {NodeJS.ProcessEnv} [environment]
@@ -12,5 +13,17 @@
12
13
  */
13
14
  export function providerExecutable(overrideVariable, defaultPath, environment = process.env) {
14
15
  const override = environment[overrideVariable]
15
- return override !== undefined && override.length > 0 ? override : defaultPath
16
+ if (override !== undefined && override.length > 0) {
17
+ try {
18
+ accessSync(override, constants.X_OK)
19
+ if (!statSync(override).isFile()) {
20
+ console.error(`threadwire: ${overrideVariable}=${override} is a directory, falling back to ${defaultPath}`)
21
+ return defaultPath
22
+ }
23
+ return override
24
+ } catch {
25
+ console.error(`threadwire: ${overrideVariable}=${override} is not executable, falling back to ${defaultPath}`)
26
+ }
27
+ }
28
+ return defaultPath
16
29
  }
@@ -6,7 +6,8 @@ import {buildKimiCommand, createKimiCompletion, createKimiParser, createKimiSess
6
6
  import {buildOpenCodeCommand, createOpenCodeParser, createOpenCodeSessionId} from "./opencode.js"
7
7
 
8
8
  /**
9
- * @typedef {{name: "codex" | "claude" | "kimi" | "opencode", executable: string, arguments: string[], parse: (record: unknown) => import("../types.js").WorkerEvent[], sessionId: (record: unknown) => string | undefined, completion?: (record: unknown) => boolean}} Provider
9
+ * @typedef {{disposition: "retrying" | "blocked", category: "authentication" | "permission" | "rate-limit" | "quota" | "billing" | "model" | "network" | "protocol" | "unknown", retryAfterMs?: number}} ProviderHealth
10
+ * @typedef {{name: "codex" | "claude" | "kimi" | "opencode", executable: string, arguments: string[], parse: (record: unknown) => import("../types.js").WorkerEvent[], sessionId: (record: unknown) => string | undefined, health: (record: unknown) => ProviderHealth | undefined, completion?: (record: unknown) => boolean}} Provider
10
11
  */
11
12
 
12
13
  /** @type {readonly ["codex", "claude", "kimi", "opencode"]} */
@@ -16,19 +17,122 @@ export const PROVIDERS = ["codex", "claude", "kimi", "opencode"]
16
17
  export function createProvider(name, providerArguments, prompt, resumeSession, environment = process.env) {
17
18
  if (name === "codex") {
18
19
  const command = buildCodexCommand(providerArguments, prompt, resumeSession, environment)
19
- return {...command, name: "codex", parse: parseCodexEvent, sessionId: codexSessionId}
20
+ return {...command, name: "codex", parse: parseCodexEvent, sessionId: codexSessionId, health: extractProviderHealth}
20
21
  }
21
22
  if (name === "claude") {
22
23
  const command = buildClaudeCommand(providerArguments, prompt, resumeSession, environment)
23
- return {...command, name: "claude", parse: createClaudeParser(), sessionId: claudeSessionId}
24
+ return {...command, name: "claude", parse: createClaudeParser(), sessionId: claudeSessionId, health: extractProviderHealth}
24
25
  }
25
26
  if (name === "opencode") {
26
27
  const command = buildOpenCodeCommand(providerArguments, prompt, resumeSession, environment)
27
- return {...command, name: "opencode", parse: createOpenCodeParser(), sessionId: createOpenCodeSessionId()}
28
+ return {...command, name: "opencode", parse: createOpenCodeParser(), sessionId: createOpenCodeSessionId(), health: extractProviderHealth}
28
29
  }
29
30
  if (name === "kimi") {
30
31
  const command = buildKimiCommand(providerArguments, prompt, resumeSession, environment)
31
- return {...command, name: "kimi", parse: createKimiParser(), sessionId: createKimiSessionId(), completion: createKimiCompletion()}
32
+ return {...command, name: "kimi", parse: createKimiParser(), sessionId: createKimiSessionId(), completion: createKimiCompletion(), health: extractProviderHealth}
32
33
  }
33
34
  throw new Error(`--provider must be one of: ${PROVIDERS.join(", ")}`)
34
35
  }
36
+
37
+ /**
38
+ * Extract safe provider health information from a structured provider record.
39
+ * Inspects only an explicit small list of safe containers — the top-level
40
+ * record and known nested `error`, `part`, `part.error`, or `*.data` objects —
41
+ * never traverses arbitrarily nested objects and never regexes raw message
42
+ * text. Generic HTTP 429 → retrying rate-limit. Structured
43
+ * exceeded_current_quota_error / insufficient_quota / insufficient_balance →
44
+ * blocked quota/billing. If no structured fields are exposed — common when a
45
+ * provider protocol suppresses upstream error details — no health fact is
46
+ * written and the run status remains running/unknown; Hermes must perform an
47
+ * independent bounded provider probe.
48
+ * @param {unknown} record
49
+ * @returns {ProviderHealth | undefined}
50
+ */
51
+ export function extractProviderHealth(record) {
52
+ if (!isRecord(record)) return undefined
53
+
54
+ // Explicit containers to inspect: top-level record, record.error, record.part,
55
+ // record.part.error. Only these bounded paths; never recursive traversal.
56
+ const containers = [record]
57
+ if (isRecord(record.error)) containers.push(record.error)
58
+ if (isRecord(record.part)) {
59
+ containers.push(record.part)
60
+ if (isRecord(record.part.error)) containers.push(record.part.error)
61
+ }
62
+
63
+ for (const container of containers) {
64
+ const health = extractFromContainer(container)
65
+ if (health !== undefined) return health
66
+ }
67
+
68
+ return undefined
69
+ }
70
+
71
+ /**
72
+ * Inspect one safe container for structured health fields.
73
+ * @param {Record<string, unknown>} container
74
+ * @returns {ProviderHealth | undefined}
75
+ */
76
+ function extractFromContainer(container) {
77
+ // Inspect structured data sub-object when present.
78
+ if (isRecord(container.data)) return extractFromContainer(container.data)
79
+
80
+ const httpStatus = container.http_status ?? container.httpStatus ?? container.status_code ?? container.statusCode
81
+ if (typeof httpStatus === "number" && Number.isSafeInteger(httpStatus)) {
82
+ if (httpStatus === 429) {
83
+ const retryAfterMs = safeRetryMs(container.retryAfterMs ?? container.retry_after_ms)
84
+ return {disposition: "retrying", category: "rate-limit", ...(retryAfterMs === undefined ? {} : {retryAfterMs})}
85
+ }
86
+ if (httpStatus >= 500 && httpStatus < 600) {
87
+ return {disposition: "retrying", category: "unknown"}
88
+ }
89
+ }
90
+
91
+ const code = container.code ?? container.error_code ?? container.errorCode ?? container.error_type ?? container.errorType
92
+ if (typeof code === "string") {
93
+ if (code === "exceeded_current_quota_error" || code === "insufficient_quota" || code === "quota_exceeded") {
94
+ return {disposition: "blocked", category: "quota"}
95
+ }
96
+ if (code === "insufficient_balance" || code === "billing_error" || code === "payment_required") {
97
+ return {disposition: "blocked", category: "billing"}
98
+ }
99
+ if (code === "invalid_api_key" || code === "unauthorized" || code === "auth_error" || code === "authentication_error") {
100
+ return {disposition: "blocked", category: "authentication"}
101
+ }
102
+ if (code === "forbidden" || code === "permission_denied") {
103
+ return {disposition: "blocked", category: "permission"}
104
+ }
105
+ if (code === "model_not_found" || code === "invalid_model" || code === "model_unavailable") {
106
+ return {disposition: "blocked", category: "model"}
107
+ }
108
+ }
109
+
110
+ if (container.insufficient_quota === true || container.quota_exceeded === true) {
111
+ return {disposition: "blocked", category: "quota"}
112
+ }
113
+ if (container.insufficient_balance === true) {
114
+ return {disposition: "blocked", category: "billing"}
115
+ }
116
+
117
+ return undefined
118
+ }
119
+
120
+ /**
121
+ * Extract a safe retry duration in milliseconds. Only explicit millisecond
122
+ * fields (retryAfterMs, retry_after_ms) are used; ambiguous short fields such
123
+ * as retry_after (seconds) are never interpreted as milliseconds.
124
+ * @param {unknown} value
125
+ */
126
+ function safeRetryMs(value) {
127
+ if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) return value
128
+ if (typeof value === "string" && /^\d+$/u.test(value)) {
129
+ const parsed = Number(value)
130
+ if (Number.isSafeInteger(parsed) && parsed > 0) return parsed
131
+ }
132
+ return undefined
133
+ }
134
+
135
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
136
+ function isRecord(value) {
137
+ return typeof value === "object" && value !== null && !Array.isArray(value)
138
+ }