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.
- package/CHANGELOG.md +11 -0
- package/README.md +9 -7
- package/docs/delegated-result-protocol.md +20 -0
- package/docs/evidence-artifacts.md +37 -5
- package/package.json +1 -1
- package/scripts/install-local-launcher.js +23 -1
- package/scripts/verify-package.js +1 -0
- package/src/activity-log-status.js +291 -0
- package/src/activity-log.js +91 -3
- package/src/cli.js +198 -37
- package/src/delegated-result-admission.js +13 -0
- package/src/evidence-store.js +93 -23
- package/src/provider-capacity-codex.js +2 -1
- package/src/providers/executable.js +17 -4
- package/src/providers/index.js +109 -5
- package/src/telegram-ingress/core.js +180 -61
- package/src/worker-control.js +11 -0
package/src/evidence-store.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
]
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/src/providers/index.js
CHANGED
|
@@ -6,7 +6,8 @@ import {buildKimiCommand, createKimiCompletion, createKimiParser, createKimiSess
|
|
|
6
6
|
import {buildOpenCodeCommand, createOpenCodeParser, createOpenCodeSessionId} from "./opencode.js"
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* @typedef {{
|
|
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
|
+
}
|