threadwire 0.1.13 → 0.1.15

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.
@@ -2,11 +2,12 @@
2
2
 
3
3
  import {buildClaudeCommand, claudeSessionId, createClaudeParser} from "./claude.js"
4
4
  import {buildCodexCommand, codexSessionId, parseCodexEvent} from "./codex.js"
5
- import {buildKimiCommand, createKimiParser, createKimiSessionId} from "./kimi.js"
5
+ import {buildKimiCommand, createKimiCompletion, createKimiParser, createKimiSessionId} from "./kimi.js"
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}} 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()}
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
+ }
@@ -67,6 +67,36 @@ export function createKimiSessionId() {
67
67
  }
68
68
  }
69
69
 
70
+ /**
71
+ * Provider-owned protocol completion. Kimi confirms a completed run with a
72
+ * final assistant response (assistant content without further tool calls) plus
73
+ * its session handoff record; the two records may arrive in either order. The
74
+ * detector reports completion exactly once and never treats intermediate tool
75
+ * activity as completion.
76
+ * @returns {(record: unknown) => boolean}
77
+ */
78
+ export function createKimiCompletion() {
79
+ let finalResponseSeen = false
80
+ let resumeHintSeen = false
81
+ let completed = false
82
+ return (record) => {
83
+ if (completed || !isRecord(record)) return false
84
+ if (isFinalAssistantResponse(record)) finalResponseSeen = true
85
+ else if (recognizeKimiRecord(record, undefined).sessionId !== undefined) resumeHintSeen = true
86
+ if (!finalResponseSeen || !resumeHintSeen) return false
87
+ completed = true
88
+ return true
89
+ }
90
+ }
91
+
92
+ /** @param {Record<string, unknown>} record */
93
+ function isFinalAssistantResponse(record) {
94
+ return record.role === "assistant"
95
+ && exactOptionalKeys(record, ["role"], ["content", "tool_calls"])
96
+ && typeof record.content === "string"
97
+ && record.tool_calls === undefined
98
+ }
99
+
70
100
  /** @returns {(record: unknown) => import("../types.js").WorkerEvent[]} */
71
101
  export function createKimiParser() {
72
102
  const state = createKimiRecordState()